Skip to content

Commit d955282

Browse files
ci(workflow): adopt hardened hypatia-scan from hyperpolymath/hypatia#237 (#14)
* ci(workflow): adopt hardened hypatia-scan from hyperpolymath/hypatia#237 Replaces the local copy of `.github/workflows/hypatia-scan.yml` with the canonical version from upstream main. The old copy had three issues that combined to break every Dependabot PR: 1. `working-directory: \${{ env.HOME }}/hypatia\``, where `env.HOME` is not a GHA context — it evaluated to empty, so `cd /hypatia` failed and the scanner was never built. 2. `hypatia-cli.sh scan .` without `--exit-zero` — scanner exit-1 on findings short-circuited the rest of the step under `set -e`. 3. No baseline gate, so any pre-existing critical/high failed the build. Upstream version: - captures scanner exit code + stderr (visible on crash) - falls back to `[]` on missing/invalid JSON - reads `.hypatia-baseline.json` and fails only on NET-NEW critical/high - scopes permissions narrowly (contents: read, pull-requests: write) - marks the PR-comment step `continue-on-error: true` so Dependabot PRs (read-only token) don't fail on the unavoidable 403 Baseline file follows in a second commit on this branch — first we need the new workflow to actually run and capture current findings. Unblocks PR #13 (CODEOWNERS) which is stuck on this exact scan. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ci(fixup): restore newlines in hypatia-scan.yml Previous commit on this branch wrote the YAML as a single line due to a PowerShell encoding/-NoNewline mistake on my end. This re-applies the canonical workflow content byte-for-byte, with line breaks intact, so GitHub Actions can parse it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ci(baseline): seed/refresh .hypatia-baseline.json from new workflow's first scan Captured from run 25856329959 on this branch. 24 critical+high entries accepted as pre-existing baseline. Net-new findings going forward will still fail the gate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 30f669a commit d955282

2 files changed

Lines changed: 254 additions & 20 deletions

File tree

.github/workflows/hypatia-scan.yml

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ on:
1111
- cron: '0 0 * * 0' # Weekly on Sunday
1212
workflow_dispatch:
1313

14-
permissions: read-all
14+
permissions:
15+
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.
20+
pull-requests: write
1521

1622
jobs:
1723
scan:
@@ -30,30 +36,62 @@ jobs:
3036
elixir-version: '1.19.4'
3137
otp-version: '28.3'
3238

33-
- name: Clone Hypatia
39+
- name: Clone Hypatia (or use checkout when scanning hypatia itself)
3440
run: |
35-
if [ ! -d "$HOME/hypatia" ]; then
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
3648
git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia"
3749
fi
3850
3951
- name: Build Hypatia scanner (if needed)
40-
working-directory: ${{ env.HOME }}/hypatia
4152
run: |
42-
if [ ! -f hypatia-v2 ]; then
43-
echo "Building hypatia-v2 scanner..."
44-
cd scanner
53+
cd "$HOME/hypatia"
54+
if [ ! -f hypatia ]; then
55+
echo "Building hypatia scanner..."
4556
mix deps.get
4657
mix escript.build
47-
mv hypatia ../hypatia-v2
4858
fi
4959
5060
- name: Run Hypatia scan
5161
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 }}
5267
run: |
5368
echo "Scanning repository: ${{ github.repository }}"
5469
55-
# Run scanner
56-
HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . > hypatia-findings.json
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
5795
5896
# Count findings
5997
FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0)
@@ -75,7 +113,7 @@ jobs:
75113
echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY
76114
77115
- name: Upload findings artifact
78-
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
116+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
79117
with:
80118
name: hypatia-findings
81119
path: hypatia-findings.json
@@ -85,6 +123,8 @@ jobs:
85123
if: steps.scan.outputs.findings_count > 0
86124
env:
87125
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
126+
FLEET_PUSH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }}
127+
FLEET_DISPATCH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }}
88128
GITHUB_REPOSITORY: ${{ github.repository }}
89129
GITHUB_SHA: ${{ github.sha }}
90130
run: |
@@ -94,21 +134,63 @@ jobs:
94134
FLEET_DIR="/tmp/gitbot-fleet-$$"
95135
git clone https://github.com/hyperpolymath/gitbot-fleet.git "$FLEET_DIR"
96136
97-
# Run submission script
98-
bash "$FLEET_DIR/scripts/submit-finding.sh" hypatia-findings.json
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"
99142
100143
# Cleanup
101144
rm -rf "$FLEET_DIR"
102145
103146
echo "✅ Finding submission complete"
104147
105-
- name: Check for critical issues
106-
if: steps.scan.outputs.critical > 0
148+
- name: Check for critical or high-severity issues
149+
if: steps.scan.outputs.critical > 0 || steps.scan.outputs.high > 0
107150
run: |
108-
echo "⚠️ Critical security issues found!"
109-
echo "Review hypatia-findings.json for details"
110-
# Don't fail the build yet - just warn
111-
# exit 1
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
112194
113195
- name: Generate scan report
114196
run: |
@@ -145,7 +227,13 @@ jobs:
145227
cat hypatia-report.md >> $GITHUB_STEP_SUMMARY
146228
147229
- 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.
148235
if: github.event_name == 'pull_request' && steps.scan.outputs.findings_count > 0
236+
continue-on-error: true
149237
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
150238
with:
151239
script: |
@@ -176,4 +264,4 @@ jobs:
176264
repo: context.repo.repo,
177265
issue_number: context.issue.number,
178266
body: comment
179-
});
267+
});

