Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 16 additions & 30 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

151 changes: 150 additions & 1 deletion lib/rules/supply_chain.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.<name>.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.<name>.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 """
Expand Down Expand Up @@ -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

%{
Expand Down Expand Up @@ -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))
Expand Down
40 changes: 24 additions & 16 deletions scripts/fix-scripts/fix-dependabot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -77,7 +79,10 @@ YAML
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
open-pull-requests-limit: 2
groups:
actions:
patterns: ["*"]
YAML
;;
esac
Expand All @@ -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

Expand Down
Loading
Loading