diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9335537d..d10949a9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,46 +5,32 @@ updates: directory: "/" schedule: interval: "daily" - open-pull-requests-limit: 10 - - - package-ecosystem: "cargo" - directory: "/" - schedule: - interval: "weekly" + open-pull-requests-limit: 2 groups: - rust-minor: - update-types: ["minor", "patch"] - - - package-ecosystem: "cargo" - directory: "/adapters" - schedule: - interval: "weekly" - - - package-ecosystem: "cargo" - directory: "/cli" - schedule: - interval: "weekly" - - - package-ecosystem: "cargo" - directory: "/data" - schedule: - interval: "weekly" + actions: + patterns: + - "*" - package-ecosystem: "cargo" - directory: "/fixer" - schedule: - interval: "weekly" - - - package-ecosystem: "cargo" - directory: "/integration" + directories: + - "/" + - "/adapters" + - "/cli" + - "/data" + - "/fixer" + - "/integration" schedule: interval: "weekly" + open-pull-requests-limit: 3 + groups: + shared-rust-dependencies: + group-by: dependency-name - package-ecosystem: "mix" directory: "/" schedule: interval: "weekly" + open-pull-requests-limit: 3 groups: elixir-minor: update-types: ["minor", "patch"] - diff --git a/lib/rules/supply_chain.ex b/lib/rules/supply_chain.ex index b6c1dcc2..b112150a 100644 --- a/lib/rules/supply_chain.ex +++ b/lib/rules/supply_chain.ex @@ -6,7 +6,7 @@ defmodule Hypatia.Rules.SupplyChain do Supply-chain integrity rules drawn from OSSF Scorecard, SLSA, OWASP Top-10 CI/CD, and GitHub Actions hardening guide. - Rule IDs SC001-SC011. + Rule IDs SC001-SC012. These rules concern the **provenance and integrity** of code, actions, and release artifacts — distinct from `WorkflowHardening` (content @@ -27,6 +27,7 @@ defmodule Hypatia.Rules.SupplyChain do | SC009 | scorecard `Security-Policy` | `SECURITY.md` missing | | SC010 | scorecard `Webhooks` | Repo webhooks configured without a secret | | SC011 | scorecard `Signed-Releases` + Endor `R-END-02` | Release workflow doesn't emit signed artifacts / provenance attestation | + | SC012 | GitHub Dependabot limits + estate incident 2026-07-19 | Duplicate ecosystem blocks multiply the open-PR limit; multi-directory updates are not grouped | Module-level conventions match `WorkflowHardening`: pure-local for the file-presence rules, GitHub-API for the org/repo-state rules. @@ -584,6 +585,107 @@ defmodule Hypatia.Rules.SupplyChain do end) end + # ─── SC012: Dependabot update fan-out ─────────────────────────────── + + @doc """ + SC012: A Dependabot configuration can multiply its open version-update + capacity by repeating the same package ecosystem in separate update blocks. + Dependabot's default limit is five *per update block*, so eleven Cargo + directories expressed as eleven blocks permit 55 simultaneous PRs. + + It also reports a single ecosystem queue above five PRs, a repository-wide + configured capacity above twelve PRs, and multi-directory configurations that omit + `groups..group-by: dependency-name`, because a shared dependency then + produces one PR per directory rather than one cross-directory PR. + + This is a static cost-amplification check. It does not inspect or restrict + Dependabot security updates, which GitHub manages under a separate limit. + """ + def sc012_dependabot_pr_fanout(repo_path) do + case locate_dependabot(repo_path) do + nil -> + [] + + path -> + content = File.read!(path) + rel = Path.relative_to(path, repo_path) + + update_blocks = dependabot_update_blocks(content) + + ecosystem_findings = + update_blocks + |> Enum.group_by(& &1.ecosystem) + |> Enum.flat_map(fn {ecosystem, blocks} -> + projected = Enum.sum(Enum.map(blocks, & &1.limit)) + directories = Enum.sum(Enum.map(blocks, & &1.directory_count)) + ungrouped_multidir? = + directories > 1 and not Enum.any?(blocks, & &1.group_by_dependency) + + if projected > 5 or ungrouped_multidir? do + severity = if projected > 10, do: :high, else: :warn + + [ + %{ + rule: "SC012", + file: rel, + severity: severity, + reason: + "Dependabot `#{ecosystem}` configuration can fan out to " <> + "#{projected} simultaneous version-update PRs across " <> + "#{directories} director#{if directories == 1, do: "y", else: "ies"}", + action: :report, + detail: %{ + ecosystem: ecosystem, + update_blocks: length(blocks), + directories: directories, + projected_open_prs: projected, + grouped_by_dependency: Enum.any?(blocks, & &1.group_by_dependency), + fix: + "Consolidate same-ecosystem paths into one `directories:` block, " <> + "set a small `open-pull-requests-limit`, and add " <> + "`groups..group-by: dependency-name`." + } + } + ] + else + [] + end + end) + + total_projected = Enum.sum(Enum.map(update_blocks, & &1.limit)) + + repository_findings = + if total_projected > 12 and ecosystem_findings == [] do + [ + %{ + rule: "SC012", + file: rel, + severity: if(total_projected > 20, do: :high, else: :warn), + reason: + "Dependabot configuration admits up to #{total_projected} " <> + "simultaneous version-update PRs across #{length(update_blocks)} ecosystems", + action: :report, + detail: %{ + ecosystem: "all", + update_blocks: length(update_blocks), + directories: Enum.sum(Enum.map(update_blocks, & &1.directory_count)), + projected_open_prs: total_projected, + grouped_by_dependency: false, + fix: + "Remove update entries for unused ecosystems, cap package ecosystems " <> + "at three and GitHub Actions at two, and keep the repository-wide " <> + "version-PR capacity at twelve or less." + } + } + ] + else + [] + end + + ecosystem_findings ++ repository_findings + end + end + # ─── Scan facade ──────────────────────────────────────────────────── @doc """ @@ -617,6 +719,7 @@ defmodule Hypatia.Rules.SupplyChain do sc008_static_secret_publish(repo_path) ++ sc009_security_md_missing(repo_path) ++ sc011_release_without_signing(repo_path) ++ + sc012_dependabot_pr_fanout(repo_path) ++ api_findings %{ @@ -645,6 +748,52 @@ defmodule Hypatia.Rules.SupplyChain do end end + defp locate_dependabot(repo_path) do + ["dependabot.yml", "dependabot.yaml"] + |> Enum.map(&Path.join([repo_path, ".github", &1])) + |> Enum.find(&File.regular?/1) + end + + # Dependabot's file has a deliberately small top-level grammar. Splitting on + # update-entry headers avoids introducing a general YAML parser (and its + # boolean-key ambiguities) into the scanner while retaining the values SC012 + # needs. Nested list items never contain `package-ecosystem`, so they cannot + # be mistaken for update blocks. + defp dependabot_update_blocks(content) do + Regex.scan( + ~r/(?ms)^\s{2}-\s+package-ecosystem:\s*["']?([^"'\s]+)["']?\s*$\n(.*?)(?=^\s{2}-\s+package-ecosystem:|\z)/, + content, + capture: :all_but_first + ) + |> Enum.map(fn [ecosystem, body] -> + explicit_limit = + case Regex.run(~r/^\s+open-pull-requests-limit:\s*(\d+)\s*$/m, body, + capture: :all_but_first + ) do + [limit] -> String.to_integer(limit) + _ -> 5 + end + + singular_directories = length(Regex.scan(~r/^\s+directory:\s*[^#\n]+/m, body)) + + plural_directories = + case Regex.run(~r/(?ms)^\s+directories:\s*$\n(.*?)(?=^\s{4}\S|\z)/, body, + capture: :all_but_first + ) do + [directory_body] -> length(Regex.scan(~r/^\s+-\s+["']?\//m, directory_body)) + _ -> 0 + end + + %{ + ecosystem: ecosystem, + limit: explicit_limit, + directory_count: max(singular_directories + plural_directories, 1), + group_by_dependency: + Regex.match?(~r/^\s+group-by:\s*["']?dependency-name["']?\s*$/m, body) + } + end) + end + defp locate_codeowners(repo_path) do ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"] |> Enum.map(&Path.join(repo_path, &1)) diff --git a/scripts/fix-scripts/fix-dependabot.sh b/scripts/fix-scripts/fix-dependabot.sh index 8a64b389..75b5ff0e 100755 --- a/scripts/fix-scripts/fix-dependabot.sh +++ b/scripts/fix-scripts/fix-dependabot.sh @@ -22,8 +22,10 @@ ecosystems=() [[ -f "${REPO}/Cargo.toml" ]] && ecosystems+=("cargo") [[ -f "${REPO}/mix.exs" ]] && ecosystems+=("mix") [[ -f "${REPO}/deno.json" || -f "${REPO}/deno.jsonc" ]] && ecosystems+=("npm") -[[ -f "${REPO}/gleam.toml" ]] && ecosystems+=("hex") -[[ -f "${REPO}/build.zig" ]] && ecosystems+=("zig") # No dependabot support yet, skip +# Dependabot has no native Gleam package ecosystem. Do not emit the invalid +# `hex` ecosystem for gleam.toml; those updates need a separate Gleam-aware tool. +# Dependabot also has no native Zig ecosystem, so build.zig is intentionally +# ignored here rather than counted as an ecosystem with no generated block. [[ -d "${REPO}/.github/workflows" ]] && ecosystems+=("github-actions") cat > "$TARGET" <<'YAML' @@ -41,7 +43,10 @@ for eco in "${ecosystems[@]}"; do directory: "/" schedule: interval: "weekly" - open-pull-requests-limit: 5 + open-pull-requests-limit: 3 + groups: + dependency-updates: + patterns: ["*"] YAML ;; mix) @@ -50,7 +55,10 @@ YAML directory: "/" schedule: interval: "weekly" - open-pull-requests-limit: 5 + open-pull-requests-limit: 3 + groups: + dependency-updates: + patterns: ["*"] YAML ;; npm) @@ -59,16 +67,10 @@ YAML directory: "/" schedule: interval: "weekly" - open-pull-requests-limit: 5 -YAML - ;; - hex) - cat >> "$TARGET" <<'YAML' - - package-ecosystem: "hex" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 + open-pull-requests-limit: 3 + groups: + dependency-updates: + patterns: ["*"] YAML ;; github-actions) @@ -77,7 +79,10 @@ YAML directory: "/" schedule: interval: "weekly" - open-pull-requests-limit: 10 + open-pull-requests-limit: 2 + groups: + actions: + patterns: ["*"] YAML ;; esac @@ -90,7 +95,10 @@ if [[ ${#ecosystems[@]} -eq 0 ]]; then directory: "/" schedule: interval: "weekly" - open-pull-requests-limit: 10 + open-pull-requests-limit: 2 + groups: + actions: + patterns: ["*"] YAML fi diff --git a/test/supply_chain_test.exs b/test/supply_chain_test.exs index 76162455..80b2ef6f 100644 --- a/test/supply_chain_test.exs +++ b/test/supply_chain_test.exs @@ -178,6 +178,126 @@ defmodule Hypatia.Rules.SupplyChainTest do end end + # ─── SC012 ───────────────────────────────────────────────────────────── + + describe "sc012_dependabot_pr_fanout/1" do + test "flags repeated ecosystem blocks and reports projected capacity", %{repo: repo} do + File.mkdir_p!(Path.join(repo, ".github")) + + blocks = + 1..11 + |> Enum.map_join("\n", fn n -> + """ + - package-ecosystem: "cargo" + directory: "/bot-#{n}" + schedule: + interval: weekly + """ + end) + + File.write!( + Path.join([repo, ".github", "dependabot.yml"]), + "version: 2\nupdates:\n#{blocks}" + ) + + assert [finding] = SupplyChain.sc012_dependabot_pr_fanout(repo) + assert finding.rule == "SC012" + assert finding.severity == :high + assert finding.detail.update_blocks == 11 + assert finding.detail.directories == 11 + assert finding.detail.projected_open_prs == 55 + end + + test "passes grouped multi-directory configuration with one global limit", %{repo: repo} do + File.mkdir_p!(Path.join(repo, ".github")) + + File.write!(Path.join([repo, ".github", "dependabot.yaml"]), """ + version: 2 + updates: + - package-ecosystem: "cargo" + directories: + - "/bot-a" + - "/bot-b" + - "/bot-c" + schedule: + interval: weekly + open-pull-requests-limit: 3 + groups: + monorepo-dependencies: + group-by: dependency-name + """) + + assert SupplyChain.sc012_dependabot_pr_fanout(repo) == [] + end + + test "flags ungrouped multi-directory configuration", %{repo: repo} do + File.mkdir_p!(Path.join(repo, ".github")) + + File.write!(Path.join([repo, ".github", "dependabot.yml"]), """ + version: 2 + updates: + - package-ecosystem: cargo + directories: + - /one + - /two + schedule: + interval: weekly + open-pull-requests-limit: 3 + """) + + assert [finding] = SupplyChain.sc012_dependabot_pr_fanout(repo) + assert finding.severity == :warn + assert finding.detail.projected_open_prs == 3 + refute finding.detail.grouped_by_dependency + end + + test "flags one ecosystem with an excessive explicit limit", %{repo: repo} do + File.mkdir_p!(Path.join(repo, ".github")) + + File.write!(Path.join([repo, ".github", "dependabot.yml"]), """ + version: 2 + updates: + - package-ecosystem: bundler + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 99 + """) + + assert [finding] = SupplyChain.sc012_dependabot_pr_fanout(repo) + assert finding.severity == :high + assert finding.detail.ecosystem == "bundler" + assert finding.detail.projected_open_prs == 99 + end + + test "flags excessive repository-wide capacity across distinct ecosystems", %{repo: repo} do + File.mkdir_p!(Path.join(repo, ".github")) + + blocks = + ~w(cargo npm pip mix) + |> Enum.map_join("\n", fn ecosystem -> + """ + - package-ecosystem: #{ecosystem} + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + """ + end) + + File.write!( + Path.join([repo, ".github", "dependabot.yml"]), + "version: 2\nupdates:\n#{blocks}" + ) + + assert [finding] = SupplyChain.sc012_dependabot_pr_fanout(repo) + assert finding.severity == :warn + assert finding.detail.ecosystem == "all" + assert finding.detail.update_blocks == 4 + assert finding.detail.projected_open_prs == 20 + end + end + # ─── SC003 / SC007 / SC010 (token-gated) ──────────────────────────── describe "API-backed rules with no token" do