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.
1920permissions :
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
2342jobs :
@@ -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