Skip to content

Commit 975f5c3

Browse files
fix(ci): sync hypatia-scan.yml to canonical (kill cd-scanner build drift) (#44)
The build step did `cd scanner` / built `hypatia-v2` against a path that no longer exists in the hypatia repo (mix.exs is at root), so the Hypatia Neurosymbolic Analysis lane exited 1 every run. The env.HOME and Phase-2 sweeps never normalised this older build-step drift. Replace with the canonical rsr-template-repo hypatia-scan.yml. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1ad5498 commit 975f5c3

1 file changed

Lines changed: 211 additions & 83 deletions

File tree

.github/workflows/hypatia-scan.yml

Lines changed: 211 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,33 @@ on:
1010
schedule:
1111
- cron: '0 0 * * 0' # Weekly on Sunday
1212
workflow_dispatch:
13+
# Estate guardrail: cancel superseded runs so re-pushes don't pile up
14+
# queued runs across the estate. Safe here because this workflow only
15+
# performs read-only checks/lint/test/scan with no publish or mutation.
16+
concurrency:
17+
group: ${{ github.workflow }}-${{ github.ref }}
18+
cancel-in-progress: true
1319

14-
# `read-all` is insufficient: the "Comment on PR with findings" step at
15-
# the bottom of this workflow posts to the `issues/{n}/comments` endpoint,
16-
# which requires write access to pull-requests. We keep contents read-only
17-
# (the workflow only reads the repo tree to scan it) and grant the minimum
18-
# write scope needed for the PR-comment step.
1920
permissions:
2021
contents: read
22+
# security-events: write serves two purposes (write implies read):
23+
# 1. read — lets the built-in GITHUB_TOKEN query this repo's own
24+
# Dependabot alerts via the Hypatia DependabotAlerts rule
25+
# (DA001-DA004). Without read, `scan_from_path` gets HTTP 403
26+
# and the rule silently returns no findings.
27+
# See 007-lang/audits/audit-dependabot-automation-gap-2026-04-17.md.
28+
# 2. write — lets the "Upload SARIF to code scanning" step publish
29+
# Hypatia findings to the Security → Code scanning page so they
30+
# are triaged/deduplicated like CodeQL alerts instead of living
31+
# only in a build artifact nobody is required to look at.
32+
# See hyperpolymath/burble#35 (SARIF integration).
33+
# This is a single-job workflow, so job-level scoping would not
34+
# narrow the grant further; it stays workflow-level and documented.
35+
security-events: write
36+
# pull-requests: write lets the advisory "Comment on PR with findings"
37+
# step post its summary. Without it the built-in GITHUB_TOKEN gets
38+
# "Resource not accessible by integration" and (absent continue-on-error)
39+
# hard-fails the scan — exactly what the gate-decoupling design forbids.
2140
pull-requests: write
2241

2342
jobs:
@@ -27,15 +46,15 @@ jobs:
2746

2847
steps:
2948
- name: Checkout repository
30-
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
49+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
3150
with:
3251
fetch-depth: 0 # Full history for better pattern analysis
3352

3453
- name: Setup Elixir for Hypatia scanner
35-
uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
54+
uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.18.2
3655
with:
37-
elixir-version: '1.19.4'
38-
otp-version: '28.3'
56+
elixir-version: '1.18'
57+
otp-version: '27'
3958

4059
- name: Clone Hypatia
4160
run: |
@@ -46,22 +65,25 @@ jobs:
4665
- name: Build Hypatia scanner (if needed)
4766
run: |
4867
cd "$HOME/hypatia"
49-
if [ ! -f hypatia ] && [ ! -f hypatia-v2 ]; then
50-
echo "Building hypatia-v2 scanner..."
68+
if [ ! -f hypatia ]; then
69+
echo "Building hypatia scanner..."
5170
mix deps.get
5271
mix escript.build
5372
fi
5473
5574
- name: Run Hypatia scan
5675
id: scan
5776
env:
58-
# Suppress the Dependabot "GITHUB_TOKEN not set" warning.
77+
# Pass the built-in Actions token through to Hypatia so the
78+
# DependabotAlerts rule can query this repo's own alerts.
79+
# For cross-repo scanning (fleet-coordinator scan-supervised),
80+
# a PAT with `security_events` scope is required instead.
5981
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
6082
run: |
6183
echo "Scanning repository: ${{ github.repository }}"
6284
63-
# Run scanner
64-
HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json
85+
# Run scanner (exits non-zero when findings exist — suppress to continue)
86+
HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json || true
6587
6688
# Count findings
6789
FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0)
@@ -89,6 +111,143 @@ jobs:
89111
path: hypatia-findings.json
90112
retention-days: 90
91113

