Skip to content

Commit b1782ca

Browse files
feat(workflow_audit): WF022 unanchored_heading_regex (#360) (#410)
WorkflowAudit WF022: flag a markdown-heading-detection regex used unanchored inside inline Python in a workflow run: block (e.g. re.search(r'TypeScript [Ee]xemptions', line) without ^# anchor) — it also matches prose mentions and silently parses the wrong section. Gated on python3 presence + Title-Case heading shape + absence of ^# anchoring. Threaded into audit/3; tests + changelog. Verified locally (Elixir 1.14): isolated compile (no new warnings), real compiled module passes detection fixtures, zero findings on hypatia's own workflows. Pre-existing lines kept byte-identical; the one concat reflow follows 1.17 greedy-fill style. Closes #360 https://claude.ai/code/session_01J8oLNn6MjKDRRUF65e2jLf
1 parent 9387601 commit b1782ca

4 files changed

Lines changed: 130 additions & 1 deletion

File tree

CHANGELOG.adoc

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

1313
=== Added
1414

15+
==== `WorkflowAudit` rule `WF022` unanchored-heading-regex (2026-05-30, #360)
16+
17+
Flags a markdown-heading-detection regex used *unanchored* inside inline
18+
Python in a workflow `run:` block — e.g. `re.search(r'TypeScript [Ee]xemptions', line)`
19+
with no `^#` anchor. Such a regex also matches prose mentions of the phrase,
20+
so the parser silently walks the wrong section. This was the multi-week
21+
silent failure of affinescript's governance gate (`standards#183` anchored
22+
it). HIGH severity (estate-wide, invisible in logs). Heuristic: gated on
23+
`python3` presence, a Title-Case heading shape, and absence of `^#`
24+
anchoring; single-word matches (`Error: (.+)`) and anchored patterns are
25+
ignored. Threaded into `audit/3`; covered in `test/workflow_audit_test.exs`
26+
(positive + anchored / non-heading / no-python negatives). Zero findings on
27+
hypatia's own workflows. Cohort hypatia#333, pattern 1.
28+
1529
==== `WorkflowAudit` rule `WF021` concurrency-missing-readonly (2026-05-30, #365)
1630

1731
Flags a read-only check workflow (runs on `pull_request`/`push`, with a

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): WorkflowAudit WF022 `unanchored_heading_regex` — flag inline-python heading-detection regexes not anchored to `^#` (#360)
2324
- feat(rules): WorkflowAudit WF021 `concurrency_missing_readonly` — flag read-only PR/push check workflows lacking a `concurrency:` block (#365)
2425
- feat(rules): CicdRules `duplicate_cron_schedule` — flag workflows with redundant cron entries on the same day-of-week / daily-subset (#362)
2526
- feat(rules): AffineScript hand-port pitfalls — HANDLE-as-fn-name + OCaml float ops (#332)

lib/rules/workflow_audit.ex

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ defmodule Hypatia.Rules.WorkflowAudit do
8484
workflow_linter_self_ref = check_workflow_linter_self_reference(workflow_contents)
8585
codeql_missing_actions = check_codeql_missing_actions_language(workflow_contents)
8686
concurrency_missing = check_concurrency_missing_readonly(workflow_contents)
87+
heading_regex_issues = check_unanchored_heading_regex(workflow_contents)
8788

8889
%{
8990
findings:
@@ -93,7 +94,8 @@ defmodule Hypatia.Rules.WorkflowAudit do
9394
reusable_caller_context_self_checkout ++ missing_timeouts ++
9495
scorecard_publish_run ++ nonroot_container_eacces ++ orphan_reusable_pins ++
9596
ungated_secret_action ++ scorecard_wrapper_missing_perms ++
96-
workflow_linter_self_ref ++ codeql_missing_actions ++ concurrency_missing,
97+
workflow_linter_self_ref ++ codeql_missing_actions ++ concurrency_missing ++
98+
heading_regex_issues,
9799
missing_count: length(missing),
98100
unpinned_count: length(unpinned),
99101
wrong_pin_count: length(wrong_pins),
@@ -114,6 +116,7 @@ defmodule Hypatia.Rules.WorkflowAudit do
114116
workflow_linter_self_ref_count: length(workflow_linter_self_ref),
115117
codeql_missing_actions_count: length(codeql_missing_actions),
116118
concurrency_missing_count: length(concurrency_missing),
119+
heading_regex_issues_count: length(heading_regex_issues),
117120
workflow_count: length(workflow_files),
118121
standard_coverage: coverage_percentage(workflow_files)
119122
}
@@ -1476,4 +1479,62 @@ defmodule Hypatia.Rules.WorkflowAudit do
14761479
end
14771480
end)
14781481
end
1482+
1483+
# ─── WF022: Unanchored heading regex in inline-python workflow scripts ─
1484+
#
1485+
# An inline-python `run:` step that detects a markdown section heading with
1486+
# `re.search(r'TitleCase Phrase', line)` — without anchoring to `^#` /
1487+
# `^#{1,4}\s+` — also matches *prose* mentions of the same phrase, so the
1488+
# parser silently walks the wrong table. This was the multi-week silent
1489+
# gate failure on affinescript's governance check (standards#183 anchored
1490+
# the regex). HIGH severity: estate-wide and invisible in logs.
1491+
#
1492+
# See hyperpolymath/hypatia#360 (cohort hypatia#333, pattern 1).
1493+
1494+
@wf022_re_call ~r/re\.(?:search|match)\(\s*r(['"])(.*?)\1/
1495+
@wf022_reason "inline-python heading-detection regex is not anchored to `^#` / `^\#{1,4}\\s+`; it also matches prose mentions of the heading phrase, so the parser can silently walk the wrong section. Anchor the regex to the heading shape (e.g. `^\#{1,4}\\s+.*Phrase`) and name the intended heading in a comment."
1496+
1497+
@doc """
1498+
WF022: Detect a heading-detection regex used unanchored inside inline
1499+
Python in a workflow `run:` block.
1500+
1501+
Heuristic: in a file that uses `python3`, find `re.search`/`re.match` whose
1502+
literal pattern has a markdown-heading shape (a Title-Case word followed by
1503+
another Title-Case word or a `[Xx]`-style char class) and does NOT start
1504+
with `^#`.
1505+
1506+
Sensitivity / specificity:
1507+
* Specific — gated on `python3` presence, the heading-phrase shape, and
1508+
absence of `^#` anchoring; single-word matches (e.g. `Error: (.+)`) and
1509+
already-anchored patterns are ignored.
1510+
* Sensitive — fires on any such unanchored heading regex in the file.
1511+
"""
1512+
def check_unanchored_heading_regex(workflow_contents) do
1513+
Enum.flat_map(workflow_contents, fn {filename, content} ->
1514+
if String.contains?(content, "python3") do
1515+
@wf022_re_call
1516+
|> Regex.scan(content)
1517+
|> Enum.map(fn [_, _q, pat] -> pat end)
1518+
|> Enum.filter(&wf022_heading_unanchored?/1)
1519+
|> Enum.map(fn pat ->
1520+
%{
1521+
rule: "WF022",
1522+
type: :unanchored_heading_regex,
1523+
file: filename,
1524+
severity: :high,
1525+
pattern: pat,
1526+
reason: @wf022_reason,
1527+
fix_recipe: :anchor_heading_regex
1528+
}
1529+
end)
1530+
else
1531+
[]
1532+
end
1533+
end)
1534+
end
1535+
1536+
defp wf022_heading_unanchored?(pat) do
1537+
heading_like? = Regex.match?(~r/[A-Z][a-z]+\s+(?:[A-Z][a-z]+|\[[A-Za-z])/, pat)
1538+
heading_like? and not String.starts_with?(pat, "^#")
1539+
end
14791540
end

test/workflow_audit_test.exs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,4 +529,57 @@ defmodule Hypatia.Rules.WorkflowAuditTest do
529529
assert [] = WorkflowAudit.check_concurrency_missing_readonly(%{"labeler.yml" => wf})
530530
end
531531
end
532+
533+
describe "check_unanchored_heading_regex/1 (WF022)" do
534+
test "flags an unanchored heading regex inside inline python" do
535+
wf = """
536+
jobs:
537+
gate:
538+
steps:
539+
- run: |
540+
python3 << 'PYEOF'
541+
if re.search(r'TypeScript [Ee]xemptions', line):
542+
pass
543+
PYEOF
544+
"""
545+
546+
[f] = WorkflowAudit.check_unanchored_heading_regex(%{"gov.yml" => wf})
547+
assert f.rule == "WF022"
548+
assert f.severity == :high
549+
end
550+
551+
test "silent when the heading regex is anchored to ^#" do
552+
wf = """
553+
jobs:
554+
gate:
555+
steps:
556+
- run: |
557+
python3 << 'PYEOF'
558+
if re.search(r'^# TypeScript Exemptions', line):
559+
pass
560+
PYEOF
561+
"""
562+
563+
assert [] = WorkflowAudit.check_unanchored_heading_regex(%{"gov.yml" => wf})
564+
end
565+
566+
test "silent for a non-heading regex (single capitalised word)" do
567+
wf = """
568+
jobs:
569+
gate:
570+
steps:
571+
- run: |
572+
python3 << 'PYEOF'
573+
m = re.search(r'Error: (.+)', line)
574+
PYEOF
575+
"""
576+
577+
assert [] = WorkflowAudit.check_unanchored_heading_regex(%{"gov.yml" => wf})
578+
end
579+
580+
test "silent when the workflow has no inline python" do
581+
wf = "jobs:\n build:\n steps:\n - run: echo hi\n"
582+
assert [] = WorkflowAudit.check_unanchored_heading_regex(%{"ci.yml" => wf})
583+
end
584+
end
532585
end

0 commit comments

Comments
 (0)