Skip to content

Commit 49f57a0

Browse files
feat(rules): WF018/WF019/WF020 + SD014 — 4 new CI/CD detection rules (closes #336/#337/#338/#390) (#403)
## Summary - **WF018** (workflow_audit.ex): scorecard.yml wrappers that delegate to standards' `scorecard-reusable.yml` but lack `security-events: write`. Estate baseline: 81 of 88 wrappers affected — every Scorecard run silently fails with `startup_failure`. Closes #390. - **WF019** (workflow_audit.ex): in-tree `workflow-linter.yml` that greps for `uses:` across all workflow files without exempting itself or the sibling `scorecard-enforcer.yml`. The linter flags itself. Observed in ipv6-only#9/#10, file-soup#44, fireflag#30. Closes #337. - **WF020** (workflow_audit.ex): companion to existing `check_codeql_language_matrix_mismatch`. Catches the positive case: codeql.yml that does not list `language: actions` in its matrix on a repo with workflow files. Closes #338. - **SD014** (structural_drift.ex): `examples/SafeDOMExample.res` lingering without the canonical `.affine` replacement (ReScript banned in new code 2026-04-30). Two states: fail when `.res` only, warn when both dialects present. Closes #336. Each new check is wired into the corresponding module's audit/scan dispatcher and accompanied by sensitivity/specificity comment block documenting fire conditions. ## Test plan - [ ] mix test (when phoenix dep compile is unblocked locally — the in-tree mix compile currently fails on `Phoenix.Naming` due to an Elixir 1.14 vs Phoenix version issue, unrelated to this change) - [ ] Once merged: run hypatia scan against the cited prior-art repos and confirm each new rule fires once - [ ] Test scaffolds for the 4 new rules will land in a follow-up PR keyed to issue #333 cohort completion ## Closes - #336 - #337 - #338 - #390 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6a2b9bc commit 49f57a0

2 files changed

Lines changed: 262 additions & 2 deletions

File tree

lib/rules/structural_drift.ex

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,70 @@ defmodule Hypatia.Rules.StructuralDrift do
568568
end
569569
end
570570

571+
# ─── SD014: SafeDOMExample dialect mismatch (.res lingers without .affine) ───
572+
573+
@doc """
574+
SD014: Detect `examples/SafeDOMExample.res` lingering in repos that
575+
haven't picked up the canonical AffineScript version
576+
`examples/SafeDOMExample.affine` (lives in burble/main since the
577+
2026-05 ReScript→AffineScript migration).
578+
579+
Three states:
580+
* `.res` only → `:fail` (dialect_mismatch) — repo carries the legacy
581+
ReScript variant with no AffineScript replacement.
582+
* Both `.res` and `.affine` → `:warn` (both_dialects) — delete the
583+
`.res` copy; the `.affine` is canonical.
584+
* `.affine` only or neither → no finding.
585+
586+
Origin: dominant per-PR failure class on the otpiser#11 + 48-PR
587+
sweep was `governance / Language / package anti-pattern policy`
588+
firing on `examples/SafeDOMExample.res`. Template repos kept
589+
regenerating it; this rule catches the long tail. See
590+
hyperpolymath/hypatia#336.
591+
"""
592+
def sd014_safedom_example_dialect(repo_path) do
593+
res_path = Path.join(repo_path, "examples/SafeDOMExample.res")
594+
affine_path = Path.join(repo_path, "examples/SafeDOMExample.affine")
595+
has_res = File.exists?(res_path)
596+
has_affine = File.exists?(affine_path)
597+
598+
cond do
599+
has_res and not has_affine ->
600+
[%{
601+
rule: "SD014",
602+
type: :safedom_example_dialect_mismatch,
603+
file: "examples/SafeDOMExample.res",
604+
severity: :high,
605+
reason:
606+
"examples/SafeDOMExample.res lingers without the canonical " <>
607+
"AffineScript replacement examples/SafeDOMExample.affine. " <>
608+
"ReScript is banned in new code as of 2026-04-30 " <>
609+
"(estate policy); the canonical .affine version lives in " <>
610+
"burble/main. The governance/language-policy check fires " <>
611+
"on every push until the .res is replaced.",
612+
action: :replace_safedom_with_affine,
613+
trigger_intensive: false
614+
}]
615+
616+
has_res and has_affine ->
617+
[%{
618+
rule: "SD014",
619+
type: :safedom_example_both_dialects,
620+
file: "examples/SafeDOMExample.res",
621+
severity: :medium,
622+
reason:
623+
"Both examples/SafeDOMExample.res and " <>
624+
"examples/SafeDOMExample.affine are present. The .affine " <>
625+
"is canonical; delete the .res copy.",
626+
action: :delete_legacy_safedom_res,
627+
trigger_intensive: false
628+
}]
629+
630+
true ->
631+
[]
632+
end
633+
end
634+
571635
# ─── Comprehensive scan (triggered by any finding) ─────────────────────
572636

573637
@doc """
@@ -588,7 +652,8 @@ defmodule Hypatia.Rules.StructuralDrift do
588652
sd009_missing_spdx(repo_path) ++
589653
sd010_tracked_node_modules(repo_path) ++
590654
sd011_missing_gitignore(repo_path) ++
591-
sd013_path_specific_gitignore(repo_path)
655+
sd013_path_specific_gitignore(repo_path) ++
656+
sd014_safedom_example_dialect(repo_path)
592657

593658
needs_intensive = Enum.any?(findings, & &1[:trigger_intensive])
594659
needs_alert = Enum.any?(findings, & &1[:alert_user])

lib/rules/workflow_audit.ex

Lines changed: 196 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ defmodule Hypatia.Rules.WorkflowAudit do
8080
nonroot_container_eacces = check_nonroot_container_checkout_eacces(workflow_contents)
8181
orphan_reusable_pins = check_orphan_standards_reusable_pin(workflow_contents)
8282
ungated_secret_action = check_ungated_secret_action(workflow_contents)
83+
scorecard_wrapper_missing_perms = check_scorecard_wrapper_missing_job_permissions(workflow_contents)
84+
workflow_linter_self_ref = check_workflow_linter_self_reference(workflow_contents)
85+
codeql_missing_actions = check_codeql_missing_actions_language(workflow_contents)
8386

8487
%{
8588
findings:
@@ -88,7 +91,8 @@ defmodule Hypatia.Rules.WorkflowAudit do
8891
codeql_lang_mismatch ++ workflow_sha_foreign_ref ++
8992
reusable_caller_context_self_checkout ++ missing_timeouts ++
9093
scorecard_publish_run ++ nonroot_container_eacces ++ orphan_reusable_pins ++
91-
ungated_secret_action,
94+
ungated_secret_action ++ scorecard_wrapper_missing_perms ++
95+
workflow_linter_self_ref ++ codeql_missing_actions,
9296
missing_count: length(missing),
9397
unpinned_count: length(unpinned),
9498
wrong_pin_count: length(wrong_pins),
@@ -105,6 +109,9 @@ defmodule Hypatia.Rules.WorkflowAudit do
105109
nonroot_container_eacces_count: length(nonroot_container_eacces),
106110
orphan_reusable_pin_count: length(orphan_reusable_pins),
107111
ungated_secret_action_count: length(ungated_secret_action),
112+
scorecard_wrapper_missing_perms_count: length(scorecard_wrapper_missing_perms),
113+
workflow_linter_self_ref_count: length(workflow_linter_self_ref),
114+
codeql_missing_actions_count: length(codeql_missing_actions),
108115
workflow_count: length(workflow_files),
109116
standard_coverage: coverage_percentage(workflow_files)
110117
}
@@ -1223,4 +1230,192 @@ defmodule Hypatia.Rules.WorkflowAudit do
12231230
end
12241231

12251232
def check_flawed_regex(_), do: []
1233+
1234+
# ─── WF018: Scorecard wrapper missing job-level permissions ───────────
1235+
#
1236+
# Caller-of-`scorecard-reusable.yml` workflow without job-level
1237+
# `security-events: write`. Reusable called-workflow permissions are
1238+
# CAPPED by the caller's grant: even though the reusable re-asserts
1239+
# the grant on its own analysis job, the cap silently zeros it out.
1240+
# ossf/scorecard-action then cannot upload SARIF and the run fails
1241+
# with `startup_failure` — no logs, no findings, the silent-CI-
1242+
# failure class hypatia is best positioned to catch.
1243+
#
1244+
# Estate baseline 2026-05-30: 81 of 88 wrappers across the estate
1245+
# were in this state (see standards#303 / #282). Canonical fix shape
1246+
# in standards/.github/workflows/scorecard-reusable.yml docstring.
1247+
# See hyperpolymath/hypatia#390 + memory
1248+
# feedback_scorecard_wrapper_caller_permissions.md.
1249+
1250+
@doc """
1251+
WF018: Detect a `scorecard.yml` wrapper that delegates to
1252+
`hyperpolymath/standards`'s `scorecard-reusable.yml` but lacks
1253+
`security-events: write`.
1254+
1255+
Sensitivity / specificity:
1256+
* Specific — only fires when the file references
1257+
`scorecard-reusable.yml`. A standalone scorecard workflow is not
1258+
flagged.
1259+
* Sensitive — looks for the literal `security-events: write` token
1260+
anywhere in the file (workflow-level is enough since called-
1261+
workflow permissions inherit from there, but the fix recipe
1262+
recommends job-level for clarity).
1263+
"""
1264+
def check_scorecard_wrapper_missing_job_permissions(workflow_contents) do
1265+
Enum.flat_map(workflow_contents, fn {filename, content} ->
1266+
base = Path.basename(filename)
1267+
cond do
1268+
base not in ["scorecard.yml", "scorecard.yaml"] ->
1269+
[]
1270+
1271+
not String.contains?(content, "scorecard-reusable.yml") ->
1272+
[]
1273+
1274+
Regex.match?(~r/security-events:\s*write/, content) ->
1275+
[]
1276+
1277+
true ->
1278+
[%{
1279+
rule: "WF018",
1280+
type: :scorecard_wrapper_missing_job_permissions,
1281+
file: filename,
1282+
severity: :high,
1283+
reason:
1284+
"scorecard.yml delegates to hyperpolymath/standards " <>
1285+
"`scorecard-reusable.yml` but the file does not declare " <>
1286+
"`security-events: write`. Reusable called-workflow " <>
1287+
"permissions are CAPPED by the caller's grants; the " <>
1288+
"reusable's own job-level grant cannot exceed what the " <>
1289+
"caller provides. Result: ossf/scorecard-action cannot " <>
1290+
"upload SARIF and the run fails with `startup_failure` " <>
1291+
"(no logs, no findings). Add `permissions: " <>
1292+
"{security-events: write, id-token: write}` at the job " <>
1293+
"level (preferred) or workflow level.",
1294+
fix_recipe: :add_job_level_scorecard_perms
1295+
}]
1296+
end
1297+
end)
1298+
end
1299+
1300+
# ─── WF019: workflow-linter.yml self-referential `uses:` grep ─────────
1301+
#
1302+
# Repos carrying the legacy in-tree `workflow-linter.yml` (rather than
1303+
# the consolidated `governance.yml` → standards reusable) often
1304+
# contain a shell step that `grep`s for `uses:` across all workflow
1305+
# files. The linter's own comments + grep command line contain
1306+
# literal `uses:` tokens, so the linter flags itself. Fix is to
1307+
# exempt `workflow-linter.yml` (and the sibling
1308+
# `scorecard-enforcer.yml`) from the grep, or to migrate to the
1309+
# consolidated governance reusable.
1310+
#
1311+
# Observed 4 repos in this state on the 2026-05-30 sweep
1312+
# (ipv6-only#9 / #10, file-soup#44, fireflag#30). See
1313+
# hyperpolymath/hypatia#337.
1314+
1315+
@doc """
1316+
WF019: Detect `workflow-linter.yml` that greps for `uses:` across all
1317+
workflow files without exempting itself or the canonical
1318+
`scorecard-enforcer.yml`.
1319+
1320+
Sensitivity / specificity:
1321+
* Specific — fires only when all three markers are present: file
1322+
basename is `workflow-linter.yml`, file contains a
1323+
`grep ... "uses:"` invocation, file does NOT contain a string
1324+
naming `workflow-linter.yml` or `scorecard-enforcer.yml`
1325+
(which would suggest a `grep -v` exemption is in place).
1326+
* Sensitive — works regardless of whether the grep is in a `run:`
1327+
block or a heredoc.
1328+
"""
1329+
def check_workflow_linter_self_reference(workflow_contents) do
1330+
Enum.flat_map(workflow_contents, fn {filename, content} ->
1331+
base = Path.basename(filename)
1332+
cond do
1333+
base not in ["workflow-linter.yml", "workflow-linter.yaml"] ->
1334+
[]
1335+
1336+
not Regex.match?(~r/grep[^\n]*["']uses:["']/, content) ->
1337+
[]
1338+
1339+
Regex.match?(~r/workflow-linter\.ya?ml|scorecard-enforcer\.ya?ml/, content) ->
1340+
[]
1341+
1342+
true ->
1343+
[%{
1344+
rule: "WF019",
1345+
type: :workflow_linter_self_reference,
1346+
file: filename,
1347+
severity: :medium,
1348+
reason:
1349+
"workflow-linter.yml greps for `uses:` across all workflow " <>
1350+
"files but does not exempt itself or the sibling " <>
1351+
"`scorecard-enforcer.yml`. Its own comments + grep " <>
1352+
"command line contain literal `uses:` tokens, so the " <>
1353+
"linter flags itself on every run. Either add " <>
1354+
"`grep -v workflow-linter.yml | grep -v scorecard-enforcer.yml` " <>
1355+
"to the pipeline, or migrate to the consolidated " <>
1356+
"`governance.yml` -> standards reusable.",
1357+
fix_recipe: :exempt_linter_from_self_grep
1358+
}]
1359+
end
1360+
end)
1361+
end
1362+
1363+
# ─── WF020: CodeQL workflow missing `language: actions` matrix entry ──
1364+
#
1365+
# Companion to check_codeql_language_matrix_mismatch (which catches
1366+
# the OPPOSITE: a codeql.yml that lists a *source-scanning* language
1367+
# on a repo with no matching source). WF020 catches the positive
1368+
# case: almost every repo has .github/workflows/*.yml, so almost
1369+
# every repo SHOULD declare `language: actions` in its CodeQL matrix.
1370+
#
1371+
# See hyperpolymath/hypatia#338.
1372+
1373+
@doc """
1374+
WF020: Detect a `codeql.yml` that does not list `language: actions`
1375+
in its matrix, when the repo has workflow files.
1376+
1377+
Companion to (not replacement for) `check_codeql_language_matrix_mismatch`.
1378+
This rule says "you SHOULD also scan workflow YAML"; the other says
1379+
"you should NOT pretend to scan a source language you don't have".
1380+
1381+
Sensitivity / specificity:
1382+
* Specific — only fires when codeql.yml exists AND lacks
1383+
`language: actions` AND the workflow_contents map contains at
1384+
least one non-codeql workflow.
1385+
* Sensitive — handles both YAML list-style and inline-string-style
1386+
`language: actions`.
1387+
"""
1388+
def check_codeql_missing_actions_language(workflow_contents) do
1389+
has_other_workflows? =
1390+
Enum.any?(workflow_contents, fn {f, _} ->
1391+
base = Path.basename(f)
1392+
String.ends_with?(base, ".yml") and not codeql_workflow?(f)
1393+
end)
1394+
1395+
if not has_other_workflows? do
1396+
[]
1397+
else
1398+
Enum.flat_map(workflow_contents, fn {filename, content} ->
1399+
if codeql_workflow?(filename) and
1400+
not Regex.match?(~r/language:\s*actions(?:\s|$)/m, content) do
1401+
[%{
1402+
rule: "WF020",
1403+
type: :codeql_missing_actions_language,
1404+
file: filename,
1405+
severity: :medium,
1406+
reason:
1407+
"codeql.yml does not list `language: actions` in its " <>
1408+
"matrix, but the repo has workflow files. CodeQL's " <>
1409+
"`actions` language scans workflow YAML for injection " <>
1410+
"and other CI/CD-specific weaknesses — every repo with " <>
1411+
"workflows benefits. Add an entry to `matrix.include` " <>
1412+
"with `language: actions` + `build-mode: none`.",
1413+
fix_recipe: :add_codeql_actions_language
1414+
}]
1415+
else
1416+
[]
1417+
end
1418+
end)
1419+
end
1420+
end
12261421
end

0 commit comments

Comments
 (0)