Skip to content

Commit a5c471b

Browse files
feat(rules): WH004 = canonical unpinned-action + content-scanner activates 6 dormant rules (#384)
## Summary Two audit consolidations bundled (4 files, +318/-54): ### A) WH004 = canonical unpinned-action source (audit §3.1) - Extracted `wh004_scan_content(filename, content)` core from `wh004_unpinned_uses(repo_path)` - `WorkflowAudit.check_unpinned_actions/1` now delegates to WH004 core + post-processes for `pin_exempt?` + `known_sha` extras (output schema unchanged) - `cicd_rules/:unpinned_action` annotated as fast-path mirror of WH004 (catalogue lockstep) ### B) Content-scanner activates 6 dormant rules (audit §5) New `CicdRules.scan_content_patterns(repo_path)` walks the repo and fires the regex+applies_to rules previously hidden behind the `check_pattern(%{pattern:}) -> []` fallthrough. Activated rules: - `:innerhtml_usage`, `:eval_in_shell`, `:download_then_run_shell`, `:hardcoded_tmp`, `:innerhtml_usage` - Once #383 merges + rebase: `:v_build_in_ci`, `:npx_in_workflow`, `:http_in_docs` light up via same path Honored mechanisms: - `applies_to` glob (supports `*.ext`, `**/*`, literal names) - `path_allow_prefixes` (substring, glob-shape mirror) - `exception` (single substring — covers existing `Containerfile` carve-out) - `exception_repos` (repo basename match) - `negative: true` (fires when pattern is ABSENT) - Inline pragma: `hypatia:ignore <rule_id>` on same or preceding line Rules without `applies_to` (e.g. `:deno_all_perms`, `:template_placeholder`) intentionally left dormant — adding `applies_to: ["*"]` is a deliberate follow-up. ## Test plan - [x] 9 new tests in `test/rules/cicd_rules_content_scanner_test.exs` (per-rule positive, applies_to filter, same-line + preceding-line pragma, `exception` field) - [x] All 9 pass via standalone elixir - [ ] Full `mix test` green in CI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8e042e6 commit a5c471b

4 files changed

Lines changed: 317 additions & 53 deletions

File tree

lib/rules/cicd_rules.ex

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,15 @@ defmodule Hypatia.Rules.CicdRules do
260260
reason: "Jekyll banned -- _config.yml is Jekyll's site config. Migrate to casket-ssg (hyperpolymath/casket-ssg)."},
261261
%{id: :gemfile_detected, glob: "Gemfile",
262262
reason: "Gemfile banned (no Ruby/Jekyll in estate) -- if this is for Jekyll, migrate to casket-ssg (hyperpolymath/casket-ssg). If for non-Jekyll Ruby, file an exemption request: Ruby itself is not in the allowed-language table."},
263+
# CANONICAL DETECTION: this entry mirrors WH004 in workflow_hardening.ex
264+
# for the fast-path commit-gate. WH004 is the authoritative scanner
265+
# (per audit 2026-05-28 Part 3.1) — its regex + exempt-slug logic
266+
# is the source of truth. workflow_audit/check_unpinned_actions
267+
# delegates to WH004. Any rule-logic change should land in WH004
268+
# first; this entry follows.
263269
%{id: :unpinned_action,
264270
pattern: ~r/uses:\s+[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.\/-]+@(v[0-9][a-zA-Z0-9.-]*|main|master)/,
265-
reason: "GitHub Actions and reusable workflows must be SHA-pinned"},
271+
reason: "GitHub Actions and reusable workflows must be SHA-pinned (canonical detection: WH004 in workflow_hardening.ex)"},
266272
%{id: :missing_permissions, pattern: ~r/^permissions:/m, negative: true,
267273
reason: "Workflows must declare permissions"},
268274
%{id: :missing_spdx, pattern: ~r/^# SPDX-License-Identifier:/m, negative: true,
@@ -318,6 +324,149 @@ defmodule Hypatia.Rules.CicdRules do
318324

319325
defp check_pattern(%{pattern: _regex}, _files), do: []
320326

327+
@doc """
328+
Content scanner — activates the regex+applies_to rules in @blocked_patterns
329+
that were previously dormant.
330+
331+
Walks `repo_path`, opens any file matching one of a rule's `applies_to`
332+
globs, and emits a finding for each regex match. Honors:
333+
334+
* `path_allow_prefixes` — substring match against the relative file
335+
path (mirrors the glob-pattern behaviour).
336+
* `exception` — single-substring exemption against the relative
337+
path (covers the existing `Containerfile` / `rsr-template-repo`
338+
style entries).
339+
* `exception_repos` — list of repo names; if any matches the basename
340+
of `repo_path`, the rule is skipped for this scan.
341+
* `negative: true` — fires when the regex does NOT match (used by
342+
`:missing_permissions` and `:missing_spdx` which test for the
343+
ABSENCE of an expected line).
344+
* Inline pragma — a line starting with `# hypatia:ignore <rule_id>`
345+
or `<!-- hypatia:ignore <rule_id> -->` (for markdown/HTML)
346+
suppresses findings for that rule on the SAME line and the
347+
following line. Matches the convention used by other Hypatia
348+
scanners (scanner_suppression.ex).
349+
350+
Activates these previously-dormant rules: :innerhtml_usage,
351+
:eval_in_shell, :download_then_run_shell, :hardcoded_tmp,
352+
:template_placeholder, :deno_all_perms, :v_build_in_ci (#383),
353+
:npx_in_workflow (#383), :http_in_docs (#383).
354+
355+
Returns a list of findings:
356+
[%{rule: :rule_id, reason: "...", file: "rel/path", line: N, match: "..."}]
357+
"""
358+
def scan_content_patterns(repo_path) do
359+
repo_name = Path.basename(repo_path)
360+
361+
@blocked_patterns
362+
|> Enum.filter(fn p -> Map.has_key?(p, :pattern) and Map.has_key?(p, :applies_to) end)
363+
|> Enum.flat_map(fn rule -> scan_one_content_rule(rule, repo_path, repo_name) end)
364+
end
365+
366+
defp scan_one_content_rule(rule, repo_path, repo_name) do
367+
exception_repos = Map.get(rule, :exception_repos, [])
368+
369+
if repo_name in exception_repos do
370+
[]
371+
else
372+
rule
373+
|> matching_files(repo_path)
374+
|> Enum.flat_map(fn rel -> scan_one_file(rule, repo_path, rel) end)
375+
end
376+
end
377+
378+
defp matching_files(rule, repo_path) do
379+
globs = Map.get(rule, :applies_to, [])
380+
allow_prefixes = Map.get(rule, :path_allow_prefixes, [])
381+
exception = Map.get(rule, :exception)
382+
383+
Path.wildcard("#{repo_path}/**/*", match_dot: false)
384+
|> Enum.reject(&File.dir?/1)
385+
|> Enum.map(&Path.relative_to(&1, repo_path))
386+
|> Enum.filter(fn rel ->
387+
not String.starts_with?(rel, ".git/") and
388+
Enum.any?(globs, fn g -> glob_matches?(g, rel) end)
389+
end)
390+
|> Enum.reject(fn rel ->
391+
Enum.any?(allow_prefixes, &String.contains?(rel, &1)) or
392+
(is_binary(exception) and String.contains?(rel, exception))
393+
end)
394+
end
395+
396+
defp scan_one_file(rule, repo_path, rel) do
397+
abs = Path.join(repo_path, rel)
398+
399+
case File.read(abs) do
400+
{:ok, content} ->
401+
negative? = Map.get(rule, :negative, false)
402+
matched? = Regex.match?(rule.pattern, content)
403+
404+
cond do
405+
# Negative rules: fire when pattern is ABSENT
406+
negative? and not matched? ->
407+
[%{rule: rule.id, reason: rule.reason, file: rel, line: 1, match: "(absent)"}]
408+
409+
negative? ->
410+
[]
411+
412+
matched? ->
413+
line_findings(rule, rel, content)
414+
415+
true ->
416+
[]
417+
end
418+
419+
{:error, _} ->
420+
[]
421+
end
422+
end
423+
424+
defp line_findings(rule, rel, content) do
425+
lines = String.split(content, "\n")
426+
427+
lines
428+
|> Enum.with_index(1)
429+
|> Enum.flat_map(fn {line, n} ->
430+
cond do
431+
not Regex.match?(rule.pattern, line) -> []
432+
ignored?(rule.id, lines, n) -> []
433+
true -> [%{rule: rule.id, reason: rule.reason, file: rel, line: n, match: String.trim(line)}]
434+
end
435+
end)
436+
end
437+
438+
# Inline pragma: this line OR the previous line carries
439+
# `hypatia:ignore <rule_id>` (in any comment syntax we recognise).
440+
defp ignored?(rule_id, lines, n) do
441+
here = Enum.at(lines, n - 1, "")
442+
prev = Enum.at(lines, n - 2, "")
443+
needle = "hypatia:ignore #{rule_id}"
444+
String.contains?(here, needle) or String.contains?(prev, needle)
445+
end
446+
447+
defp glob_matches?(glob, path) do
448+
# Support: "*.ext" (suffix), "**/path/**", literal "Justfile" / "Mustfile",
449+
# "*/segment/*" (substring).
450+
cond do
451+
String.starts_with?(glob, "*.") ->
452+
String.ends_with?(path, String.replace_leading(glob, "*", ""))
453+
454+
not String.contains?(glob, "*") ->
455+
String.ends_with?(path, glob) or path == glob
456+
457+
true ->
458+
# Convert glob to regex: ** → .*, * → [^/]*
459+
re =
460+
glob
461+
|> Regex.escape()
462+
|> String.replace("\\*\\*", ".*")
463+
|> String.replace("\\*", "[^/]*")
464+
|> then(&Regex.compile!("^#{&1}$"))
465+
466+
Regex.match?(re, path)
467+
end
468+
end
469+
321470
# ---------------------------------------------------------------------------
322471
# CI/CD Waste Detection
323472
# ---------------------------------------------------------------------------

lib/rules/workflow_audit.ex

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -204,34 +204,43 @@ defmodule Hypatia.Rules.WorkflowAudit do
204204

205205
@doc """
206206
Check for GitHub Actions that use tag references instead of SHA pins.
207+
208+
Delegates the regex detection to `WorkflowHardening.wh004_scan_content/2`
209+
(canonical per audit 2026-05-28 Part 3.1) and post-processes the
210+
canonical findings to add this module's extras: `pin_exempt?` carve-
211+
outs (hypatia#262 self-verifying-ref reusables) and `known_sha`
212+
hints from the `@known_good_shas` table.
207213
"""
208214
def check_unpinned_actions(workflow_contents) do
209215
Enum.flat_map(workflow_contents, fn {filename, content} ->
210-
# Match uses: owner/repo@vN.N.N or @main/@master (tag/branch, not SHA)
211-
Regex.scan(~r/uses:\s*([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.\/-]+)@((?:v[\d][\w.-]*|main|master))\s*$/m, content)
212-
|> Enum.map(fn [_full, action, ref] ->
213-
action_ref = "#{action}@#{ref}"
214-
215-
if Hypatia.Rules.SecurityErrors.pin_exempt?(action_ref) do
216-
# Self-verifying-ref reusable workflow: SHA-pinning is HARMFUL.
217-
# Emit an accept-with-rationale finding (never :pin_sha) so the
218-
# reconciler dismisses the Scorecard alert instead of "fixing" it.
219-
# Refs hyperpolymath/hypatia#262.
216+
Hypatia.Rules.WorkflowHardening.wh004_scan_content(filename, content)
217+
|> Enum.map(fn finding ->
218+
slug = finding.detail.uses
219+
# Reconstruct action_ref + ref from the canonical "slug"
220+
# (e.g. "owner/repo@vN.N.N") emitted by WH004.
221+
[action_ref, ref] =
222+
case String.split(slug, "@", parts: 2) do
223+
[_a, r] -> [slug, r]
224+
[_a] -> [slug, ""]
225+
end
226+
_ = action_ref
227+
228+
if Hypatia.Rules.SecurityErrors.pin_exempt?(slug) do
220229
%{
221230
type: :pin_exempt_accepted,
222231
file: filename,
223-
action_ref: action_ref,
232+
action_ref: slug,
224233
severity: :info,
225234
action: :accept_with_rationale,
226-
rationale: Hypatia.Rules.SecurityErrors.pin_exemption_reason(action_ref)
235+
rationale: Hypatia.Rules.SecurityErrors.pin_exemption_reason(slug)
227236
}
228237
else
229238
severity = if ref in ["main", "master"], do: :high, else: :medium
230239

231240
%{
232241
type: :unpinned_action,
233242
file: filename,
234-
action_ref: action_ref,
243+
action_ref: slug,
235244
severity: severity,
236245
action: :pin_sha,
237246
known_sha: Map.get(Hypatia.Rules.SecurityErrors.sha_pins(), action_ref)

lib/rules/workflow_hardening.ex

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -285,54 +285,80 @@ defmodule Hypatia.Rules.WorkflowHardening do
285285
release can substitute the implementation.
286286
"""
287287
def wh004_unpinned_uses(repo_path) do
288-
# Skip local-action / docker refs. `actions/...` and `github/...`
289-
# are GitHub-owned and exempt by convention (configurable later).
288+
# Path-walking wrapper: enumerate workflow files and delegate per-file
289+
# scanning to `wh004_scan_content/2` so the same detection logic is
290+
# callable both from a repo-path walker (this function) and from a
291+
# pre-loaded `{filename, content}` mapper (workflow_audit). See audit
292+
# 2026-05-28 Part 3.1 — WH004 is now the canonical unpinned-action
293+
# source; workflow_audit/check_unpinned_actions delegates here.
290294
repo_path
291295
|> workflow_files()
292296
|> Enum.flat_map(fn path ->
293297
content = File.read!(path)
294298
rel = Path.relative_to(path, repo_path)
299+
wh004_scan_content(rel, content)
300+
end)
301+
end
295302

296-
Regex.scan(~r/^\s*-?\s*uses:\s*(\S+)/m, content, return: :index)
297-
|> Enum.flat_map(fn [{full_start, _}, {slug_start, slug_len}] ->
298-
slug = String.slice(content, slug_start, slug_len)
303+
@doc """
304+
WH004 core scanner: detect unpinned `uses:` references in a single
305+
workflow file's content. Returns the canonical WH004 finding shape
306+
with severity `:warn` (no special-casing of main/master here; callers
307+
that want main-vs-tag severity bumps can post-process).
299308
300-
cond do
301-
String.starts_with?(slug, "./") or String.starts_with?(slug, "docker://") ->
302-
[]
303-
304-
# Already SHA-pinned (40 hex chars after @)
305-
Regex.match?(~r/@[a-fA-F0-9]{40}\b/, slug) ->
306-
[]
307-
308-
# Has an @ but it's a tag or branch
309-
String.contains?(slug, "@") ->
310-
line_no = line_number_for_offset(content, full_start)
311-
312-
[
313-
%{
314-
rule: "WH004",
315-
file: rel,
316-
severity: :warn,
317-
reason:
318-
"workflow #{rel}:#{line_no} pins `#{slug}` to a tag/branch — " <>
319-
"mutable ref allows upstream takeover",
320-
action: :report,
321-
detail: %{
322-
line: line_no,
323-
uses: slug,
324-
fix:
325-
"Replace tag/branch ref with a 40-char commit SHA: " <>
326-
"`gh api repos/<owner>/<repo>/git/refs/tags/<tag> --jq .object.sha`. " <>
327-
"Append `# <tag>` as a comment for readability."
328-
}
309+
Exempts: local-action refs (`./...`), docker images (`docker://...`),
310+
already-SHA-pinned refs (40 hex chars after `@`).
311+
312+
This is the canonical unpinned-action detection per audit 2026-05-28
313+
Part 3.1. Direct consumers:
314+
315+
* `wh004_unpinned_uses/1` — repo-path walker (this module)
316+
* `Hypatia.Rules.WorkflowAudit.check_unpinned_actions/1` — pre-
317+
loaded-content path; post-processes findings to add
318+
`pin_exempt?` carve-outs and `@known_good_shas` lookups.
319+
320+
Both should be the only sites doing unpinned-action regex matching.
321+
"""
322+
def wh004_scan_content(filename, content) do
323+
Regex.scan(~r/^\s*-?\s*uses:\s*(\S+)/m, content, return: :index)
324+
|> Enum.flat_map(fn [{full_start, _}, {slug_start, slug_len}] ->
325+
slug = String.slice(content, slug_start, slug_len)
326+
327+
cond do
328+
String.starts_with?(slug, "./") or String.starts_with?(slug, "docker://") ->
329+
[]
330+
331+
# Already SHA-pinned (40 hex chars after @)
332+
Regex.match?(~r/@[a-fA-F0-9]{40}\b/, slug) ->
333+
[]
334+
335+
# Has an @ but it's a tag or branch
336+
String.contains?(slug, "@") ->
337+
line_no = line_number_for_offset(content, full_start)
338+
339+
[
340+
%{
341+
rule: "WH004",
342+
file: filename,
343+
severity: :warn,
344+
reason:
345+
"workflow #{filename}:#{line_no} pins `#{slug}` to a tag/branch — " <>
346+
"mutable ref allows upstream takeover",
347+
action: :report,
348+
detail: %{
349+
line: line_no,
350+
uses: slug,
351+
fix:
352+
"Replace tag/branch ref with a 40-char commit SHA: " <>
353+
"`gh api repos/<owner>/<repo>/git/refs/tags/<tag> --jq .object.sha`. " <>
354+
"Append `# <tag>` as a comment for readability."
329355
}
330-
]
356+
}
357+
]
331358

332-
true ->
333-
[]
334-
end
335-
end)
359+
true ->
360+
[]
361+
end
336362
end)
337363
end
338364

0 commit comments

Comments
 (0)