diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 8c2861a..f1ce544 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -29,25 +29,6 @@ Surfaced through the facade as (same-day-of-week + daily-subset oracles) and specificity (different-time non-subset, single cron, commented-out lines). -==== `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 0be4fb6..89456c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,6 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - feat(rules): CicdRules `duplicate_cron_schedule` — flag workflows with redundant cron entries on the same day-of-week / daily-subset (#362) -- 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 47d8d47..4d72a91 100644 --- a/lib/rules/cicd_rules.ex +++ b/lib/rules/cicd_rules.ex @@ -871,102 +871,6 @@ 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 - # --------------------------------------------------------------------------- # Duplicate cron schedules (#362) # --------------------------------------------------------------------------- diff --git a/lib/rules/rules.ex b/lib/rules/rules.ex index 23567bb..096992e 100644 --- a/lib/rules/rules.ex +++ b/lib/rules/rules.ex @@ -422,12 +422,6 @@ 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 """ Scan workflows for redundant `cron:` schedules firing on the same day-of-week (#362). diff --git a/test/rules/cicd_rules_scorecard_wrapper_test.exs b/test/rules/cicd_rules_scorecard_wrapper_test.exs deleted file mode 100644 index 2d43417..0000000 --- a/test/rules/cicd_rules_scorecard_wrapper_test.exs +++ /dev/null @@ -1,128 +0,0 @@ -# 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