diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 289b5a2..2d7bd6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,23 @@ jobs: - name: Run tests run: cargo test --workspace + # Overrides must match GitHub's canonical repository names. Catch typos, + # renamed/deleted repositories, and incorrect casing before an override is + # silently skipped. + # Limitation: `github.token` cannot read private repositories, so those fail here with a 404. + - name: Validate policy repositories + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + for repository in $(yq '.repositories | keys | .[]' zizmor-policy.yml); do + canonical=$(gh api "repos/$repository" --jq .full_name) + if [[ "$repository" != "$canonical" ]]; then + echo "Policy repository '$repository' must use GitHub's canonical name '$canonical'." >&2 + exit 1 + fi + done + typos: name: Typos runs-on: ubuntu-24.04 diff --git a/.github/workflows/crabwatch.yml b/.github/workflows/crabwatch.yml index c51edcd..9048f76 100644 --- a/.github/workflows/crabwatch.yml +++ b/.github/workflows/crabwatch.yml @@ -27,11 +27,23 @@ jobs: persist-credentials: false - name: Download crabwatch zizmor config + shell: bash env: GH_TOKEN: ${{ github.token }} run: | - gh api repos/rust-lang/crabwatch/contents/zizmor-default.yml \ - -H "Accept: application/vnd.github.raw+json" > zizmor-default.yml + gh api repos/rust-lang/crabwatch/contents/zizmor-policy.yml \ + -H "Accept: application/vnd.github.raw+json" > "$RUNNER_TEMP/zizmor-policy.yml" + + # Apply this repository's overrides to the default configuration. + # zizmor-action runs zizmor in a container that mounts only the + # workspace, so the config must be written inside it. + yq --exit-status \ + '.default * (.repositories[strenv(GITHUB_REPOSITORY)] // {})' \ + "$RUNNER_TEMP/zizmor-policy.yml" > crabwatch-zizmor.yml + + echo "::group::Effective zizmor configuration for $GITHUB_REPOSITORY" + cat crabwatch-zizmor.yml + echo "::endgroup::" - name: Run zizmor # A missing or empty root .github directory has nothing to audit. @@ -39,7 +51,7 @@ jobs: uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 with: advanced-security: false - config: zizmor-default.yml + config: crabwatch-zizmor.yml # Only lint the root .github directory. # Ignore nested .github directories because they can belong to # vendored projects or test fixtures. diff --git a/Cargo.toml b/Cargo.toml index 2b43955..421370b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ serde_json = "1" futures = "0.3.33" log = "0.4.33" env_logger = "0.11.11" +tempfile = "3.27.0" [dev-dependencies] insta = "1.48.0" -tempfile = "3.27.0" diff --git a/README.md b/README.md index e888686..df7ac56 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,38 @@ It provides: ## Checks Crabwatch runs [zizmor](https://docs.zizmor.sh/) with the -[`zizmor-default.yml`](./zizmor-default.yml) configuration file, maintained by -the Rust Infrastructure team. +[`zizmor-policy.yml`](./zizmor-policy.yml) policy, maintained by the Rust +Infrastructure team. Its `default` section contains the shared zizmor +configuration, and `repositories` contains repository-specific overrides. + +### Repository overrides + +Add an `owner/repository` entry under `repositories` to change a rule's settings +for that repository, for example to disable a rule or to opt in to a rule that is +disabled by default. +Overrides are deep-merged into the default, so unspecified settings are +inherited, but a list such as `ignore` replaces the default's list rather than +extending it. +CI validates the policy's structure and that every repository key is GitHub's +canonical `owner/repository` name, since the lookup is case-sensitive. + +Example (hypothetical): + +```yaml +repositories: + rust-lang/rust-clippy: + rules: + bot-conditions: + # Accepted exception for this repository's automation pattern. + disable: true + rust-lang/rust: + rules: + bot-conditions: + disable: true + overprovisioned-secrets: + # Temporary exception while its workflows are migrated. + disable: true +``` ## Design principles @@ -42,6 +72,9 @@ manually auditing repositories with the same configuration. ## CLI usage +The CLI determines the repository's configuration in the same way as the workflow, +so you need to install [mikefarah's `yq` v4](https://github.com/mikefarah/yq#install). + Analyze every eligible repository in a GitHub organization: ```console diff --git a/src/command/analyze.rs b/src/command/analyze.rs index 019f361..9e695f4 100644 --- a/src/command/analyze.rs +++ b/src/command/analyze.rs @@ -1,4 +1,4 @@ -use crate::{clone, github, scan}; +use crate::{clone, config, github, scan}; use anyhow::{Context as _, anyhow, bail}; use futures::stream::StreamExt; use std::path::{Path, PathBuf}; @@ -104,10 +104,14 @@ async fn analyze_repo( client: &reqwest::Client, parsed: &ParsedRepo, crabwatch_dir: &Path, - zizmor_config: &Path, + policy: &config::ZizmorPolicy, github_token: &str, ) -> anyhow::Result<(String, scan::ScanOutcome)> { - let sha = github::fetch_head_commit(client, &parsed.org, &parsed.repo, github_token).await?; + let head = github::fetch_head_commit(client, &parsed.org, &parsed.repo, github_token).await?; + // GitHub's canonical name selects the policy overrides. Kept alive until + // the scan finishes; the file is deleted on drop. + let config_file = policy.write_config(&head.name_with_owner).await?; + let sha = head.sha; log::debug!("HEAD commit: {sha}"); let path = cache_path(parsed, crabwatch_dir, &sha); let repo_cache_dir = path @@ -127,7 +131,7 @@ async fn analyze_repo( clone::clone_repo(&parsed.org, &parsed.repo, github_token, &path, &sha).await?; } - let report = scan::scan_workflows(&path, zizmor_config, github_token).await?; + let report = scan::scan_workflows(&path, config_file.path(), github_token).await?; Ok((report.output.trim_end().to_string(), report.outcome)) } @@ -140,18 +144,12 @@ pub async fn run( ) -> anyhow::Result<()> { let client = reqwest::Client::new(); let crabwatch_dir = crabwatch_dir(cache_dir_override)?; - let zizmor_config = scan::sync_zizmor_config(&crabwatch_dir)?; + let policy = config::ZizmorPolicy::bundled().await?; if let Some(repo_arg) = repo_arg { let parsed = parse_repo(&repo_arg)?; - let (output, outcome) = analyze_repo( - &client, - &parsed, - &crabwatch_dir, - &zizmor_config, - github_token, - ) - .await?; + let (output, outcome) = + analyze_repo(&client, &parsed, &crabwatch_dir, &policy, github_token).await?; match outcome { scan::ScanOutcome::Findings => log::info!("{output}"), scan::ScanOutcome::Clean => log::info!("No findings to report."), @@ -164,7 +162,7 @@ pub async fn run( let client = &client; let crabwatch_dir = &crabwatch_dir; - let zizmor_config = &zizmor_config; + let policy = &policy; let org = &org; let mut failures = Vec::new(); let mut any_findings = false; @@ -177,8 +175,7 @@ pub async fn run( }; async move { let result = - analyze_repo(client, &parsed, crabwatch_dir, zizmor_config, github_token) - .await; + analyze_repo(client, &parsed, crabwatch_dir, policy, github_token).await; (parsed, result) } }) diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..3c2a18a --- /dev/null +++ b/src/config.rs @@ -0,0 +1,357 @@ +use anyhow::{Context as _, bail}; +use std::io::Write as _; +use std::path::Path; +use tempfile::NamedTempFile; +use tokio::process::Command; + +// Embed/bundle the zizmor policy inside the compiled Crabwatch executable. +const ZIZMOR_POLICY: &str = include_str!("../zizmor-policy.yml"); + +/// Builds a repository's zizmor configuration from the policy: the `default` +/// section with the repository's overrides deep-merged on top. +/// +/// Must stay identical to the expression in `.github/workflows/crabwatch.yml` +/// so the CLI and the workflow resolve the same configuration (checked by a test). +const BUILD_CONFIG_EXPR: &str = ".default * (.repositories[strenv(GITHUB_REPOSITORY)] // {})"; + +const YQ_INSTALL_HINT: &str = + "If you haven't, install mikefarah's yq v4: https://github.com/mikefarah/yq#install"; + +/// The bundled zizmor policy, written once to a temporary file that every +/// scan passes to `yq` as a path. +pub(crate) struct ZizmorPolicy { + file: NamedTempFile, +} + +fn temp_file(contents: &str) -> anyhow::Result { + let mut file = tempfile::Builder::new() + .prefix("crabwatch-") + .tempfile() + .context("failed to create temporary file")?; + file.write_all(contents.as_bytes()) + .context("failed to write temporary file")?; + Ok(file) +} + +/// Run `yq` on `policy`, exposing `repository` to the expression as +/// `GITHUB_REPOSITORY` like GitHub Actions does. +async fn yq(args: &[&str], policy: &Path, repository: &str) -> anyhow::Result { + let output = Command::new("yq") + .args(["--exit-status", "--no-colors"]) + .args(args) + .arg(policy) + .env("GITHUB_REPOSITORY", repository) + .output() + .await + .with_context(|| format!("failed to run yq. {YQ_INSTALL_HINT}"))?; + if !output.status.success() { + // The unrelated Python `yq` also installs a `yq` binary, so mention the right one. + bail!( + "yq failed ({}): {}. {YQ_INSTALL_HINT}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + String::from_utf8(output.stdout).context("yq returned invalid UTF-8") +} + +impl ZizmorPolicy { + /// Fail if `yq` is missing or the bundled policy is not valid YAML. + /// The policy's structure is validated by tests. + pub(crate) async fn bundled() -> anyhow::Result { + Self::from_source(ZIZMOR_POLICY).await + } + + async fn from_source(source: &str) -> anyhow::Result { + let policy = Self { + file: temp_file(source)?, + }; + policy.effective_config("").await?; + Ok(policy) + } + + async fn effective_config(&self, repository: &str) -> anyhow::Result { + yq(&[BUILD_CONFIG_EXPR], self.file.path(), repository).await + } + + /// Write `repository`'s zizmor configuration to a temporary file that is + /// deleted on drop. + /// + /// `repository` is GitHub's canonical `owner/name`; override lookup is + /// case-sensitive. + pub(crate) async fn write_config(&self, repository: &str) -> anyhow::Result { + temp_file(&self.effective_config(repository).await?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + use serde_json::Value; + use std::collections::BTreeMap; + + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Policy { + default: Config, + repositories: BTreeMap, + } + + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Config { + rules: BTreeMap, + } + + /// Mirrors the shape of zizmor's rule settings so a malformed field is caught early. + /// Unknown rule names are not caught. + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + #[allow(dead_code)] + struct RuleSettings { + // Non-Option like zizmor, so an explicit `null` is rejected. + #[serde(default)] + disable: bool, + #[serde(default)] + ignore: Vec, + config: Option>, + remap: Option, + } + + /// Check the policy's structure. This runs only in tests: the bundled policy + /// is validated in CI by `embedded_policy_is_valid`. + async fn validate(source: &str) -> anyhow::Result<()> { + // yq preserves duplicate mapping keys when parsing, so check them explicitly. + let file = temp_file(source)?; + let json = yq( + &[ + // Convert to JSON so that we can use `serde_json` for validation. + "--output-format=json", + r#" + select( # Keep the original document only if the check passes. + [ + # Gather the separate mapping checks into an array so `all` + # can decide whether the entire document passes. + .. # Visit the document root and its nested values. + | select(tag == "!!map") # Inspect mappings only. + | keys # Get this mapping's keys, including duplicates. + | (length == (unique | length)) # Equal counts mean no duplicates. + ] + | all # Require every mapping to pass. + ) // null # Emit null instead of silently dropping a rejected document. + "#, + ], + file.path(), + "", + ) + .await?; + // Policy rejects null, and from_str rejects trailing JSON values. Keeping + // rejected documents as null prevents multi-document input from hiding them. + let policy: Policy = serde_json::from_str(&json).context("invalid zizmor policy")?; + // Check that each override can be merged using the production expression. + // JSON validation alone misses failures such as an aliased override. + let resolved = ZizmorPolicy::from_source(source).await?; + for (repository, overrides) in &policy.repositories { + crate::command::analyze::parse_repo(repository) + .with_context(|| format!("repository override {repository}"))?; + for rule in overrides.rules.keys() { + if !policy.default.rules.contains_key(rule) { + bail!("{repository}: unknown rule {rule}; add it to default.rules first"); + } + } + resolved + .effective_config(repository) + .await + .with_context(|| format!("{repository}: failed to resolve configuration"))?; + } + Ok(()) + } + + async fn yaml_as_json(source: &str) -> Value { + let file = temp_file(source).unwrap(); + serde_json::from_str( + &yq(&["--output-format=json", "."], file.path(), "") + .await + .unwrap(), + ) + .unwrap() + } + + const TEST_POLICY: &str = "default: + rules: + bot-conditions: + disable: false + ignore: [example.yml] + insecure-commands: + disable: false + ignore: [default.yml] + unpinned-uses: + disable: true +repositories: + Example/Overridden: + rules: + bot-conditions: + disable: true + insecure-commands: + ignore: [strict.yml] + Example/Stricter: + rules: + bot-conditions: + ignore: [] + unpinned-uses: + disable: false +"; + + #[tokio::test] + async fn embedded_policy_is_valid() { + validate(ZIZMOR_POLICY).await.unwrap(); + } + + #[tokio::test] + async fn selects_override_with_exact_case_and_falls_back() { + validate(TEST_POLICY).await.unwrap(); + let policy = ZizmorPolicy::from_source(TEST_POLICY).await.unwrap(); + + let overridden = + yaml_as_json(&policy.effective_config("Example/Overridden").await.unwrap()).await; + assert_eq!(overridden["rules"]["bot-conditions"]["disable"], true); + assert_eq!( + overridden["rules"]["bot-conditions"]["ignore"], + serde_json::json!(["example.yml"]) + ); + assert_eq!(overridden["rules"]["insecure-commands"]["disable"], false); + assert_eq!( + overridden["rules"]["insecure-commands"]["ignore"], + serde_json::json!(["strict.yml"]) + ); + assert_eq!(overridden["rules"]["unpinned-uses"]["disable"], true); + + let stricter = + yaml_as_json(&policy.effective_config("Example/Stricter").await.unwrap()).await; + assert_eq!(stricter["rules"]["unpinned-uses"]["disable"], false); + assert_eq!(stricter["rules"]["bot-conditions"]["disable"], false); + assert_eq!( + stricter["rules"]["bot-conditions"]["ignore"], + serde_json::json!([]) + ); + + for repository in ["example/Overridden", "Example/overridden", "Example/other"] { + let default = yaml_as_json(&policy.effective_config(repository).await.unwrap()).await; + assert_eq!(default["rules"]["bot-conditions"]["disable"], false); + assert_eq!(default["rules"]["insecure-commands"]["disable"], false); + assert_eq!(default["rules"]["unpinned-uses"]["disable"], true); + } + } + + #[tokio::test] + async fn write_config_uses_the_bundled_policy() { + let policy = ZizmorPolicy::bundled().await.unwrap(); + // Write the config for a repo that doesn't exist. + let config_file = policy.write_config("example/repo").await.unwrap(); + let effective_config = policy.effective_config("example/repo").await.unwrap(); + // Ensure the file was created, and its contents match the effective config. + assert_eq!( + std::fs::read_to_string(config_file.path()).unwrap(), + effective_config + ); + // Since the repo doesn't exist, the effective configuration should be the default configuration. + assert_eq!( + yaml_as_json(&effective_config).await, + yaml_as_json(ZIZMOR_POLICY).await["default"] + ); + } + + #[test] + fn workflow_uses_the_same_yq_expression() { + assert!(include_str!("../.github/workflows/crabwatch.yml").contains(BUILD_CONFIG_EXPR)); + } + + #[tokio::test] + async fn rejects_invalid_policies() { + for (reason, source) in [ + ("malformed YAML", "default: ["), + ("empty", ""), + ("missing default", "repositories: {}"), + ("missing repositories", "default: {rules: {}}"), + ("missing rules", "default: {}\nrepositories: {}"), + ( + "multiple documents", + "default: {rules: {}}\nrepositories: {}\n---\ndefault: {rules: {}}\nrepositories: {}", + ), + ( + "unknown top-level key", + "default: {rules: {}}\nrepositories: {}\nextra: {}", + ), + ( + "duplicate top-level key", + "default: {rules: {}}\ndefault: {rules: {}}\nrepositories: {}", + ), + ( + "duplicate rule key", + "default: {rules: {bot-conditions: {}, bot-conditions: {}}}\nrepositories: {}", + ), + ( + "duplicate key before valid document", + "default: {rules: {}}\ndefault: {rules: {}}\nrepositories: {}\n---\ndefault: {rules: {}}\nrepositories: {}", + ), + ( + "duplicate key after valid document", + "default: {rules: {}}\nrepositories: {}\n---\ndefault: {rules: {}}\ndefault: {rules: {}}\nrepositories: {}", + ), + ( + "non-boolean default disable", + "default: {rules: {a: {disable: 'false'}}}\nrepositories: {}", + ), + ( + "unknown rule field", + "default: {rules: {a: {disabled: true}}}\nrepositories: {}", + ), + ( + "non-list ignore", + "default: {rules: {a: {ignore: strict.yml}}}\nrepositories: {}", + ), + ( + "repository key without slash", + "default: {rules: {}}\nrepositories: {example: {rules: {}}}", + ), + ( + "unknown rule", + "default: {rules: {}}\nrepositories: {example/repo: {rules: {unknown: {disable: true}}}}", + ), + ( + "override rules not a mapping", + "default: {rules: {a: {}}}\nrepositories: {example/repo: {rules: []}}", + ), + ( + "non-mapping override", + "default: {rules: {a: {}}}\nrepositories: {example/repo: {rules: {a: true}}}", + ), + ( + "quoted override disable", + "default: {rules: {a: {}}}\nrepositories: {example/repo: {rules: {a: {disable: 'false'}}}}", + ), + ( + "numeric override disable", + "default: {rules: {a: {}}}\nrepositories: {example/repo: {rules: {a: {disable: 0}}}}", + ), + ( + "null override disable", + "default: {rules: {a: {}}}\nrepositories: {example/repo: {rules: {a: {disable: null}}}}", + ), + ( + "unknown override key", + "default: {rules: {a: {}}}\nrepositories: {example/repo: {rules: {}, extra: {}}}", + ), + ( + "aliased override", + "default: {rules: {a: {}}}\nrepositories: {example/one: &shared {rules: {a: {disable: true}}}, example/two: *shared}", + ), + ] { + assert!( + validate(source).await.is_err(), + "accepted {reason}: {source:?}" + ); + } + } +} diff --git a/src/github.rs b/src/github.rs index dfec9e6..88fc094 100644 --- a/src/github.rs +++ b/src/github.rs @@ -4,7 +4,7 @@ use serde::Deserialize; pub fn head_commit_query(org: &str, repo: &str) -> String { let inner = format!( - "query {{ repository(owner: \"{org}\", name: \"{repo}\") {{ defaultBranchRef {{ target {{ oid }} }} }} }}" + "query {{ repository(owner: \"{org}\", name: \"{repo}\") {{ nameWithOwner defaultBranchRef {{ target {{ oid }} }} }} }}" ); serde_json::json!({ "query": inner }).to_string() } @@ -14,16 +14,22 @@ pub async fn fetch_head_commit( org: &str, repo: &str, token: &str, -) -> anyhow::Result { +) -> anyhow::Result { let body = head_commit_query(org, repo); let response: GraphQlResponse = post_graphql(client, token, body).await?; check_graphql_errors(&response.errors)?; response - .head_commit_sha() + .head_commit() .ok_or_else(|| anyhow!("repository {org}/{repo} not found or has no default branch")) } +#[derive(Debug, PartialEq)] +pub struct RepositoryHead { + pub name_with_owner: String, + pub sha: String, +} + async fn post_graphql( client: &reqwest::Client, token: &str, @@ -63,11 +69,12 @@ struct GraphQlResponse { } impl GraphQlResponse { - fn head_commit_sha(self) -> Option { - self.data - .and_then(|d| d.repository) - .and_then(|r| r.default_branch_ref) - .map(|b| b.target.oid) + fn head_commit(self) -> Option { + let repository = self.data?.repository?; + Some(RepositoryHead { + name_with_owner: repository.name_with_owner, + sha: repository.default_branch_ref?.target.oid, + }) } } @@ -79,6 +86,7 @@ struct GraphQlRepoData { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Repository { + name_with_owner: String, default_branch_ref: Option, } @@ -207,7 +215,7 @@ mod tests { #[test] fn head_commit_query_snapshot() { - insta::assert_snapshot!(head_commit_query("rust-lang", "crabwatch"), @r#"{"query":"query { repository(owner: \"rust-lang\", name: \"crabwatch\") { defaultBranchRef { target { oid } } } }"}"#); + insta::assert_snapshot!(head_commit_query("rust-lang", "crabwatch"), @r#"{"query":"query { repository(owner: \"rust-lang\", name: \"crabwatch\") { nameWithOwner defaultBranchRef { target { oid } } } }"}"#); } #[test] @@ -253,10 +261,11 @@ mod tests { } #[test] - fn parses_successful_response() { + fn parses_head_with_canonical_repository_name() { let json = r#"{ "data": { "repository": { + "nameWithOwner": "Example/Canonical-Repo", "defaultBranchRef": { "target": { "oid": "abc123" @@ -266,16 +275,34 @@ mod tests { } }"#; let parsed: GraphQlResponse = serde_json::from_str(json).unwrap(); - let sha = parsed.head_commit_sha(); - assert_eq!(sha, Some("abc123".to_string())); + assert_eq!( + parsed.head_commit(), + Some(RepositoryHead { + name_with_owner: "Example/Canonical-Repo".to_string(), + sha: "abc123".to_string(), + }) + ); } #[test] fn parses_missing_repository_as_none() { let json = r#"{ "data": { "repository": null } }"#; let parsed: GraphQlResponse = serde_json::from_str(json).unwrap(); - let sha = parsed.head_commit_sha(); - assert_eq!(sha, None); + assert_eq!(parsed.head_commit(), None); + } + + #[test] + fn parses_missing_default_branch_as_none() { + let json = r#"{ + "data": { + "repository": { + "nameWithOwner": "Example/Empty", + "defaultBranchRef": null + } + } + }"#; + let parsed: GraphQlResponse = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.head_commit(), None); } #[test] diff --git a/src/main.rs b/src/main.rs index 970f16e..7602856 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; mod clone; mod command; +mod config; mod github; mod scan; mod security_fork; diff --git a/src/scan.rs b/src/scan.rs index ebfc817..d4ed3bf 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -3,8 +3,6 @@ use std::io::ErrorKind; use std::path::{Path, PathBuf}; use tokio::process::Command; -const ZIZMOR_CONFIG: &str = include_str!("../zizmor-default.yml"); -const ZIZMOR_CONFIG_FILE: &str = "zizmor-default.yml"; #[derive(Debug, PartialEq)] pub enum ScanOutcome { Clean, @@ -31,30 +29,6 @@ fn zizmor_command(github_path: &Path, config_path: &Path, github_token: &str) -> command } -pub(crate) fn sync_zizmor_config(crabwatch_dir: &Path) -> anyhow::Result { - let config_path = crabwatch_dir.join(ZIZMOR_CONFIG_FILE); - - match std::fs::read(&config_path) { - // Config is already present and identical, return early - Ok(contents) if contents == ZIZMOR_CONFIG.as_bytes() => return Ok(config_path), - // Config is already present but different, overwrite it - Ok(_) => {} - // Config is not present, create it - Err(err) if err.kind() == ErrorKind::NotFound => {} - Err(err) => { - return Err(err) - .with_context(|| format!("failed to read zizmor config at {config_path:?}")); - } - } - - std::fs::create_dir_all(crabwatch_dir) - .with_context(|| format!("failed to create Crabwatch directory at {crabwatch_dir:?}"))?; - std::fs::write(&config_path, ZIZMOR_CONFIG) - .with_context(|| format!("failed to write zizmor config at {config_path:?}"))?; - - Ok(config_path) -} - fn root_github_path(repo_path: &Path) -> anyhow::Result> { let github_path = repo_path.join(".github"); let path = github_path @@ -130,61 +104,12 @@ mod tests { "on: push\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - run: echo hello\n", ) .unwrap(); - let config_dir = tempfile::tempdir().unwrap(); - let config_path = sync_zizmor_config(config_dir.path()).unwrap(); + // Never read: without a root `.github`, zizmor is not run. + let config_path = Path::new("unused-config.yml"); - let report = scan_workflows(repo.path(), &config_path, "").await.unwrap(); + let report = scan_workflows(repo.path(), config_path, "").await.unwrap(); assert_eq!(report.outcome, ScanOutcome::NoWorkflows); assert_eq!(report.output, "no workflows to scan"); } - - #[test] - fn creates_config_and_keeps_identical_file() { - // The first sync should create the directory and bundled config from scratch. - let temp_dir = tempfile::tempdir().unwrap(); - let crabwatch_dir = temp_dir.path().join("crabwatch"); - let config_path = sync_zizmor_config(&crabwatch_dir).unwrap(); - - // Make the generated file read-only so a second sync can succeed only by - // recognizing the identical contents and returning without rewriting it. - let original_metadata = std::fs::metadata(&config_path).unwrap(); - let original_permissions = original_metadata.permissions(); - let mut read_only_permissions = original_permissions.clone(); - read_only_permissions.set_readonly(true); - std::fs::set_permissions(&config_path, read_only_permissions).unwrap(); - - let second_path = sync_zizmor_config(&crabwatch_dir).expect("failed to sync config a second time. Maybe the read-only permission prevented it from being overwritten?"); - let second_metadata = std::fs::metadata(&second_path).unwrap(); - - std::fs::set_permissions(&config_path, original_permissions).unwrap(); - - assert_eq!(config_path, second_path); - assert_eq!( - std::fs::read_to_string(&config_path).unwrap(), - ZIZMOR_CONFIG - ); - // The modification time should be preserved because the second sync - // should not have rewritten the file. - assert_eq!( - original_metadata.modified().unwrap(), - second_metadata.modified().unwrap() - ); - } - - #[test] - fn overwrites_different_config() { - let temp_dir = tempfile::tempdir().unwrap(); - let crabwatch_dir = temp_dir.path().join("crabwatch"); - std::fs::create_dir_all(&crabwatch_dir).unwrap(); - let config_path = crabwatch_dir.join(ZIZMOR_CONFIG_FILE); - std::fs::write(&config_path, "different config").unwrap(); - - sync_zizmor_config(&crabwatch_dir).unwrap(); - - assert_eq!( - std::fs::read_to_string(&config_path).unwrap(), - ZIZMOR_CONFIG - ); - } } diff --git a/zizmor-default.yml b/zizmor-default.yml index fc18396..e6b1900 100644 --- a/zizmor-default.yml +++ b/zizmor-default.yml @@ -1,4 +1,5 @@ -# Default zizmor configuration used by crabwatch. +# Old configuration file, kept for compatibility with older workflows that fetch this path. +# Current policy is maintained in zizmor-policy.yml. # # crabwatch runs a curated set of zizmor audits. # zizmor has no allow-list, so we need to explicitly disable every audit we don't want to run. diff --git a/zizmor-policy.yml b/zizmor-policy.yml new file mode 100644 index 0000000..3d80b5d --- /dev/null +++ b/zizmor-policy.yml @@ -0,0 +1,159 @@ +# Zizmor policy used by crabwatch. +# +# crabwatch runs a curated set of zizmor audits. +# zizmor has no allow-list, so we need to explicitly disable every audit we don't want to run. +default: + # Configuration shared by every repository. + rules: + # Enabled + archived-uses: + disable: false + bot-conditions: + disable: false + dependabot-execution: + disable: false + hardcoded-container-credentials: + disable: false + impostor-commit: + disable: false + insecure-commands: + disable: false + insecure-url-scheme: + disable: false + known-vulnerable-actions: + disable: false + overprovisioned-secrets: + disable: false + typosquat-uses: + disable: false + unredacted-secrets: + disable: false + unsound-condition: + disable: false + unsound-contains: + disable: false + + # Disabled + adhoc-packages: + disable: true + anonymous-definition: + # Disabled because it doesn't have a security impact. + disable: true + artipacked: + disable: true + cache-poisoning: + disable: true + concurrency-limits: + disable: true + dangerous-triggers: + disable: true + dependabot-cooldown: + disable: true + excessive-permissions: + disable: true + forbidden-uses: + disable: true + github-app: + disable: true + github-env: + disable: true + misfeature: + disable: true + obfuscation: + disable: true + ref-confusion: + disable: true + ref-version-mismatch: + disable: true + secrets-inherit: + disable: true + secrets-outside-env: + disable: true + self-hosted-runner: + disable: true + self-repository: + disable: true + stale-action-refs: + disable: true + superfluous-actions: + disable: true + template-injection: + disable: true + undocumented-permissions: + disable: true + unpinned-images: + disable: true + unpinned-tools: + disable: true + unpinned-uses: + disable: true + unsound-ternary: + disable: true + use-trusted-publishing: + disable: true + +repositories: + # Override `default` in specific repositories. + rust-lang/chalk: + # Chalk is unused at the moment and it's going to be archived soon probably. + # Fixing lints it's not worth it. + rules: + archived-uses: + disable: true + rust-lang/crabwatch: + # Opt in to the disabled audits that pass for this repository. + rules: + adhoc-packages: + disable: false + anonymous-definition: + disable: false + artipacked: + disable: false + cache-poisoning: + disable: false + concurrency-limits: + disable: false + dangerous-triggers: + disable: false + dependabot-cooldown: + disable: false + excessive-permissions: + disable: false + forbidden-uses: + disable: false + github-app: + disable: false + github-env: + disable: false + misfeature: + disable: false + obfuscation: + disable: false + ref-confusion: + disable: false + ref-version-mismatch: + disable: false + secrets-inherit: + disable: false + secrets-outside-env: + disable: false + self-hosted-runner: + disable: false + stale-action-refs: + disable: false + superfluous-actions: + disable: false + template-injection: + disable: false + undocumented-permissions: + disable: false + unpinned-images: + disable: false + unpinned-tools: + disable: false + unpinned-uses: + disable: false + unsound-ternary: + disable: false + use-trusted-publishing: + disable: false