@@ -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
12261421end
0 commit comments