Skip to content

Commit 1ec812c

Browse files
feat(rules): extend 5 existing rules to auto-classify recurring estate failure modes (#414)
Extends — not adds — 5 rules to cover failure patterns surfaced in the 2026-06-02 inbox audit. Each commit is a single rule extension. ## Commits 1. `feat(cicd_rules): extend license carve-outs for paint-type (PMPL) and echidna docs (MPL)` — adds `@pmpl_exception_repos`, `@license_exception_paths`, and a 3-arity `validate_license/3` with a minimal glob matcher. 2. `chore(sha-pins): bump a2ml-validate-action to 6bff6ec` — full 40-char SHA `6bff6ec134fc977e86d25166a5c522ddea5c1e78` (s-expression form support, panic-attack#94). 3. `feat(workflow_audit): Chapel >=2.8.0 ABI / runs-on detector (WF024)` — flags `CHAPEL_DEB_URL` / `chapel-*.deb` workflows whose `runs-on:` is `ubuntu-latest` / `ubuntu-24.04` (libclang-cpp.so.14 vs .so.18). Sources: proven#141, panic-attack#99. 4. `feat(proof_obligation): add baseline-delta admit-count regression detector` — exposes `count_admit_markers/1` and `check_admit_regression/2`. Not wired into a top-level entry yet (per ticket). 5. `feat(scorecard): extend check_fuzzing to classify PR (address) sanitizer-job failures` — 3-arity input shape `%{files: [...], workflows: %{name => log}}` classifies sibling-crate-missing / toolchain-skew (rustc-1.91) / build-script-panic. ## Sanity - `mix compile --warnings-as-errors` — blocked by unrelated Elixir 1.14 vs Phoenix 1.15 env mismatch in local sandbox; CI will run the canonical check. - All 5 edited files parse-check clean via `Code.string_to_quoted!/2`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent a885002 commit 1ec812c

5 files changed

Lines changed: 371 additions & 7 deletions

File tree

lib/rules/cicd_rules.ex

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,12 +1331,46 @@ defmodule Hypatia.Rules.CicdRules do
13311331
# Repos that legitimately use AGPL-3.0-or-later (co-developed with family, etc.)
13321332
@agpl_exception_repos ["game-server-admin", "idaptik", "airborne-submarine-squadron"]
13331333

1334+
# Repos that legitimately use PMPL-1.0-or-later (Palimpsest MPL).
1335+
# paint-type (JoshuaJewell/son's repo) uses PMPL-1.0-or-later for source/doc
1336+
# SPDX headers with MPL-2.0 only as the LICENSE-file fallback (explicit dual
1337+
# pattern, paint-type#4 owner carve-out 2026-06-01). Estate-licence-debt
1338+
# audits flagging this as a mismatch are FALSE POSITIVES.
1339+
@pmpl_exception_repos ["paint-type"]
1340+
1341+
# Per-repo path globs to skip strict SPDX-vs-LICENSE checking on.
1342+
# echidna intentionally keeps doc files at MPL-2.0 even though the LICENSE
1343+
# file is AGPL (owner reverted PR #149 docs reconciliation 2026-05-30 —
1344+
# this is owner-managed and must NOT be auto-reconciled by sweep rules).
1345+
@license_exception_paths %{
1346+
"echidna" => [
1347+
"docs/**",
1348+
"EXPLAINME.adoc",
1349+
"CLAUDE.md",
1350+
"CONTRIBUTING.adoc",
1351+
"FAQ.md"
1352+
]
1353+
}
1354+
13341355
def required_spdx, do: @required_spdx
13351356
def agpl_exception_repos, do: @agpl_exception_repos
1357+
def pmpl_exception_repos, do: @pmpl_exception_repos
1358+
def license_exception_paths, do: @license_exception_paths
1359+
1360+
@doc """
1361+
Return the list of path globs that are exempt from strict SPDX-vs-LICENSE
1362+
checking for the given repo. Returns `[]` if the repo has no carve-out.
1363+
"""
1364+
def license_exception_paths_for(repo_name) when is_binary(repo_name) do
1365+
Map.get(@license_exception_paths, repo_name, [])
1366+
end
1367+
1368+
def license_exception_paths_for(_), do: []
13361369

13371370
@doc """
13381371
Validate a license SPDX identifier, optionally scoped to a repo name.
13391372
AGPL-3.0-or-later is permitted for repos in @agpl_exception_repos.
1373+
PMPL-1.0 / PMPL-1.0-or-later is permitted for repos in @pmpl_exception_repos.
13401374
"""
13411375
def validate_license(spdx_id, repo_name \\ nil)
13421376

@@ -1354,6 +1388,9 @@ defmodule Hypatia.Rules.CicdRules do
13541388
spdx_id in ["AGPL-3.0", "AGPL-3.0-or-later"] and repo_name in @agpl_exception_repos ->
13551389
:ok_agpl_exception
13561390

1391+
spdx_id in ["PMPL-1.0", "PMPL-1.0-or-later"] and repo_name in @pmpl_exception_repos ->
1392+
:ok_pmpl_exception
1393+
13571394
spdx_id in @wrong_licenses ->
13581395
{:error, :wrong_license, spdx_id}
13591396

@@ -1362,6 +1399,35 @@ defmodule Hypatia.Rules.CicdRules do
13621399
end
13631400
end
13641401

1402+
@doc """
1403+
Validate a license SPDX identifier for a specific file path within a repo.
1404+
If the file path matches a glob in @license_exception_paths for the repo,
1405+
returns `:ok_path_exception` regardless of the SPDX identifier — used by
1406+
doc-vs-source carve-outs (e.g. echidna's MPL-2.0 docs under AGPL LICENSE).
1407+
"""
1408+
def validate_license(spdx_id, repo_name, file_path)
1409+
when is_binary(repo_name) and is_binary(file_path) do
1410+
globs = license_exception_paths_for(repo_name)
1411+
1412+
if Enum.any?(globs, &path_matches_glob?(file_path, &1)) do
1413+
:ok_path_exception
1414+
else
1415+
validate_license(spdx_id, repo_name)
1416+
end
1417+
end
1418+
1419+
# Minimal glob match: `**` (any depth), `*` (no slash), literal otherwise.
1420+
defp path_matches_glob?(path, glob) do
1421+
regex_src =
1422+
glob
1423+
|> String.replace(".", "\\.")
1424+
|> String.replace("**", "::DOUBLESTAR::")
1425+
|> String.replace("*", "[^/]*")
1426+
|> String.replace("::DOUBLESTAR::", ".*")
1427+
1428+
Regex.match?(~r/\A#{regex_src}\z/, path)
1429+
end
1430+
13651431
# Detect SPDX identifiers with a duplicated `-or-later` (or `+`) suffix —
13661432
# the rhodibot regex-regression class observed 2026-05-27 in
13671433
# the-nash-equilibrium#41. The deployed bot's auto-fix applies

lib/rules/proof_obligation.ex

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,4 +323,83 @@ defmodule Hypatia.Rules.ProofObligation do
323323

324324
# Best tactic hint for the eliminate tier (generic auto-tactic).
325325
defp best_tactic_hint, do: "simp; omega; decide"
326+
327+
# ─── Baseline-delta admit-count regression detector ──────────────────────
328+
#
329+
# Counts the literal proof-debt markers in a source blob, with light
330+
# comment-guarding so a phrase like `(* sorry: TODO *)` doesn't inflate
331+
# the count. Used by `check_admit_regression/2` to flag PRs whose head
332+
# tree carries more admits than the base — a cheap soundness signal that
333+
# complements per-module `Print Assumptions` whitelists.
334+
#
335+
# Markers counted (literal, case-sensitive where it matters):
336+
# Coq → `Admitted.` `Axiom ` (followed by an identifier)
337+
# Agda/Idris → `postulate`
338+
# Lean / Coq → `sorry`
339+
# Idris2 → `believe_me`
340+
#
341+
# Comment guard: strip Coq `(* … *)`, Lean/Rust `--`/`//` line comments,
342+
# and Agda `--` line comments before counting. Block comments inside
343+
# block comments are left alone (single-level strip is sufficient for
344+
# the false-positive cases observed in practice).
345+
346+
@admit_markers [
347+
{~r/\bAdmitted\.\B|\bAdmitted\./, :admitted},
348+
{~r/\bAxiom\s+[A-Za-z_]/, :axiom},
349+
{~r/\bsorry\b/, :sorry},
350+
{~r/\bbelieve_me\b/, :believe_me},
351+
{~r/\bpostulate\b/, :postulate}
352+
]
353+
354+
@doc """
355+
Count literal proof-debt markers (`Admitted.`, `Axiom`, `sorry`,
356+
`believe_me`, `postulate`) in a source blob. Single-level comment guard
357+
strips `(* … *)`, `// …`, and `-- …` before counting so quoted markers
358+
inside comments don't inflate the count.
359+
"""
360+
@spec count_admit_markers(String.t()) :: non_neg_integer()
361+
def count_admit_markers(text) when is_binary(text) do
362+
stripped =
363+
text
364+
|> String.replace(~r/\(\*.*?\*\)/s, " ")
365+
|> String.replace(~r/(?m)\/\/[^\n]*$/, "")
366+
|> String.replace(~r/(?m)--[^\n]*$/, "")
367+
368+
Enum.reduce(@admit_markers, 0, fn {re, _kind}, acc ->
369+
acc + length(Regex.scan(re, stripped))
370+
end)
371+
end
372+
373+
def count_admit_markers(_), do: 0
374+
375+
@doc """
376+
Compare admit-marker counts between a base tree and a head tree.
377+
378+
Both arguments are maps of `%{filename => content}`. Returns:
379+
* `:ok` — head_count ≤ base_count
380+
* `{:warn, {base, head}}` — head_count > base_count (regression)
381+
382+
Intended for use as a soft gate on proof-bearing PRs: combined with a
383+
per-module `Print Assumptions` whitelist this catches the "snuck-in
384+
extra Admitted" failure mode the whitelist alone cannot.
385+
"""
386+
@spec check_admit_regression(map(), map()) ::
387+
:ok | {:warn, {non_neg_integer(), non_neg_integer()}}
388+
def check_admit_regression(base_files, head_files)
389+
when is_map(base_files) and is_map(head_files) do
390+
base_count = sum_admit_markers(base_files)
391+
head_count = sum_admit_markers(head_files)
392+
393+
if head_count > base_count do
394+
{:warn, {base_count, head_count}}
395+
else
396+
:ok
397+
end
398+
end
399+
400+
defp sum_admit_markers(files) do
401+
Enum.reduce(files, 0, fn {_name, content}, acc ->
402+
acc + count_admit_markers(content)
403+
end)
404+
end
326405
end

lib/rules/scorecard_compliance.ex

Lines changed: 142 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,20 +85,156 @@ defmodule Hypatia.Rules.ScorecardCompliance do
8585

8686
@doc """
8787
Check for Fuzzing integration.
88+
89+
Three input shapes accepted:
90+
91+
* `[file_path, ...]` — legacy: file-presence check only
92+
* `%{files: [...], workflows: %{}}` — enriched: also classify failing
93+
sanitizer jobs (`PR (address)` / `PR (undefined)` / `PR (memory)`)
94+
by their three most common failure modes:
95+
1. sibling-crate missing in Docker fuzz context
96+
2. toolchain skew — rustc-1.91-not-supported
97+
3. build-script panic
98+
99+
Returns the legacy list of findings, optionally enriched with one
100+
finding per classified sanitizer failure.
88101
"""
89-
def check_fuzzing(file_list) do
90-
has_fuzzing = Enum.any?(file_list, fn f ->
91-
String.contains?(f, "fuzz") or String.contains?(f, "clusterfuzz")
92-
end)
102+
def check_fuzzing(input)
103+
104+
def check_fuzzing(file_list) when is_list(file_list) do
105+
has_fuzzing =
106+
Enum.any?(file_list, fn f ->
107+
String.contains?(f, "fuzz") or String.contains?(f, "clusterfuzz")
108+
end)
93109

94110
if has_fuzzing do
95111
[]
96112
else
97-
[%{type: :scorecard_violation, metric: :fuzzing, file: "tests/fuzz/",
98-
severity: :medium, detail: "No fuzz testing detected. Consider adding ClusterFuzzLite or similar."}]
113+
[
114+
%{
115+
type: :scorecard_violation,
116+
metric: :fuzzing,
117+
file: "tests/fuzz/",
118+
severity: :medium,
119+
detail: "No fuzz testing detected. Consider adding ClusterFuzzLite or similar."
120+
}
121+
]
122+
end
123+
end
124+
125+
def check_fuzzing(%{files: file_list} = input) when is_list(file_list) do
126+
base = check_fuzzing(file_list)
127+
workflows = Map.get(input, :workflows, %{})
128+
base ++ classify_sanitizer_failures(workflows)
129+
end
130+
131+
def check_fuzzing(_), do: []
132+
133+
# Pattern set for "PR (address|undefined|memory)" job names emitted by
134+
# ClusterFuzzLite's PR-time sanitizer workflows. Each is a Regex that
135+
# matches a job name string.
136+
@sanitizer_job_patterns [
137+
{~r/^PR\s*\(\s*address\s*\)/i, :asan},
138+
{~r/^PR\s*\(\s*undefined\s*\)/i, :ubsan},
139+
{~r/^PR\s*\(\s*memory\s*\)/i, :msan}
140+
]
141+
142+
# Failure-mode classifiers ordered most-specific → most-generic.
143+
# `log_text` is the captured failure log (job step output) — any string;
144+
# we pattern-match for the three known recurring failure modes.
145+
defp classify_failure_mode(log_text) when is_binary(log_text) do
146+
cond do
147+
Regex.match?(
148+
~r/(no matching package|could not find .*Cargo\.toml|sibling crate|workspace member.+not found)/i,
149+
log_text
150+
) ->
151+
{:sibling_crate_missing,
152+
"Sibling crate referenced from fuzz/ is absent from the Docker " <>
153+
"build context. Add the missing crate to the fuzz Dockerfile " <>
154+
"COPY/workspace or vendor it before invoking cargo fuzz."}
155+
156+
Regex.match?(
157+
~r/(rustc 1\.9[1-9]|requires rustc.+1\.9[1-9]|not supported.+1\.9[1-9]|toolchain.+1\.9[1-9].+not (?:installed|supported))/i,
158+
log_text
159+
) ->
160+
{:toolchain_skew_rustc_191,
161+
"Toolchain skew: a dependency declares rustc-1.91+, but the fuzz " <>
162+
"Docker image ships an older toolchain. Bump the rust-toolchain " <>
163+
"file or pin the dependency to a 1.90-compatible version."}
164+
165+
Regex.match?(~r/(error: failed to run custom build command|build script .+ panicked|panicked at .*build\.rs)/i, log_text) ->
166+
{:build_script_panic,
167+
"Build-script panic: a transitive crate's `build.rs` crashed " <>
168+
"before any fuzz target could be linked. Inspect the panic " <>
169+
"message and pin the offending crate."}
170+
171+
true ->
172+
:unknown
99173
end
100174
end
101175

176+
defp classify_failure_mode(_), do: :unknown
177+
178+
defp classify_sanitizer_failures(workflows) when is_map(workflows) do
179+
workflows
180+
|> Enum.flat_map(fn {job_name, payload} ->
181+
case classify_one_job(job_name, payload) do
182+
nil -> []
183+
finding -> [finding]
184+
end
185+
end)
186+
end
187+
188+
defp classify_sanitizer_failures(_), do: []
189+
190+
defp classify_one_job(job_name, payload) when is_binary(job_name) do
191+
matched =
192+
Enum.find_value(@sanitizer_job_patterns, nil, fn {re, kind} ->
193+
if Regex.match?(re, job_name), do: kind, else: nil
194+
end)
195+
196+
log_text =
197+
cond do
198+
is_binary(payload) -> payload
199+
is_map(payload) -> Map.get(payload, :log, "") |> to_string()
200+
true -> ""
201+
end
202+
203+
cond do
204+
is_nil(matched) ->
205+
nil
206+
207+
true ->
208+
case classify_failure_mode(log_text) do
209+
:unknown ->
210+
%{
211+
type: :scorecard_violation,
212+
metric: :fuzzing,
213+
sanitizer: matched,
214+
job: job_name,
215+
severity: :low,
216+
classification: :unknown,
217+
detail:
218+
"Sanitizer job `#{job_name}` failed; failure mode did not " <>
219+
"match any known recurring pattern."
220+
}
221+
222+
{mode, advice} ->
223+
%{
224+
type: :scorecard_violation,
225+
metric: :fuzzing,
226+
sanitizer: matched,
227+
job: job_name,
228+
severity: :medium,
229+
classification: mode,
230+
detail: advice
231+
}
232+
end
233+
end
234+
end
235+
236+
defp classify_one_job(_, _), do: nil
237+
102238
@doc """
103239
Check for CII Best Practices badge (or entry point for the badge).
104240
"""

lib/rules/security_errors.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,8 @@ defmodule Hypatia.Rules.SecurityErrors do
300300
"actions/deploy-pages@v4" => "d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e",
301301
"ruby/setup-ruby@v1" => "09a7688d3b55cf0e976497ff046b70949eeaccfd",
302302
"editorconfig-checker/action-editorconfig-checker@main" => "4054fa83a075fdf090bd098bdb1c09aaf64a4169",
303-
"hyperpolymath/a2ml-validate-action@main" => "cb3c1e298169dc5ac2b42e257068b0fb5920cd5e",
303+
# Bumped 2026-06-01 → 6bff6ec (s-expression form support, panic-attack#94)
304+
"hyperpolymath/a2ml-validate-action@main" => "6bff6ec134fc977e86d25166a5c522ddea5c1e78",
304305
"hyperpolymath/k9-validate-action@main" => "236f0035cc159051c8dd5dc7cd8af1e8cf961462",
305306
"hyperpolymath/panic-attacker/.github/workflows/scan-and-report.yml@main" => "21fc3f00a088c954912936f4a68970621b82c2e6"
306307
}

0 commit comments

Comments
 (0)