.hypatia-baseline.json

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
[
2+
{
3+
"severity": "critical",
4+
"rule_module": "structural_drift",
5+
"type": "SD001",
6+
"file": ".machine_readable/AGENTIC.scm"
7+
},
8+
{
9+
"severity": "critical",
10+
"rule_module": "structural_drift",
11+
"type": "SD001",
12+
"file": ".machine_readable/ECOSYSTEM.scm"
13+
},
14+
{
15+
"severity": "critical",
16+
"rule_module": "structural_drift",
17+
"type": "SD001",
18+
"file": ".machine_readable/META.scm"
19+
},
20+
{
21+
"severity": "critical",
22+
"rule_module": "structural_drift",
23+
"type": "SD001",
24+
"file": ".machine_readable/NEUROSYM.scm"
25+
},
26+
{
27+
"severity": "critical",
28+
"rule_module": "structural_drift",
29+
"type": "SD001",
30+
"file": ".machine_readable/PLAYBOOK.scm"
31+
},
32+
{
33+
"severity": "critical",
34+
"rule_module": "structural_drift",
35+
"type": "SD001",
36+
"file": ".machine_readable/STATE.scm"
37+
},
38+
{
39+
"severity": "critical",
40+
"rule_module": "workflow_audit",
41+
"type": "actions_expression_injection",
42+
"file": "hypatia-scan.yml"
43+
},
44+
{
45+
"severity": "critical",
46+
"rule_module": "workflow_audit",
47+
"type": "actions_expression_injection",
48+
"file": "mirror.yml"
49+
},
50+
{
51+
"severity": "critical",
52+
"rule_module": "workflow_audit",
53+
"type": "actions_expression_injection",
54+
"file": "quality.yml"
55+
},
56+
{
57+
"severity": "high",
58+
"rule_module": "cicd_rules",
59+
"type": "missing_requirement",
60+
"file": ".github/workflows/scorecard.yml"
61+
},
62+
{
63+
"severity": "high",
64+
"rule_module": "cicd_rules",
65+
"type": "missing_requirement",
66+
"file": "permissions: read-all"
67+
},
68+
{
69+
"severity": "high",
70+
"rule_module": "git_state",
71+
"type": "GS005",
72+
"file": "."
73+
},
74+
{
75+
"severity": "high",
76+
"rule_module": "honest_completion",
77+
"type": "no_tests",
78+
"file": "/home/runner/work/coq-jr/coq-jr"
79+
},
80+
{
81+
"severity": "high",
82+
"rule_module": "migration_rules",
83+
"type": "deprecated_api",
84+
"file": "src/Components.res"
85+
},
86+
{
87+
"severity": "high",
88+
"rule_module": "migration_rules",
89+
"type": "deprecated_api",
90+
"file": "src/Page.res"
91+
},
92+
{
93+
"severity": "high",
94+
"rule_module": "migration_rules",
95+
"type": "deprecated_api",
96+
"file": "src/Server.res"
97+
},
98+
{
99+
"severity": "high",
100+
"rule_module": "migration_rules",
101+
"type": "deprecated_api",
102+
"file": "src/Server.res"
103+
},
104+
{
105+
"severity": "high",
106+
"rule_module": "root_hygiene",
107+
"type": "banned",
108+
"file": "AI.a2ml"
109+
},
110+
{
111+
"severity": "high",
112+
"rule_module": "root_hygiene",
113+
"type": "banned",
114+
"file": "AI.djot"
115+
},
116+
{
117+
"severity": "high",
118+
"rule_module": "root_hygiene",
119+
"type": "banned",
120+
"file": "package-lock.json"
121+
},
122+
{
123+
"severity": "high",
124+
"rule_module": "structural_drift",
125+
"type": "SD002",
126+
"file": "contractiles/trust/Trustfile.hs"
127+
},
128+
{
129+
"severity": "high",
130+
"rule_module": "structural_drift",
131+
"type": "SD003",
132+
"file": "AI.djot"
133+
},
134+
{
135+
"severity": "high",
136+
"rule_module": "workflow_audit",
137+
"type": "download_then_run",
138+
"file": "mirror.yml"
139+
},
140+
{
141+
"severity": "high",
142+
"rule_module": "workflow_audit",
143+
"type": "unsafe_curl_payload",
144+
"file": "hypatia-scan.yml"
145+
}
146+
]

0 commit comments

Comments
 (0)