-
Notifications
You must be signed in to change notification settings - Fork 0
414 lines (368 loc) · 18 KB
/
hypatia-scan.yml
File metadata and controls
414 lines (368 loc) · 18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
# SPDX-License-Identifier: MPL-2.0
# Hypatia Neurosymbolic CI/CD Security Scan
name: Hypatia Security Scan
on:
push:
branches: [ main, master, develop ]
pull_request:
branches: [ main, master ]
schedule:
- cron: '0 0 * * 0' # Weekly on Sunday
workflow_dispatch:
# Estate guardrail: cancel superseded runs so re-pushes don't pile up
# queued runs across the estate. Safe here because this workflow only
# performs read-only checks/lint/test/scan with no publish or mutation.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
# security-events: write serves two purposes (write implies read):
# 1. read — lets the built-in GITHUB_TOKEN query this repo's own
# Dependabot alerts via the Hypatia DependabotAlerts rule
# (DA001-DA004). Without read, `scan_from_path` gets HTTP 403
# and the rule silently returns no findings.
# See 007-lang/audits/audit-dependabot-automation-gap-2026-04-17.md.
# 2. write — lets the "Upload SARIF to code scanning" step publish
# Hypatia findings to the Security → Code scanning page so they
# are triaged/deduplicated like CodeQL alerts instead of living
# only in a build artifact nobody is required to look at.
# See hyperpolymath/burble#35 (SARIF integration).
# This is a single-job workflow, so job-level scoping would not
# narrow the grant further; it stays workflow-level and documented.
security-events: write
# pull-requests: write lets the advisory "Comment on PR with findings"
# step post its summary. Without it the built-in GITHUB_TOKEN gets
# "Resource not accessible by integration" and (absent continue-on-error)
# hard-fails the scan — exactly what the gate-decoupling design forbids.
pull-requests: write
jobs:
scan:
name: Hypatia Neurosymbolic Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # Full history for better pattern analysis
- name: Setup Elixir for Hypatia scanner
uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.18.2
with:
elixir-version: '1.18'
otp-version: '27'
- name: Clone Hypatia
run: |
if [ ! -d "$HOME/hypatia" ]; then
git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia"
fi
- name: Build Hypatia scanner (if needed)
run: |
cd "$HOME/hypatia"
if [ ! -f hypatia ]; then
echo "Building hypatia scanner..."
mix deps.get
mix escript.build
fi
- name: Run Hypatia scan
id: scan
env:
# Pass the built-in Actions token through to Hypatia so the
# DependabotAlerts rule can query this repo's own alerts.
# For cross-repo scanning (fleet-coordinator scan-supervised),
# a PAT with `security_events` scope is required instead.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Scanning repository: ${{ github.repository }}"
# Run scanner (exits non-zero when findings exist — suppress to continue)
HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json || true
# Count findings
FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0)
echo "findings_count=$FINDING_COUNT" >> $GITHUB_OUTPUT
# Extract severity counts
CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json)
HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json)
MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json)
echo "critical=$CRITICAL" >> $GITHUB_OUTPUT
echo "high=$HIGH" >> $GITHUB_OUTPUT
echo "medium=$MEDIUM" >> $GITHUB_OUTPUT
echo "## Hypatia Scan Results" >> $GITHUB_STEP_SUMMARY
echo "- Total findings: $FINDING_COUNT" >> $GITHUB_STEP_SUMMARY
echo "- Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY
echo "- High: $HIGH" >> $GITHUB_STEP_SUMMARY
echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY
- name: Upload findings artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: hypatia-findings
path: hypatia-findings.json
retention-days: 90
- name: Convert Hypatia findings to SARIF
# Always runs (no findings_count guard): an EMPTY SARIF run is
# valid and intentional — uploading it clears stale Hypatia
# alerts from the code-scanning page when a repo goes clean.
# The converter is dependency-free Node (Node ships on
# ubuntu-latest; no npm install — estate npm ban respected) and
# is hardened against the heterogeneous Hypatia JSON schema:
# most findings are {rule_module,severity,type,file,reason,
# action}; only some carry an integer `line`; `file` may be
# empty or absolute. See lib/hypatia/cli.ex (collect_findings).
run: |
cat > "$RUNNER_TEMP/hypatia-sarif.cjs" <<'CJS'
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ws = process.env.GITHUB_WORKSPACE || process.cwd();
let findings = [];
try {
const parsed = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8'));
if (Array.isArray(parsed)) findings = parsed;
} catch (_) {
// Scanner unavailable / empty / malformed -> empty SARIF.
// Intentionally clears stale alerts rather than erroring.
findings = [];
}
// Mirrors Hypatia's own "github" annotation mapping
// (lib/hypatia/cli.ex output/2): critical|high -> error,
// medium -> warning, everything else -> note.
const levelFor = (sev) => {
switch (String(sev || '').toLowerCase()) {
case 'critical':
case 'high': return 'error';
case 'medium': return 'warning';
default: return 'note';
}
};
// SARIF artifactLocation.uri must be a repo-relative POSIX
// path. Hypatia may emit absolute paths (scanned under
// $GITHUB_WORKSPACE) or "" / "." for repo-level findings.
const relUri = (file) => {
if (!file) return '.';
let f = String(file);
if (path.isAbsolute(f)) {
const rel = path.relative(ws, f);
f = (rel && !rel.startsWith('..')) ? rel : path.basename(f);
}
f = f.replace(/\\/g, '/').replace(/^\.\//, '');
return f || '.';
};
const rules = new Map();
const results = findings.map((f) => {
const mod = String(f.rule_module || 'hypatia');
const type = String(f.type || 'finding');
const ruleId = `hypatia/${mod}/${type}`;
const level = levelFor(f.severity);
if (!rules.has(ruleId)) {
rules.set(ruleId, {
id: ruleId,
name: `${mod}.${type}`,
shortDescription: { text: `Hypatia ${mod}: ${type}` },
defaultConfiguration: { level }
});
}
const uri = relUri(f.file);
const msg = String(f.reason || f.type || 'Hypatia finding');
const startLine =
Number.isInteger(f.line) && f.line > 0 ? f.line : 1;
// Stable cross-run fingerprint for dedupe (no line, so a
// moved finding in the same file/rule stays one alert).
const fp = crypto
.createHash('sha256')
.update([ruleId, uri, type, msg].join('|'))
.digest('hex');
return {
ruleId,
level,
message: { text: msg },
locations: [
{
physicalLocation: {
artifactLocation: { uri },
region: { startLine }
}
}
],
partialFingerprints: { 'hypatiaFindingHash/v1': fp }
};
});
const sarif = {
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
version: '2.1.0',
runs: [
{
tool: {
driver: {
name: 'Hypatia',
informationUri: 'https://github.com/hyperpolymath/hypatia',
rules: Array.from(rules.values())
}
},
results
}
]
};
fs.writeFileSync('hypatia.sarif', JSON.stringify(sarif, null, 2));
console.log(`hypatia.sarif written: ${results.length} result(s).`);
CJS
node "$RUNNER_TEMP/hypatia-sarif.cjs"
- name: Upload SARIF to GitHub code scanning
# Fork PRs get a read-only GITHUB_TOKEN, so security-events:write
# is unavailable and upload-sarif cannot publish — skip there
# rather than hard-fail (the push/schedule run on the default
# branch is the authoritative upload). Same-repo PRs and pushes
# do upload. This step is deliberately NOT continue-on-error:
# if the security-surface integration breaks we want a loud red,
# not a silently-ungated scanner (the exact failure mode #35
# exists to end). The empty-SARIF "clear stale alerts" path is
# handled in the converter above and does not error here.
if: >-
always() &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork != true)
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.28.1
with:
sarif_file: hypatia.sarif
# Distinct category so Hypatia results coexist with CodeQL's
# (codeql.yml) instead of overwriting them on the same surface.
category: hypatia
- name: Submit findings to gitbot-fleet (Phase 2)
if: steps.scan.outputs.findings_count > 0
# Phase 2 is the collaborative LEARNING side-channel ("bots share
# findings via gitbot-fleet"), not the security gate. The gate is
# the baseline-aware "Check for critical or high-severity issues"
# step below. A fleet-side regression (e.g. the submit script being
# moved/removed) must NEVER hard-fail every consuming repo's scan.
# Same reasoning as the "Comment on PR with findings" step.
# See hyperpolymath/hypatia#213 (gate decoupling) and the exit-127
# estate-wide breakage when gitbot-fleet/scripts/submit-finding.sh
# no longer existed on the default branch.
continue-on-error: true
env:
# All GitHub context values surface as env vars so the run
# block never interpolates `${{ … }}` inline (closes the
# workflow_audit/unsafe_curl_payload + actions_expression_injection
# findings).
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FLEET_PUSH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }}
FLEET_DISPATCH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
FINDINGS_COUNT: ${{ steps.scan.outputs.findings_count }}
run: |
echo "📤 Submitting $FINDINGS_COUNT findings to gitbot-fleet..."
# Clone gitbot-fleet to temp directory. A clone failure (network,
# repo gone) is non-fatal: learning submission is best-effort.
FLEET_DIR="/tmp/gitbot-fleet-$$"
if ! git clone --depth 1 https://github.com/hyperpolymath/gitbot-fleet.git "$FLEET_DIR"; then
echo "::warning::Could not clone gitbot-fleet — skipping Phase 2 learning submission (non-fatal)."
exit 0
fi
# The submission script's location in gitbot-fleet has drifted
# before (it was absent from the default branch, which exit-127'd
# every consuming repo's scan). Probe known locations rather than
# hard-coding one path, and skip gracefully if none is present.
SUBMIT_SCRIPT=""
for cand in \
"$FLEET_DIR/scripts/submit-finding.sh" \
"$FLEET_DIR/scripts/submit_finding.sh" \
"$FLEET_DIR/bin/submit-finding.sh" \
"$FLEET_DIR/submit-finding.sh"; do
if [ -f "$cand" ]; then
SUBMIT_SCRIPT="$cand"
break
fi
done
if [ -z "$SUBMIT_SCRIPT" ]; then
echo "::warning::gitbot-fleet submit-finding script not found at any known path — skipping Phase 2 learning submission (non-fatal). Findings are still uploaded as an artifact and gated below."
rm -rf "$FLEET_DIR"
exit 0
fi
# Run submission script. Pass the findings path as ABSOLUTE —
# the script cd's into its own working dir before reading the
# file, so a relative path would resolve to the wrong place.
# A submission-script failure is logged but non-fatal.
if bash "$SUBMIT_SCRIPT" "$GITHUB_WORKSPACE/hypatia-findings.json"; then
echo "✅ Finding submission complete"
else
echo "::warning::gitbot-fleet submission script exited non-zero — Phase 2 learning submission skipped (non-fatal)."
fi
# Cleanup
rm -rf "$FLEET_DIR"
- name: Check for critical issues
if: steps.scan.outputs.critical > 0
# GATING POLICY (explicit, by design — not an oversight):
# Hypatia is ADVISORY here. Critical findings are surfaced
# (step annotation + SARIF alert on the code-scanning page +
# PR comment) but do NOT fail this check. Enforcement is
# delegated to the code-scanning surface: tighten by adding a
# branch-protection "required" status on the `hypatia` SARIF
# category, not by reintroducing an `exit 1` here. This keeps
# the gate decision in one auditable place (hypatia#213 gate
# decoupling) and lets a repo opt into fail-on-critical without
# editing this canonical workflow. To change the policy, change
# branch protection — deliberately no commented-out `exit 1`.
run: |
echo "::warning::Hypatia found critical security issue(s) — advisory."
echo "See the Security → Code scanning page (category: hypatia)"
echo "and the hypatia-findings.json artifact for details."
- name: Generate scan report
run: |
cat << EOF > hypatia-report.md
# Hypatia Security Scan Report
**Repository:** ${{ github.repository }}
**Scan Date:** $(date -u +"%Y-%m-%d %H:%M:%S UTC")
**Commit:** ${{ github.sha }}
## Summary
| Severity | Count |
|----------|-------|
| Critical | ${{ steps.scan.outputs.critical }} |
| High | ${{ steps.scan.outputs.high }} |
| Medium | ${{ steps.scan.outputs.medium }} |
| **Total**| ${{ steps.scan.outputs.findings_count }} |
## Next Steps
1. Triage findings on the **Security → Code scanning** page
(SARIF category \`hypatia\`) — dismiss/track them there like
CodeQL alerts.
2. The full finding set is also attached as the
\`hypatia-findings.json\` build artifact for offline review.
3. Findings are **advisory** today (surfaced, not gated); the
gating policy is documented in the workflow's "Check for
critical issues" step.
## Learning
These findings feed Hypatia's learning engine to improve future rules.
---
*Powered by [Hypatia](https://github.com/hyperpolymath/hypatia) - Neurosymbolic CI/CD Intelligence*
EOF
cat hypatia-report.md >> $GITHUB_STEP_SUMMARY
- name: Comment on PR with findings
if: github.event_name == 'pull_request' && steps.scan.outputs.findings_count > 0
# Advisory only — posting findings as a PR comment must never gate
# the scan (hypatia#213 gate decoupling). Belt-and-braces alongside
# the pull-requests: write permission above: a token/API hiccup or
# a fork PR (read-only token) skips the comment, not the check.
continue-on-error: true
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v7
with:
script: |
const fs = require('fs');
const findings = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8'));
const critical = findings.filter(f => f.severity === 'critical').length;
const high = findings.filter(f => f.severity === 'high').length;
let comment = `## 🔍 Hypatia Security Scan\n\n`;
comment += `**Findings:** ${findings.length} issues detected\n\n`;
comment += `| Severity | Count |\n|----------|-------|\n`;
comment += `| 🔴 Critical | ${critical} |\n`;
comment += `| 🟠 High | ${high} |\n`;
comment += `| 🟡 Medium | ${findings.length - critical - high} |\n\n`;
if (critical > 0) {
comment += `⚠️ **Action Required:** Critical security issues found!\n\n`;
}
comment += `<details><summary>View findings</summary>\n\n`;
comment += `\`\`\`json\n${JSON.stringify(findings.slice(0, 10), null, 2)}\n\`\`\`\n`;
comment += `</details>\n\n`;
comment += `*Powered by Hypatia Neurosymbolic CI/CD Intelligence*`;
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});