114+
- name: Convert Hypatia findings to SARIF
115+
# Always runs (no findings_count guard): an EMPTY SARIF run is
116+
# valid and intentional — uploading it clears stale Hypatia
117+
# alerts from the code-scanning page when a repo goes clean.
118+
# The converter is dependency-free Node (Node ships on
119+
# ubuntu-latest; no npm install — estate npm ban respected) and
120+
# is hardened against the heterogeneous Hypatia JSON schema:
121+
# most findings are {rule_module,severity,type,file,reason,
122+
# action}; only some carry an integer `line`; `file` may be
123+
# empty or absolute. See lib/hypatia/cli.ex (collect_findings).
124+
run: |
125+
cat > "$RUNNER_TEMP/hypatia-sarif.cjs" <<'CJS'
126+
const fs = require('fs');
127+
const path = require('path');
128+
const crypto = require('crypto');
129+
130+
const ws = process.env.GITHUB_WORKSPACE || process.cwd();
131+
132+
let findings = [];
133+
try {
134+
const parsed = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8'));
135+
if (Array.isArray(parsed)) findings = parsed;
136+
} catch (_) {
137+
// Scanner unavailable / empty / malformed -> empty SARIF.
138+
// Intentionally clears stale alerts rather than erroring.
139+
findings = [];
140+
}
141+
142+
// Mirrors Hypatia's own "github" annotation mapping
143+
// (lib/hypatia/cli.ex output/2): critical|high -> error,
144+
// medium -> warning, everything else -> note.
145+
const levelFor = (sev) => {
146+
switch (String(sev || '').toLowerCase()) {
147+
case 'critical':
148+
case 'high': return 'error';
149+
case 'medium': return 'warning';
150+
default: return 'note';
151+
}
152+
};
153+
154+
// SARIF artifactLocation.uri must be a repo-relative POSIX
155+
// path. Hypatia may emit absolute paths (scanned under
156+
// $GITHUB_WORKSPACE) or "" / "." for repo-level findings.
157+
const relUri = (file) => {
158+
if (!file) return '.';
159+
let f = String(file);
160+
if (path.isAbsolute(f)) {
161+
const rel = path.relative(ws, f);
162+
f = (rel && !rel.startsWith('..')) ? rel : path.basename(f);
163+
}
164+
f = f.replace(/\\/g, '/').replace(/^\.\//, '');
165+
return f || '.';
166+
};
167+
168+
const rules = new Map();
169+
const results = findings.map((f) => {
170+
const mod = String(f.rule_module || 'hypatia');
171+
const type = String(f.type || 'finding');
172+
const ruleId = `hypatia/${mod}/${type}`;
173+
const level = levelFor(f.severity);
174+
if (!rules.has(ruleId)) {
175+
rules.set(ruleId, {
176+
id: ruleId,
177+
name: `${mod}.${type}`,
178+
shortDescription: { text: `Hypatia ${mod}: ${type}` },
179+
defaultConfiguration: { level }
180+
});
181+
}
182+
const uri = relUri(f.file);
183+
const msg = String(f.reason || f.type || 'Hypatia finding');
184+
const startLine =
185+
Number.isInteger(f.line) && f.line > 0 ? f.line : 1;
186+
// Stable cross-run fingerprint for dedupe (no line, so a
187+
// moved finding in the same file/rule stays one alert).
188+
const fp = crypto
189+
.createHash('sha256')
190+
.update([ruleId, uri, type, msg].join('|'))
191+
.digest('hex');
192+
return {
193+
ruleId,
194+
level,
195+
message: { text: msg },
196+
locations: [
197+
{
198+
physicalLocation: {
199+
artifactLocation: { uri },
200+
region: { startLine }
201+
}
202+
}
203+
],
204+
partialFingerprints: { 'hypatiaFindingHash/v1': fp }
205+
};
206+
});
207+
208+
const sarif = {
209+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
210+
version: '2.1.0',
211+
runs: [
212+
{
213+
tool: {
214+
driver: {
215+
name: 'Hypatia',
216+
informationUri: 'https://github.com/hyperpolymath/hypatia',
217+
rules: Array.from(rules.values())
218+
}
219+
},
220+
results
221+
}
222+
]
223+
};
224+
225+
fs.writeFileSync('hypatia.sarif', JSON.stringify(sarif, null, 2));
226+
console.log(`hypatia.sarif written: ${results.length} result(s).`);
227+
CJS
228+
node "$RUNNER_TEMP/hypatia-sarif.cjs"
229+
230+
- name: Upload SARIF to GitHub code scanning
231+
# Fork PRs get a read-only GITHUB_TOKEN, so security-events:write
232+
# is unavailable and upload-sarif cannot publish — skip there
233+
# rather than hard-fail (the push/schedule run on the default
234+
# branch is the authoritative upload). Same-repo PRs and pushes
235+
# do upload. This step is deliberately NOT continue-on-error:
236+
# if the security-surface integration breaks we want a loud red,
237+
# not a silently-ungated scanner (the exact failure mode #35
238+
# exists to end). The empty-SARIF "clear stale alerts" path is
239+
# handled in the converter above and does not error here.
240+
if: >-
241+
always() &&
242+
(github.event_name != 'pull_request' ||
243+
github.event.pull_request.head.repo.fork != true)
244+
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.28.1
245+
with:
246+
sarif_file: hypatia.sarif
247+
# Distinct category so Hypatia results coexist with CodeQL's
248+
# (codeql.yml) instead of overwriting them on the same surface.
249+
category: hypatia
250+
92251
- name: Submit findings to gitbot-fleet (Phase 2)
93252
if: steps.scan.outputs.findings_count > 0
94253
# Phase 2 is the collaborative LEARNING side-channel ("bots share
@@ -160,11 +319,21 @@ jobs:
160319
161320
- name: Check for critical issues
162321
if: steps.scan.outputs.critical > 0
322+
# GATING POLICY (explicit, by design — not an oversight):
323+
# Hypatia is ADVISORY here. Critical findings are surfaced
324+
# (step annotation + SARIF alert on the code-scanning page +
325+
# PR comment) but do NOT fail this check. Enforcement is
326+
# delegated to the code-scanning surface: tighten by adding a
327+
# branch-protection "required" status on the `hypatia` SARIF
328+
# category, not by reintroducing an `exit 1` here. This keeps
329+
# the gate decision in one auditable place (hypatia#213 gate
330+
# decoupling) and lets a repo opt into fail-on-critical without
331+
# editing this canonical workflow. To change the policy, change
332+
# branch protection — deliberately no commented-out `exit 1`.
163333
run: |
164-
echo "⚠️ Critical security issues found!"
165-
echo "Review hypatia-findings.json for details"
166-
# Don't fail the build yet - just warn
167-
# exit 1
334+
echo "::warning::Hypatia found critical security issue(s) — advisory."
335+
echo "See the Security → Code scanning page (category: hypatia)"
336+
echo "and the hypatia-findings.json artifact for details."
168337
169338
- name: Generate scan report
170339
run: |
@@ -186,9 +355,14 @@ jobs:
186355
187356
## Next Steps
188357
189-
1. Review findings in the artifact: hypatia-findings.json
190-
2. Auto-fixable issues will be addressed by robot-repo-automaton (Phase 3)
191-
3. Manual review required for complex issues
358+
1. Triage findings on the **Security → Code scanning** page
359+
(SARIF category \`hypatia\`) — dismiss/track them there like
360+
CodeQL alerts.
361+
2. The full finding set is also attached as the
362+
\`hypatia-findings.json\` build artifact for offline review.
363+
3. Findings are **advisory** today (surfaced, not gated); the
364+
gating policy is documented in the workflow's "Check for
365+
critical issues" step.
192366
193367
## Learning
194368
@@ -202,85 +376,39 @@ jobs:
202376
203377
- name: Comment on PR with findings
204378
if: github.event_name == 'pull_request' && steps.scan.outputs.findings_count > 0
205-
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
379+
# Advisory only — posting findings as a PR comment must never gate
380+
# the scan (hypatia#213 gate decoupling). Belt-and-braces alongside
381+
# the pull-requests: write permission above: a token/API hiccup or
382+
# a fork PR (read-only token) skips the comment, not the check.
383+
continue-on-error: true
384+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v7
206385
with:
207386
script: |
208-
// Diff-scoped + baseline-aware PR comment.
209-
//
210-
// The scanner reports the WHOLE repo on every run, so a raw
211-
// comment nagged every PR with ~44 pre-existing findings it did
212-
// not introduce. We instead surface only findings that are BOTH
213-
// (a) in a file this PR changed and (b) not in the committed
214-
// baseline. If there is nothing actionable for this PR we post
215-
// no comment at all (the full set stays in the uploaded
216-
// artifact + step summary). See
217-
// docs/wiki/internals/checker-allocation-investigation.md.
218387
const fs = require('fs');
219388
const findings = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8'));
220389
221-
const ws = (process.env.GITHUB_WORKSPACE || '').replace(/\/+$/, '');
222-
const relPath = (p) => {
223-
let r = String(p || '');
224-
if (ws && r.startsWith(ws + '/')) r = r.slice(ws.length + 1);
225-
return r;
226-
};
227-
const fp = (f) => `${f.rule_module}/${f.type}:${relPath(f.file)}`;
228-
229-
let baseline = new Set();
230-
try {
231-
const b = JSON.parse(fs.readFileSync('.hypatia-baseline.json', 'utf8'));
232-
baseline = new Set(b.fingerprints || []);
233-
} catch (e) { /* no baseline yet */ }
390+
const critical = findings.filter(f => f.severity === 'critical').length;
391+
const high = findings.filter(f => f.severity === 'high').length;
234392
235-
// Files changed by this PR (paginated).
236-
const files = await github.paginate(github.rest.pulls.listFiles, {
237-
owner: context.repo.owner,
238-
repo: context.repo.repo,
239-
pull_number: context.issue.number,
240-
per_page: 100,
241-
});
242-
const changed = new Set(files.map(f => f.filename));
243-
244-
const baselined = findings.filter(f => baseline.has(fp(f)));
245-
const relevant = findings.filter(
246-
f => changed.has(relPath(f.file)) && !baseline.has(fp(f))
247-
);
248-
249-
const summary =
250-
`Hypatia: ${findings.length} repo-wide finding(s); ` +
251-
`${relevant.length} in files this PR changed; ` +
252-
`${baselined.length} baselined; ` +
253-
`full list in the hypatia-findings artifact.`;
254-
core.notice(summary);
255-
256-
// Nothing this PR can act on -> stay silent.
257-
if (relevant.length === 0) return;
258-
259-
const critical = relevant.filter(f => f.severity === 'critical').length;
260-
const high = relevant.filter(f => f.severity === 'high').length;
261-
262-
let comment = `## 🔍 Hypatia Security Scan — findings in this PR's changed files\n\n`;
263-
comment += `**${relevant.length}** finding(s) in files this PR modifies `;
264-
comment += `(of ${findings.length} repo-wide; ${baselined.length} baselined, not shown).\n\n`;
393+
let comment = `## 🔍 Hypatia Security Scan\n\n`;
394+
comment += `**Findings:** ${findings.length} issues detected\n\n`;
265395
comment += `| Severity | Count |\n|----------|-------|\n`;
266396
comment += `| 🔴 Critical | ${critical} |\n`;
267397
comment += `| 🟠 High | ${high} |\n`;
268-
comment += `| 🟡 Medium | ${relevant.length - critical - high} |\n\n`;
398+
comment += `| 🟡 Medium | ${findings.length - critical - high} |\n\n`;
269399
270400
if (critical > 0) {
271-
comment += `⚠️ **Action Required:** critical finding(s) in code this PR touches.\n\n`;
401+
comment += `⚠️ **Action Required:** Critical security issues found!\n\n`;
272402
}
273403
274-
comment += `<details><summary>View findings (this PR's files only)</summary>\n\n`;
275-
comment += `\`\`\`json\n${JSON.stringify(relevant.slice(0, 10), null, 2)}\n\`\`\`\n`;
404+
comment += `<details><summary>View findings</summary>\n\n`;
405+
comment += `\`\`\`json\n${JSON.stringify(findings.slice(0, 10), null, 2)}\n\`\`\`\n`;
276406
comment += `</details>\n\n`;
277-
comment += `_Pre-existing repo-wide findings are triaged via the Hypatia backlog `;
278-
comment += `issue and suppressed here by \`.hypatia-baseline.json\` / \`.hypatia-ignore\`._\n\n`;
279407
comment += `*Powered by Hypatia Neurosymbolic CI/CD Intelligence*`;
280408
281-
await github.rest.issues.createComment({
409+
github.rest.issues.createComment({
282410
owner: context.repo.owner,
283411
repo: context.repo.repo,
284412
issue_number: context.issue.number,
285413
body: comment
286-
});
414+
});

0 commit comments

Comments
 (0)