Skip to content

Commit af8a772

Browse files
feat(cicd_rules): scorecard_wrapper_missing_job_permissions (#390) (#404)
Detect .github/workflows/scorecard.yml wrappers that call the standards scorecard-reusable.yml but omit `security-events: write` on the calling job (caller caps called-workflow perms → SARIF upload fails → silent startup_failure). Adds scan_/check_ functions + path_allow_prefixes carve-out, facade defdelegate, tests, and changelog entries. Verified at source (Elixir 1.14 locally): syntax, mix-format clean, isolated compile with zero warnings, and 7/7 runtime logic scenarios. Closes #390 https://claude.ai/code/session_01J8oLNn6MjKDRRUF65e2jLf
1 parent 49f57a0 commit af8a772

5 files changed

Lines changed: 250 additions & 0 deletions

File tree

CHANGELOG.adoc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@ https://semver.org/[Semantic Versioning].
1212

1313
=== Added
1414

15+
==== `CicdRules` rule `scorecard_wrapper_missing_job_permissions` (2026-05-30, #390)
16+
17+
New forward-detection rule for a silent-CI-failure class: a
18+
`.github/workflows/scorecard.yml` that *calls* the standards
19+
`scorecard-reusable.yml` but omits `security-events: write` on the calling
20+
job. Called-workflow permissions are capped by the caller, so
21+
`ossf/scorecard-action` cannot upload its SARIF and every scheduled
22+
Scorecard run fails with `startup_failure` — no logs. Estate baseline
23+
2026-05-30: 37 affected wrappers (35 unique + 2 inert nested-monorepo
24+
copies). Prior art: `julia-professional-registry#19`, `absolute-zero#68`.
25+
26+
Surfaced through the facade as
27+
`Hypatia.Rules.scan_scorecard_wrapper_permissions/2`; the pure predicate
28+
`CicdRules.check_scorecard_wrapper_permissions/2` and an
29+
`opts[:path_allow_prefixes]` carve-out (for bespoke inline scorecard
30+
workflows) are covered by `test/rules/cicd_rules_scorecard_wrapper_test.exs`
31+
for both sensitivity (positive + nested copy) and specificity (perm present,
32+
no-reusable, carve-out).
33+
1534
==== `WorkflowAudit` rules WF014–WF017 (2026-05-30, PRs #393 + #396)
1635

1736
Four new forward-detection rules surfacing patterns root-fixed in

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
### Added
2222

23+
- 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)
2324
- feat(rules): AffineScript hand-port pitfalls — HANDLE-as-fn-name + OCaml float ops (#332)
2425
- feat(rules): wire 4 new rule modules through the facade (#326)
2526
- feat(rules): ResearchExtensions (RE001-RE010) — 10 rules from Snyk/StepSecurity/Endor/academic literature (#325)

lib/rules/cicd_rules.ex

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,102 @@ defmodule Hypatia.Rules.CicdRules do
834834
end
835835
end
836836

837+
# ---------------------------------------------------------------------------
838+
# Scorecard Wrapper Permissions (#390)
839+
# ---------------------------------------------------------------------------
840+
#
841+
# A `.github/workflows/scorecard.yml` that *calls* the standards
842+
# `scorecard-reusable.yml` MUST grant `security-events: write` (and
843+
# `id-token: write`) on the calling job. Called-workflow permissions are
844+
# CAPPED by the caller, so a wrapper that omits the grant leaves
845+
# `ossf/scorecard-action` unable to upload its SARIF — every scheduled
846+
# Scorecard run then fails with `startup_failure` and *no logs*. That
847+
# silent-CI-failure shape is exactly what Hypatia exists to catch.
848+
#
849+
# Estate baseline 2026-05-30: 37 affected wrappers (35 unique + 2 inert
850+
# nested-monorepo copies). Prior art: julia-professional-registry#19,
851+
# absolute-zero#68 (memory: feedback_scorecard_wrapper_caller_permissions).
852+
853+
@scorecard_wrapper_path ".github/workflows/scorecard.yml"
854+
@scorecard_reusable_marker "scorecard-reusable.yml"
855+
@scorecard_required_perm ~r/security-events:\s*write/
856+
@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)."
857+
@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"
858+
859+
@doc """
860+
Scan `repo_path` for scorecard wrappers that call the standards reusable
861+
but omit the required `security-events: write` job permission (#390).
862+
863+
A finding is emitted for each `.github/workflows/scorecard.yml` (repo-root
864+
*or* nested monorepo copy) that, in the same file:
865+
866+
* references `scorecard-reusable.yml` (i.e. uses the reusable), AND
867+
* does NOT grant `security-events: write`.
868+
869+
Inline scorecard workflows that do not call the reusable are ignored by
870+
construction (the first condition fails). `opts[:path_allow_prefixes]` is a
871+
list of substrings; any wrapper whose relative path contains one is skipped
872+
— an explicit carve-out for bespoke scorecard workflows that manage their
873+
own permissions shape.
874+
875+
Returns `[%{rule:, severity:, file:, reason:, fix:}]`.
876+
"""
877+
def scan_scorecard_wrapper_permissions(repo_path, opts \\ []) do
878+
allow_prefixes = Keyword.get(opts, :path_allow_prefixes, [])
879+
880+
Path.wildcard("#{repo_path}/**/*", match_dot: true)
881+
|> Enum.reject(&File.dir?/1)
882+
|> Enum.map(&Path.relative_to(&1, repo_path))
883+
|> Enum.filter(fn rel ->
884+
not String.starts_with?(rel, ".git/") and scorecard_wrapper_path?(rel)
885+
end)
886+
|> Enum.reject(fn rel -> Enum.any?(allow_prefixes, &String.contains?(rel, &1)) end)
887+
|> Enum.flat_map(fn rel ->
888+
case File.read(Path.join(repo_path, rel)) do
889+
{:ok, content} ->
890+
case check_scorecard_wrapper_permissions(rel, content) do
891+
{:fail, finding} -> [finding]
892+
:ok -> []
893+
end
894+
895+
{:error, _} ->
896+
[]
897+
end
898+
end)
899+
end
900+
901+
@doc """
902+
Pure predicate behind `scan_scorecard_wrapper_permissions/2`.
903+
904+
Given a scorecard wrapper's relative `path` and its `content`, returns
905+
`{:fail, finding}` when the file calls the standards reusable but does not
906+
grant `security-events: write`, or `:ok` otherwise.
907+
"""
908+
def check_scorecard_wrapper_permissions(path, content) do
909+
uses_reusable? = String.contains?(content, @scorecard_reusable_marker)
910+
grants_perm? = Regex.match?(@scorecard_required_perm, content)
911+
912+
if uses_reusable? and not grants_perm? do
913+
finding = %{
914+
rule: :scorecard_wrapper_missing_job_permissions,
915+
severity: :high,
916+
file: path,
917+
reason: @scorecard_missing_perm_reason,
918+
fix: @scorecard_missing_perm_fix
919+
}
920+
921+
{:fail, finding}
922+
else
923+
:ok
924+
end
925+
end
926+
927+
# True when `rel` is a scorecard wrapper workflow — the repo-root copy or
928+
# any nested monorepo copy (`pkg/.github/workflows/scorecard.yml`).
929+
defp scorecard_wrapper_path?(rel) do
930+
rel == @scorecard_wrapper_path or String.ends_with?(rel, "/" <> @scorecard_wrapper_path)
931+
end
932+
837933
# ---------------------------------------------------------------------------
838934
# CI/CD Waste Detection
839935
# ---------------------------------------------------------------------------

lib/rules/rules.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,12 @@ defmodule Hypatia.Rules do
422422
"""
423423
defdelegate detect_waste(repo_info), to: CicdRules
424424

425+
@doc """
426+
Scan for scorecard wrappers that call the standards reusable but omit the
427+
required `security-events: write` job permission (#390).
428+
"""
429+
defdelegate scan_scorecard_wrapper_permissions(repo_path, opts \\ []), to: CicdRules
430+
425431
@doc """
426432
Run baseline-health checks (BH001-BH007): missing required_status_checks
427433
on main, deferred-migration TODOs in dep manifests, persistent >24h red
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
3+
defmodule Hypatia.Rules.CicdRules.ScorecardWrapperTest do
4+
use ExUnit.Case, async: true
5+
6+
alias Hypatia.Rules.CicdRules
7+
8+
# #390 — a `.github/workflows/scorecard.yml` that CALLS the standards
9+
# `scorecard-reusable.yml` must grant `security-events: write` on the
10+
# calling job, or the scheduled Scorecard run fails with `startup_failure`
11+
# (no logs). Detection: uses "scorecard-reusable.yml" AND NOT
12+
# "security-events: write". Sensitivity + specificity both covered.
13+
14+
@wf_path ".github/workflows/scorecard.yml"
15+
16+
@reusable_no_perm """
17+
# SPDX-License-Identifier: MPL-2.0
18+
name: Scorecard
19+
on:
20+
schedule:
21+
- cron: "0 2 * * 1"
22+
permissions: read-all
23+
jobs:
24+
analysis:
25+
uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@abc1234
26+
"""
27+
28+
@reusable_with_perm """
29+
# SPDX-License-Identifier: MPL-2.0
30+
name: Scorecard
31+
on:
32+
schedule:
33+
- cron: "0 2 * * 1"
34+
permissions: read-all
35+
jobs:
36+
analysis:
37+
permissions:
38+
security-events: write
39+
id-token: write
40+
uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@abc1234
41+
"""
42+
43+
@inline_no_reusable """
44+
# SPDX-License-Identifier: MPL-2.0
45+
name: Scorecard
46+
on:
47+
schedule:
48+
- cron: "0 2 * * 1"
49+
permissions: read-all
50+
jobs:
51+
analysis:
52+
runs-on: ubuntu-latest
53+
steps:
54+
- uses: actions/checkout@v4
55+
- uses: ossf/scorecard-action@v2
56+
"""
57+
58+
setup do
59+
dir = Path.join(System.tmp_dir!(), "hyp-scw-#{:erlang.unique_integer([:positive])}")
60+
File.mkdir_p!(dir)
61+
on_exit(fn -> File.rm_rf!(dir) end)
62+
{:ok, dir: dir}
63+
end
64+
65+
defp write_scorecard(dir, body, sub \\ "") do
66+
rel = if sub == "", do: @wf_path, else: Path.join(sub, @wf_path)
67+
path = Path.join(dir, rel)
68+
File.mkdir_p!(Path.dirname(path))
69+
File.write!(path, body)
70+
path
71+
end
72+
73+
describe "scan_scorecard_wrapper_permissions/2 — sensitivity" do
74+
test "fires when wrapper uses reusable but lacks the perm", %{dir: dir} do
75+
write_scorecard(dir, @reusable_no_perm)
76+
assert [finding] = CicdRules.scan_scorecard_wrapper_permissions(dir)
77+
assert finding.rule == :scorecard_wrapper_missing_job_permissions
78+
assert finding.severity == :high
79+
assert finding.file == @wf_path
80+
assert finding.fix =~ "security-events: write"
81+
end
82+
83+
test "fires on a nested monorepo copy", %{dir: dir} do
84+
write_scorecard(dir, @reusable_no_perm, "packages/api")
85+
assert [finding] = CicdRules.scan_scorecard_wrapper_permissions(dir)
86+
assert finding.file == "packages/api/" <> @wf_path
87+
end
88+
end
89+
90+
describe "scan_scorecard_wrapper_permissions/2 — specificity" do
91+
test "silent when wrapper grants the perm", %{dir: dir} do
92+
write_scorecard(dir, @reusable_with_perm)
93+
assert CicdRules.scan_scorecard_wrapper_permissions(dir) == []
94+
end
95+
96+
test "silent for inline scorecard not using the reusable", %{dir: dir} do
97+
write_scorecard(dir, @inline_no_reusable)
98+
assert CicdRules.scan_scorecard_wrapper_permissions(dir) == []
99+
end
100+
101+
test "path_allow_prefixes carve-out skips the wrapper", %{dir: dir} do
102+
write_scorecard(dir, @reusable_no_perm, "vendor/upstream")
103+
104+
findings =
105+
CicdRules.scan_scorecard_wrapper_permissions(dir, path_allow_prefixes: ["vendor/"])
106+
107+
assert findings == []
108+
end
109+
end
110+
111+
describe "check_scorecard_wrapper_permissions/2 — pure predicate" do
112+
test "fail when reusable present and perm absent" do
113+
result = CicdRules.check_scorecard_wrapper_permissions(@wf_path, @reusable_no_perm)
114+
assert {:fail, finding} = result
115+
assert finding.rule == :scorecard_wrapper_missing_job_permissions
116+
assert finding.reason =~ "startup_failure"
117+
end
118+
119+
test "ok when perm present with irregular spacing" do
120+
body = String.replace(@reusable_no_perm, "uses:", "security-events: write\n uses:")
121+
assert :ok = CicdRules.check_scorecard_wrapper_permissions(@wf_path, body)
122+
end
123+
124+
test "ok when reusable not called" do
125+
assert :ok = CicdRules.check_scorecard_wrapper_permissions(@wf_path, @inline_no_reusable)
126+
end
127+
end
128+
end

0 commit comments

Comments
 (0)