Skip to content

Commit 35f9f3c

Browse files
fix(code_scanning_alerts): break CSA001 self-referential echo loop (#328)
## Why \`CSA001-CSA004\` (\`lib/rules/code_scanning_alerts.ex\`) are lenses over GitHub's code-scanning alerts — they query \`/repos/<owner>/<repo>/code-scanning/alerts\` and echo each as a Hypatia finding so a single run surfaces everything GitHub's Security tab shows. The findings then get uploaded back to GitHub as SARIF, creating new alerts under \`tool: Hypatia\`, \`rule_id: hypatia/code_scanning_alerts/CSA00X\`. Without a filter, the **next** Hypatia scan sees those alerts via CSA001 and echoes them again, generating fresh CSA findings about previous CSA findings. ## The observed failure mode (boj-server) boj-server accumulated 30+ self-referential alerts on \`cartridges/*/ffi/cartridge_shim.zig\` (alert numbers 357-386, all created at exactly \`2026-05-24T20:07:32Z\` — single scan emitted 30 echoes in one go). The PR check Hypatia failed and blocked PR #149 (\"Claude/repo tidy rsr taxonomy\") even though the PR didn't touch the code-scanning surface at all. The message field of each looped alert showed the self-echo chain explicitly — the \`description\` slot was filled with the previous round's rendered \`build_alert_reason/5\` output, so each round nested one level deeper: > Code scanning (Hypatia): hypatia/code_scanning_alerts/CSA001 -- Hypatia code_scanning_alerts: CSA001 -- 1 day(s) old ## The fix Adds \`self_referential_alert?/1\` — a predicate that returns true when: - \`tool.name == \"Hypatia\"\`, AND - \`rule.id\` starts with \`hypatia/code_scanning_alerts/\` \`fetch_alerts/2\` applies it as an \`Enum.reject\` filter immediately after JSON decode, so all four CSA rules see a CSA-free alert list and cannot generate fresh CSA findings about previous CSA findings. ### Why narrow The predicate **only** suppresses this module's own emissions. Real findings from other Hypatia rule modules (\`code_safety\`, \`supply_chain\`, \`workflow_hardening\`, etc.) continue to surface via CSA001 even when they happen to carry \`tool: Hypatia\`, because they do **not** match the \`rule.id\` prefix. CodeQL and third-party SARIF findings are unaffected. ## Tests Six regression cases in \`test/code_scanning_alerts_test.exs\`: - CSA001 self-echo (the primary failure mode) - CSA002 / CSA003 / CSA004 echoes (same loop mechanism) - CodeQL alert passes through (real findings still surface) - Third-party SARIF (Snyk) alert passes through - Hypatia non-CSA finding passes through (other rule modules unaffected) - Missing \`tool\` / \`rule\` keys handled gracefully Local \`mix test\` couldn't complete in this environment (Phoenix dep is incompatible with Elixir 1.14 here); CI will run the suite. ## Cleanup follow-up Once this lands, the 30+ existing self-referential alerts on boj-server can be dismissed in one batch: \`\`\`bash gh api repos/hyperpolymath/boj-server/code-scanning/alerts --paginate \\ --jq '.[] | select(.rule.id == \"hypatia/code_scanning_alerts/CSA001\" and .state == \"open\") | .number' \\ | xargs -I{} gh api -X PATCH \\ repos/hyperpolymath/boj-server/code-scanning/alerts/{} \\ -f state=dismissed -f dismissed_reason=\"false positive\" \\ -f dismissed_comment=\"Self-referential CSA001 echo loop; see hypatia#$THIS_PR\" \`\`\` ## Refs - boj-server #149 (the visible failure) - Earlier memory: \`project_hypatia_scorecard_loop_closer.md\` (related alert-lifecycle pattern; this is a sibling fix in the same class) ## Architectural note (separate consideration, not in this PR) The deeper question is whether Hypatia's SARIF-upload step should exclude **lens** rules (CSA001-004, plus Dependabot lens equivalents) from upload entirely — uploading \"this is what GitHub already shows\" back to GitHub Code Scanning is by definition redundant. That's a larger architectural change; this PR is the defensive belt-and-braces filter that stops the bleeding immediately. ## Test plan - [ ] CI green (especially the new \`describe \"self_referential_alert?/1\"\` block) - [ ] After merge, run the batch dismissal on boj-server and confirm PR #149's Hypatia check goes green - [ ] Confirm no regression in real CSA001 surfacing (CodeQL findings on a fresh repo should still appear)
1 parent 75c3019 commit 35f9f3c

2 files changed

Lines changed: 103 additions & 3 deletions

File tree

lib/rules/code_scanning_alerts.ex

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,31 @@ defmodule Hypatia.Rules.CodeScanningAlerts do
318318
end
319319
end
320320

321+
# ─── Self-referential filter ───────────────────────────────────────────
322+
323+
@doc """
324+
Returns true when the alert is one Hypatia previously uploaded for this
325+
module (CSA001-CSA004). These rules are lenses over GitHub's code-scanning
326+
alerts; their findings are themselves uploaded back to GitHub as SARIF and
327+
become new alerts under `tool: Hypatia`, rule id
328+
`hypatia/code_scanning_alerts/CSA00X`.
329+
330+
Without this filter, each scan generates new CSA alerts about the previous
331+
scan's CSA alerts — boj-server accumulated 30+ self-referential alerts on
332+
`cartridges/*/ffi/cartridge_shim.zig` this way (alert numbers 357-386).
333+
The message field showed the self-echo: the `description` slot was filled
334+
with the previous round's rendered `build_alert_reason/5` output, so each
335+
round nests one level deeper.
336+
337+
Public so the regression test in `test/code_scanning_alerts_test.exs` can
338+
exercise it without hitting the GitHub API.
339+
"""
340+
def self_referential_alert?(alert) do
341+
tool = get_in(alert, ["tool", "name"])
342+
rule_id = get_in(alert, ["rule", "id"]) || ""
343+
tool == "Hypatia" and String.starts_with?(rule_id, "hypatia/code_scanning_alerts/")
344+
end
345+
321346
# ─── GitHub API ────────────────────────────────────────────────────────
322347

323348
defp fetch_alerts(owner, repo) do
@@ -343,9 +368,14 @@ defmodule Hypatia.Rules.CodeScanningAlerts do
343368
], stderr_to_stdout: true) do
344369
{body, 0} ->
345370
case Jason.decode(body) do
346-
{:ok, alerts} when is_list(alerts) -> {:ok, alerts}
347-
{:ok, %{"message" => msg}} -> {:error, "GitHub API: #{msg}"}
348-
{:error, _} -> {:error, "Invalid JSON response from GitHub API"}
371+
{:ok, alerts} when is_list(alerts) ->
372+
{:ok, Enum.reject(alerts, &self_referential_alert?/1)}
373+
374+
{:ok, %{"message" => msg}} ->
375+
{:error, "GitHub API: #{msg}"}
376+
377+
{:error, _} ->
378+
{:error, "Invalid JSON response from GitHub API"}
349379
end
350380

