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