diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 4b68c7e..79b5290 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -12,6 +12,25 @@ https://semver.org/[Semantic Versioning]. === Added +==== `CicdRules` rule `scorecard_wrapper_missing_job_permissions` (2026-05-30, #390) + +New forward-detection rule for a silent-CI-failure class: a +`.github/workflows/scorecard.yml` that *calls* the standards +`scorecard-reusable.yml` but omits `security-events: write` on the calling +job. Called-workflow permissions are capped by the caller, so +`ossf/scorecard-action` cannot upload its SARIF and every scheduled +Scorecard run fails with `startup_failure` — no logs. Estate baseline +2026-05-30: 37 affected wrappers (35 unique + 2 inert nested-monorepo +copies). Prior art: `julia-professional-registry#19`, `absolute-zero#68`. + +Surfaced through the facade as +`Hypatia.Rules.scan_scorecard_wrapper_permissions/2`; the pure predicate +`CicdRules.check_scorecard_wrapper_permissions/2` and an +`opts[:path_allow_prefixes]` carve-out (for bespoke inline scorecard +workflows) are covered by `test/rules/cicd_rules_scorecard_wrapper_test.exs` +for both sensitivity (positive + nested copy) and specificity (perm present, +no-reusable, carve-out). + ==== `WorkflowAudit` rules WF014–WF017 (2026-05-30, PRs #393 + #396) Four new forward-detection rules surfacing patterns root-fixed in diff --git a/CHANGELOG.md b/CHANGELOG.md index 781aa6e..bf37675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- feat(rules): CicdRules `scorecard_wrapper_missing_job_permissions` — flag scorecard.yml wrappers that call the standards reusable but omit `security-events: write` on the calling job (#390) - feat(rules): AffineScript hand-port pitfalls — HANDLE-as-fn-name + OCaml float ops (#332) - feat(rules): wire 4 new rule modules through the facade (#326) - feat(rules): ResearchExtensions (RE001-RE010) — 10 rules from Snyk/StepSecurity/Endor/academic literature (#325) diff --git a/lib/rules/cicd_rules.ex b/lib/rules/cicd_rules.ex index 0992b16..52dc659 100644 --- a/lib/rules/cicd_rules.ex +++ b/lib/rules/cicd_rules.ex @@ -834,6 +834,102 @@ defmodule Hypatia.Rules.CicdRules do end end + # --------------------------------------------------------------------------- + # Scorecard Wrapper Permissions (#390) + # --------------------------------------------------------------------------- + # + # A `.github/workflows/scorecard.yml` that *calls* the standards + # `scorecard-reusable.yml` MUST grant `security-events: write` (and + # `id-token: write`) on the calling job. Called-workflow permissions are + # CAPPED by the caller, so a wrapper that omits the grant leaves + # `ossf/scorecard-action` unable to upload its SARIF — every scheduled + # Scorecard run then fails with `startup_failure` and *no logs*. That + # silent-CI-failure shape is exactly what Hypatia exists to catch. + # + # Estate baseline 2026-05-30: 37 affected wrappers (35 unique + 2 inert + # nested-monorepo copies). Prior art: julia-professional-registry#19, + # absolute-zero#68 (memory: feedback_scorecard_wrapper_caller_permissions). + + @scorecard_wrapper_path ".github/workflows/scorecard.yml" + @scorecard_reusable_marker "scorecard-reusable.yml" + @scorecard_required_perm ~r/security-events:\s*write/ + @scorecard_missing_perm_reason "scorecard.yml calls the standards scorecard-reusable.yml but does not grant `security-events: write` on the calling job; called-workflow permissions are capped by the caller, so ossf/scorecard-action cannot upload its SARIF and the scheduled Scorecard run fails with `startup_failure` (silent CI failure, no logs)." + @scorecard_missing_perm_fix "Grant the calling job `security-events: write` (and `id-token: write`); the reusable re-asserts them, but the caller caps them:\n permissions:\n security-events: write\n id-token: write" + + @doc """ + Scan `repo_path` for scorecard wrappers that call the standards reusable + but omit the required `security-events: write` job permission (#390). + + A finding is emitted for each `.github/workflows/scorecard.yml` (repo-root + *or* nested monorepo copy) that, in the same file: + + * references `scorecard-reusable.yml` (i.e. uses the reusable), AND + * does NOT grant `security-events: write`. + + Inline scorecard workflows that do not call the reusable are ignored by + construction (the first condition fails). `opts[:path_allow_prefixes]` is a + list of substrings; any wrapper whose relative path contains one is skipped + — an explicit carve-out for bespoke scorecard workflows that manage their + own permissions shape. + + Returns `[%{rule:, severity:, file:, reason:, fix:}]`. + """ + def scan_scorecard_wrapper_permissions(repo_path, opts \\ []) do + allow_prefixes = Keyword.get(opts, :path_allow_prefixes, []) + + Path.wildcard("#{repo_path}/**/*", match_dot: true) + |> Enum.reject(&File.dir?/1) + |> Enum.map(&Path.relative_to(&1, repo_path)) + |> Enum.filter(fn rel -> + not String.starts_with?(rel, ".git/") and scorecard_wrapper_path?(rel) + end) + |> Enum.reject(fn rel -> Enum.any?(allow_prefixes, &String.contains?(rel, &1)) end) + |> Enum.flat_map(fn rel -> + case File.read(Path.join(repo_path, rel)) do + {:ok, content} -> + case check_scorecard_wrapper_permissions(rel, content) do + {:fail, finding} -> [finding] + :ok -> [] + end + + {:error, _} -> + [] + end + end) + end + + @doc """ + Pure predicate behind `scan_scorecard_wrapper_permissions/2`. + + Given a scorecard wrapper's relative `path` and its `content`, returns + `{:fail, finding}` when the file calls the standards reusable but does not + grant `security-events: write`, or `:ok` otherwise. + """ + def check_scorecard_wrapper_permissions(path, content) do + uses_reusable? = String.contains?(content, @scorecard_reusable_marker) + grants_perm? = Regex.match?(@scorecard_required_perm, content) + + if uses_reusable? and not grants_perm? do + finding = %{ + rule: :scorecard_wrapper_missing_job_permissions, + severity: :high, + file: path, + reason: @scorecard_missing_perm_reason, + fix: @scorecard_missing_perm_fix + } + + {:fail, finding} + else + :ok + end + end + + # True when `rel` is a scorecard wrapper workflow — the repo-root copy or + # any nested monorepo copy (`pkg/.github/workflows/scorecard.yml`). + defp scorecard_wrapper_path?(rel) do + rel == @scorecard_wrapper_path or String.ends_with?(rel, "/" <> @scorecard_wrapper_path) + end + # --------------------------------------------------------------------------- # CI/CD Waste Detection # --------------------------------------------------------------------------- diff --git a/lib/rules/rules.ex b/lib/rules/rules.ex index de582fb..eca55fb 100644 --- a/lib/rules/rules.ex +++ b/lib/rules/rules.ex @@ -422,6 +422,12 @@ defmodule Hypatia.Rules do """ defdelegate detect_waste(repo_info), to: CicdRules + @doc """ + Scan for scorecard wrappers that call the standards reusable but omit the + required `security-events: write` job permission (#390). + """ + defdelegate scan_scorecard_wrapper_permissions(repo_path, opts \\ []), to: CicdRules + @doc """ Run baseline-health checks (BH001-BH007): missing required_status_checks on main, deferred-migration TODOs in dep manifests, persistent >24h red diff --git a/test/rules/cicd_rules_scorecard_wrapper_test.exs b/test/rules/cicd_rules_scorecard_wrapper_test.exs new file mode 100644 index 0000000..2d43417 --- /dev/null +++ b/test/rules/cicd_rules_scorecard_wrapper_test.exs @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: MPL-2.0 + +defmodule Hypatia.Rules.CicdRules.ScorecardWrapperTest do + use ExUnit.Case, async: true + + alias Hypatia.Rules.CicdRules + + # #390 — a `.github/workflows/scorecard.yml` that CALLS the standards + # `scorecard-reusable.yml` must grant `security-events: write` on the + # calling job, or the scheduled Scorecard run fails with `startup_failure` + # (no logs). Detection: uses "scorecard-reusable.yml" AND NOT + # "security-events: write". Sensitivity + specificity both covered. + + @wf_path ".github/workflows/scorecard.yml" + + @reusable_no_perm """ + # SPDX-License-Identifier: MPL-2.0 + name: Scorecard + on: + schedule: + - cron: "0 2 * * 1" + permissions: read-all + jobs: + analysis: + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@abc1234 + """ + + @reusable_with_perm """ + # SPDX-License-Identifier: MPL-2.0 + name: Scorecard + on: + schedule: + - cron: "0 2 * * 1" + permissions: read-all + jobs: + analysis: + permissions: + security-events: write + id-token: write + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@abc1234 + """ + + @inline_no_reusable """ + # SPDX-License-Identifier: MPL-2.0 + name: Scorecard + on: + schedule: + - cron: "0 2 * * 1" + permissions: read-all + jobs: + analysis: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ossf/scorecard-action@v2 + """ + + setup do + dir = Path.join(System.tmp_dir!(), "hyp-scw-#{:erlang.unique_integer([:positive])}") + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, dir: dir} + end + + defp write_scorecard(dir, body, sub \\ "") do + rel = if sub == "", do: @wf_path, else: Path.join(sub, @wf_path) + path = Path.join(dir, rel) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, body) + path + end + + describe "scan_scorecard_wrapper_permissions/2 — sensitivity" do + test "fires when wrapper uses reusable but lacks the perm", %{dir: dir} do + write_scorecard(dir, @reusable_no_perm) + assert [finding] = CicdRules.scan_scorecard_wrapper_permissions(dir) + assert finding.rule == :scorecard_wrapper_missing_job_permissions + assert finding.severity == :high + assert finding.file == @wf_path + assert finding.fix =~ "security-events: write" + end + + test "fires on a nested monorepo copy", %{dir: dir} do + write_scorecard(dir, @reusable_no_perm, "packages/api") + assert [finding] = CicdRules.scan_scorecard_wrapper_permissions(dir) + assert finding.file == "packages/api/" <> @wf_path + end + end + + describe "scan_scorecard_wrapper_permissions/2 — specificity" do + test "silent when wrapper grants the perm", %{dir: dir} do + write_scorecard(dir, @reusable_with_perm) + assert CicdRules.scan_scorecard_wrapper_permissions(dir) == [] + end + + test "silent for inline scorecard not using the reusable", %{dir: dir} do + write_scorecard(dir, @inline_no_reusable) + assert CicdRules.scan_scorecard_wrapper_permissions(dir) == [] + end + + test "path_allow_prefixes carve-out skips the wrapper", %{dir: dir} do + write_scorecard(dir, @reusable_no_perm, "vendor/upstream") + + findings = + CicdRules.scan_scorecard_wrapper_permissions(dir, path_allow_prefixes: ["vendor/"]) + + assert findings == [] + end + end + + describe "check_scorecard_wrapper_permissions/2 — pure predicate" do + test "fail when reusable present and perm absent" do + result = CicdRules.check_scorecard_wrapper_permissions(@wf_path, @reusable_no_perm) + assert {:fail, finding} = result + assert finding.rule == :scorecard_wrapper_missing_job_permissions + assert finding.reason =~ "startup_failure" + end + + test "ok when perm present with irregular spacing" do + body = String.replace(@reusable_no_perm, "uses:", "security-events: write\n uses:") + assert :ok = CicdRules.check_scorecard_wrapper_permissions(@wf_path, body) + end + + test "ok when reusable not called" do + assert :ok = CicdRules.check_scorecard_wrapper_permissions(@wf_path, @inline_no_reusable) + end + end +end