|
1 | 1 | # SPDX-License-Identifier: MPL-2.0 |
2 | | -# Hypatia Neurosymbolic CI/CD Security Scan |
| 2 | +# Thin wrapper around hyperpolymath/standards hypatia-scan-reusable.yml. |
| 3 | +# See standards#191 for the reusable's purpose and design. |
| 4 | + |
3 | 5 | name: Hypatia Security Scan |
4 | 6 |
|
5 | 7 | on: |
6 | 8 | push: |
7 | | - branches: [ main, master, develop ] |
| 9 | + branches: [main, master, develop] |
8 | 10 | pull_request: |
9 | | - branches: [ main, master ] |
| 11 | + branches: [main, master] |
10 | 12 | schedule: |
11 | | - - cron: '0 0 * * 0' # Weekly on Sunday |
| 13 | + - cron: '0 0 * * 0' |
12 | 14 | workflow_dispatch: |
13 | 15 |
|
| 16 | +# Estate guardrail: cancel superseded runs so re-pushes don't pile up. |
| 17 | +concurrency: |
| 18 | + group: ${{ github.workflow }}-${{ github.ref }} |
| 19 | + cancel-in-progress: true |
| 20 | + |
14 | 21 | permissions: |
15 | 22 | contents: read |
16 | | - # `pull-requests: write` is needed for the "Comment on PR with findings" |
17 | | - # step to POST a results summary. Note: on Dependabot PRs the token is |
18 | | - # downgraded to read-only regardless, so that step is also marked |
19 | | - # continue-on-error below. |
| 23 | + security-events: write |
20 | 24 | pull-requests: write |
21 | 25 |
|
22 | 26 | jobs: |
23 | | - scan: |
24 | | - name: Hypatia Neurosymbolic Analysis |
25 | | - runs-on: ubuntu-latest |
26 | | - |
27 | | - steps: |
28 | | - - name: Checkout repository |
29 | | - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 |
30 | | - with: |
31 | | - fetch-depth: 0 # Full history for better pattern analysis |
32 | | - |
33 | | - - name: Setup Elixir for Hypatia scanner |
34 | | - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.18.2 |
35 | | - with: |
36 | | - elixir-version: '1.19.4' |
37 | | - otp-version: '28.3' |
38 | | - |
39 | | - - name: Clone Hypatia (or use checkout when scanning hypatia itself) |
40 | | - run: | |
41 | | - # When scanning hypatia from inside hypatia, point $HOME/hypatia |
42 | | - # at the PR/branch checkout instead of cloning main — otherwise |
43 | | - # CLI changes can never pass their own gate (the scanner binary |
44 | | - # would always come from main and ignore new flags). |
45 | | - if [ "${{ github.repository }}" = "hyperpolymath/hypatia" ]; then |
46 | | - ln -sfn "${GITHUB_WORKSPACE}" "$HOME/hypatia" |
47 | | - elif [ ! -d "$HOME/hypatia" ]; then |
48 | | - git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia" |
49 | | - fi |
50 | | -
|
51 | | - - name: Build Hypatia scanner (if needed) |
52 | | - run: | |
53 | | - cd "$HOME/hypatia" |
54 | | - if [ ! -f hypatia ]; then |
55 | | - echo "Building hypatia scanner..." |
56 | | - mix deps.get |
57 | | - mix escript.build |
58 | | - fi |
59 | | -
|
60 | | - - name: Run Hypatia scan |
61 | | - id: scan |
62 | | - env: |
63 | | - # Suppress the "Warning: Dependabot alerts unavailable: GITHUB_TOKEN |
64 | | - # not set" line so the run is silent-warning-free. The token is |
65 | | - # read-only by default and only used to query Dependabot alerts. |
66 | | - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
67 | | - run: | |
68 | | - echo "Scanning repository: ${{ github.repository }}" |
69 | | -
|
70 | | - # Run scanner with --exit-zero so a findings-found exit-1 does |
71 | | - # NOT short-circuit the rest of this step under `set -e`. The |
72 | | - # downstream "Check for critical or high-severity issues" step |
73 | | - # is the explicit gate. See hyperpolymath/hypatia#213. |
74 | | - # |
75 | | - # Guard against the scanner producing no output (a crash, an |
76 | | - # unknown flag, etc.): if hypatia-findings.json is empty or |
77 | | - # missing after the run, fall back to "[]" so the jq calls |
78 | | - # below don't 9 the whole gate. We surface stderr so the |
79 | | - # underlying scanner failure is still visible in the log. |
80 | | - set +e |
81 | | - HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero \ |
82 | | - > hypatia-findings.json 2> hypatia-scan.stderr |
83 | | - SCAN_EXIT=$? |
84 | | - set -e |
85 | | - echo "Scanner exit: $SCAN_EXIT" |
86 | | - if [ -s hypatia-scan.stderr ]; then |
87 | | - echo "--- scanner stderr ---" |
88 | | - cat hypatia-scan.stderr |
89 | | - echo "--- end stderr ---" |
90 | | - fi |
91 | | - if ! jq empty hypatia-findings.json 2>/dev/null; then |
92 | | - echo "Scanner did not produce valid JSON; defaulting to empty findings." |
93 | | - echo "[]" > hypatia-findings.json |
94 | | - fi |
95 | | -
|
96 | | - # Count findings |
97 | | - FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0) |
98 | | - echo "findings_count=$FINDING_COUNT" >> $GITHUB_OUTPUT |
99 | | -
|
100 | | - # Extract severity counts |
101 | | - CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json) |
102 | | - HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json) |
103 | | - MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json) |
104 | | -
|
105 | | - echo "critical=$CRITICAL" >> $GITHUB_OUTPUT |
106 | | - echo "high=$HIGH" >> $GITHUB_OUTPUT |
107 | | - echo "medium=$MEDIUM" >> $GITHUB_OUTPUT |
108 | | -
|
109 | | - echo "## Hypatia Scan Results" >> $GITHUB_STEP_SUMMARY |
110 | | - echo "- Total findings: $FINDING_COUNT" >> $GITHUB_STEP_SUMMARY |
111 | | - echo "- Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY |
112 | | - echo "- High: $HIGH" >> $GITHUB_STEP_SUMMARY |
113 | | - echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY |
114 | | -
|
115 | | - - name: Upload findings artifact |
116 | | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 |
117 | | - with: |
118 | | - name: hypatia-findings |
119 | | - path: hypatia-findings.json |
120 | | - retention-days: 90 |
121 | | - |
122 | | - - name: Submit findings to gitbot-fleet (Phase 2) |
123 | | - if: steps.scan.outputs.findings_count > 0 |
124 | | - env: |
125 | | - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
126 | | - FLEET_PUSH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} |
127 | | - FLEET_DISPATCH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} |
128 | | - GITHUB_REPOSITORY: ${{ github.repository }} |
129 | | - GITHUB_SHA: ${{ github.sha }} |
130 | | - run: | |
131 | | - echo "📤 Submitting ${{ steps.scan.outputs.findings_count }} findings to gitbot-fleet..." |
132 | | -
|
133 | | - # Clone gitbot-fleet to temp directory |
134 | | - FLEET_DIR="/tmp/gitbot-fleet-$$" |
135 | | - git clone https://github.com/hyperpolymath/gitbot-fleet.git "$FLEET_DIR" |
136 | | -
|
137 | | - # Run submission script. Pass the findings path as ABSOLUTE — |
138 | | - # submit-finding.sh cd's into its own working dir before reading |
139 | | - # the file, so a relative path would resolve to the wrong place |
140 | | - # and the script fails with "No such file or directory". |
141 | | - bash "$FLEET_DIR/scripts/submit-finding.sh" "$GITHUB_WORKSPACE/hypatia-findings.json" |
142 | | -
|
143 | | - # Cleanup |
144 | | - rm -rf "$FLEET_DIR" |
145 | | -
|
146 | | - echo "✅ Finding submission complete" |
147 | | -
|
148 | | - - name: Check for critical or high-severity issues |
149 | | - if: steps.scan.outputs.critical > 0 || steps.scan.outputs.high > 0 |
150 | | - run: | |
151 | | - echo "Total critical/high: ${{ steps.scan.outputs.critical }} critical, ${{ steps.scan.outputs.high }} high" |
152 | | -
|
153 | | - # Baseline-aware gate: pre-existing accepted findings live in |
154 | | - # .hypatia-baseline.json (committed). New critical/high findings |
155 | | - # not in the baseline still fail the build. Findings are matched |
156 | | - # on (severity, rule_module, type, file) tuple with absolute |
157 | | - # build paths normalised to repo-relative. |
158 | | - if [ -f .hypatia-baseline.json ]; then |
159 | | - # Normalise + project the FINDING IDENTITY tuple from the current |
160 | | - # scan. Identity is (severity, rule_module, type, file) — `action` |
161 | | - # is remediation guidance that can legitimately drift between |
162 | | - # scanner versions (e.g. "flag" -> "create_branch") and is NOT |
163 | | - # part of what makes two findings the same. |
164 | | - jq '[ .[] | select(.severity == "critical" or .severity == "high") |
165 | | - | {severity, rule_module, type, |
166 | | - file: (.file | sub("^/home/runner/work/[^/]+/[^/]+/"; "") |
167 | | - | sub("^/github/workspace/"; "")) } ]' \ |
168 | | - hypatia-findings.json > findings-current.json |
169 | | -
|
170 | | - # Subtract baseline. A current finding is "new" iff there's no |
171 | | - # baseline element with the same identity tuple. Baseline entries |
172 | | - # may include extra fields (e.g. `action`); strip them before the |
173 | | - # comparison so legacy baselines keep working. |
174 | | - jq --slurpfile base .hypatia-baseline.json \ |
175 | | - '($base[0] | map({severity, rule_module, type, file})) as $bk |
176 | | - | map(. as $f | select(($bk | any(. == $f)) | not))' \ |
177 | | - findings-current.json > findings-new.json |
178 | | - new_count=$(jq 'length' findings-new.json) |
179 | | -
|
180 | | - if [ "$new_count" -gt 0 ]; then |
181 | | - echo "::error::$new_count new critical/high finding(s) outside the baseline:" |
182 | | - jq -r '.[] | " [\(.severity)] \(.rule_module)/\(.type) — \(.file)"' findings-new.json |
183 | | - echo |
184 | | - echo "If these are intentional, regenerate .hypatia-baseline.json:" |
185 | | - echo " jq '[.[] | select(.severity == \"critical\" or .severity == \"high\") | {severity, rule_module, type, file}] | sort_by(.severity, .rule_module, .type, .file)' hypatia-findings.json > .hypatia-baseline.json" |
186 | | - exit 1 |
187 | | - fi |
188 | | - echo "All critical/high findings present in baseline — gate passes." |
189 | | - else |
190 | | - echo "No .hypatia-baseline.json — failing on any critical/high (legacy behaviour)." |
191 | | - echo "Review hypatia-findings.json for details" |
192 | | - exit 1 |
193 | | - fi |
194 | | -
|
195 | | - - name: Generate scan report |
196 | | - run: | |
197 | | - cat << EOF > hypatia-report.md |
198 | | - # Hypatia Security Scan Report |
199 | | -
|
200 | | - **Repository:** ${{ github.repository }} |
201 | | - **Scan Date:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") |
202 | | - **Commit:** ${{ github.sha }} |
203 | | -
|
204 | | - ## Summary |
205 | | -
|
206 | | - | Severity | Count | |
207 | | - |----------|-------| |
208 | | - | Critical | ${{ steps.scan.outputs.critical }} | |
209 | | - | High | ${{ steps.scan.outputs.high }} | |
210 | | - | Medium | ${{ steps.scan.outputs.medium }} | |
211 | | - | **Total**| ${{ steps.scan.outputs.findings_count }} | |
212 | | -
|
213 | | - ## Next Steps |
214 | | -
|
215 | | - 1. Review findings in the artifact: hypatia-findings.json |
216 | | - 2. Auto-fixable issues will be addressed by robot-repo-automaton (Phase 3) |
217 | | - 3. Manual review required for complex issues |
218 | | -
|
219 | | - ## Learning |
220 | | -
|
221 | | - These findings feed Hypatia's learning engine to improve future rules. |
222 | | -
|
223 | | - --- |
224 | | - *Powered by [Hypatia](https://github.com/hyperpolymath/hypatia) - Neurosymbolic CI/CD Intelligence* |
225 | | - EOF |
226 | | -
|
227 | | - cat hypatia-report.md >> $GITHUB_STEP_SUMMARY |
228 | | -
|
229 | | - - name: Comment on PR with findings |
230 | | - # Dependabot PRs always run with a read-only token regardless of the |
231 | | - # workflow's declared permissions, so the createComment call below |
232 | | - # would 403 on every dep-bump PR. The PR comment is informational |
233 | | - # (the check result is already visible in the PR UI); we don't want |
234 | | - # its absence to block merge. |
235 | | - if: github.event_name == 'pull_request' && steps.scan.outputs.findings_count > 0 |
236 | | - continue-on-error: true |
237 | | - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7 |
238 | | - with: |
239 | | - script: | |
240 | | - const fs = require('fs'); |
241 | | - const findings = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8')); |
242 | | -
|
243 | | - const critical = findings.filter(f => f.severity === 'critical').length; |
244 | | - const high = findings.filter(f => f.severity === 'high').length; |
245 | | -
|
246 | | - let comment = `## 🔍 Hypatia Security Scan\n\n`; |
247 | | - comment += `**Findings:** ${findings.length} issues detected\n\n`; |
248 | | - comment += `| Severity | Count |\n|----------|-------|\n`; |
249 | | - comment += `| 🔴 Critical | ${critical} |\n`; |
250 | | - comment += `| 🟠 High | ${high} |\n`; |
251 | | - comment += `| 🟡 Medium | ${findings.length - critical - high} |\n\n`; |
252 | | -
|
253 | | - if (critical > 0) { |
254 | | - comment += `⚠️ **Action Required:** Critical security issues found!\n\n`; |
255 | | - } |
256 | | -
|
257 | | - comment += `<details><summary>View findings</summary>\n\n`; |
258 | | - comment += `\`\`\`json\n${JSON.stringify(findings.slice(0, 10), null, 2)}\n\`\`\`\n`; |
259 | | - comment += `</details>\n\n`; |
260 | | - comment += `*Powered by Hypatia Neurosymbolic CI/CD Intelligence*`; |
261 | | -
|
262 | | - github.rest.issues.createComment({ |
263 | | - owner: context.repo.owner, |
264 | | - repo: context.repo.repo, |
265 | | - issue_number: context.issue.number, |
266 | | - body: comment |
267 | | - }); |
| 27 | + hypatia: |
| 28 | + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@97df762107501909f50bb770e9bc200b6c415600 |
| 29 | + secrets: inherit |
0 commit comments