351381
{error, _} ->

test/code_scanning_alerts_test.exs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,74 @@ defmodule Hypatia.Rules.CodeScanningAlertsTest do
6565
File.rm_rf!(tmp)
6666
end
6767
end
68+
69+
describe "self_referential_alert?/1" do
70+
# Regression for the boj-server #149 self-amplifying loop where each
71+
# Hypatia scan generated new CSA001 alerts about the previous scan's
72+
# CSA001 alerts (30+ alerts on cartridges/*/ffi/cartridge_shim.zig
73+
# before the fix).
74+
75+
test "rejects Hypatia's own CSA001 echo" do
76+
alert = %{
77+
"tool" => %{"name" => "Hypatia"},
78+
"rule" => %{"id" => "hypatia/code_scanning_alerts/CSA001"}
79+
}
80+
81+
assert CodeScanningAlerts.self_referential_alert?(alert)
82+
end
83+
84+
test "rejects CSA002 / CSA003 / CSA004 echoes too" do
85+
for rule_id <- ~w(
86+
hypatia/code_scanning_alerts/CSA002
87+
hypatia/code_scanning_alerts/CSA003
88+
hypatia/code_scanning_alerts/CSA004
89+
) do
90+
alert = %{
91+
"tool" => %{"name" => "Hypatia"},
92+
"rule" => %{"id" => rule_id}
93+
}
94+
95+
assert CodeScanningAlerts.self_referential_alert?(alert),
96+
"expected #{rule_id} to be flagged self-referential"
97+
end
98+
end
99+
100+
test "keeps CodeQL alerts" do
101+
alert = %{
102+
"tool" => %{"name" => "CodeQL"},
103+
"rule" => %{"id" => "rs/unsafe-cast"}
104+
}
105+
106+
refute CodeScanningAlerts.self_referential_alert?(alert)
107+
end
108+
109+
test "keeps third-party SARIF alerts" do
110+
alert = %{
111+
"tool" => %{"name" => "Snyk"},
112+
"rule" => %{"id" => "SNYK-RS-12345"}
113+
}
114+
115+
refute CodeScanningAlerts.self_referential_alert?(alert)
116+
end
117+
118+
test "keeps Hypatia alerts from other rule modules" do
119+
# Hypatia has many rule modules (code_safety, supply_chain,
120+
# workflow_hardening, etc.); only the code_scanning_alerts module is
121+
# a lens over GitHub's API and recursive. Other modules find real
122+
# things in the code and SHOULD continue to surface even when their
123+
# findings get re-uploaded as SARIF.
124+
alert = %{
125+
"tool" => %{"name" => "Hypatia"},
126+
"rule" => %{"id" => "hypatia/code_safety/CSA001"}
127+
}
128+
129+
refute CodeScanningAlerts.self_referential_alert?(alert)
130+
end
131+
132+
test "handles missing tool / rule keys gracefully" do
133+
refute CodeScanningAlerts.self_referential_alert?(%{})
134+
refute CodeScanningAlerts.self_referential_alert?(%{"tool" => %{"name" => "Hypatia"}})
135+
refute CodeScanningAlerts.self_referential_alert?(%{"rule" => %{"id" => "x"}})
136+
end
137+
end
68138
end

0 commit comments

Comments
 (0)