Skip to content

Commit f3bec4b

Browse files
ci(workflow): adopt hardened hypatia-scan from hyperpolymath/hypatia#237 (#37)
Workflow-only commit; baseline follows after the new workflow's first run captures findings. Unblocks #36. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 27b7d70 commit f3bec4b

2 files changed

Lines changed: 892 additions & 129 deletions

File tree

.github/workflows/hypatia-scan.yml

Lines changed: 122 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,29 @@ name: Hypatia Security Scan
44

55
on:
66
push:
7-
branches: ['**']
7+
branches: [ main, master, develop ]
88
pull_request:
9-
branches: ['**']
9+
branches: [ main, master ]
1010
schedule:
1111
- cron: '0 0 * * 0' # Weekly on Sunday
1212
workflow_dispatch:
1313

1414
permissions:
1515
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
1621

1722
jobs:
1823
scan:
1924
name: Hypatia Neurosymbolic Analysis
20-
runs-on: ubuntu-22.04 # Pinned: erlef/setup-beam does not support ubuntu-24 (ImageOS mapping)
25+
runs-on: ubuntu-latest
2126

2227
steps:
2328
- name: Checkout repository
24-
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4
29+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
2530
with:
2631
fetch-depth: 0 # Full history for better pattern analysis
2732

@@ -31,28 +36,62 @@ jobs:
3136
elixir-version: '1.19.4'
3237
otp-version: '28.3'
3338

34-
- name: Clone Hypatia
39+
- name: Clone Hypatia (or use checkout when scanning hypatia itself)
3540
run: |
36-
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
3748
git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia"
3849
fi
3950
4051
- name: Build Hypatia scanner (if needed)
41-
working-directory: ${{ env.HOME }}/hypatia
4252
run: |
43-
if [ ! -x hypatia ] && [ ! -x hypatia-v2 ]; then
44-
echo "Building hypatia scanner escript..."
53+
cd "$HOME/hypatia"
54+
if [ ! -f hypatia ]; then
55+
echo "Building hypatia scanner..."
4556
mix deps.get
4657
mix escript.build
4758
fi
4859
4960
- name: Run Hypatia scan
5061
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 }}
5167
run: |
5268
echo "Scanning repository: ${{ github.repository }}"
5369
54-
# Run scanner
55-
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
5695
5796
# Count findings
5897
FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0)
@@ -62,148 +101,96 @@ jobs:
62101
CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json)
63102
HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json)
64103
MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json)
65-
SECRET_COUNT=$(jq '[.[] | select(((.type // "") | test("secret"; "i")) or ((.reason // "") | test("secret"; "i")) or ((.rule // "") | test("secret"; "i")))] | length' hypatia-findings.json)
66-
VULNERABILITY_COUNT=$(jq '[.[] | select(((.type // "") | test("vuln|vulnerab|cve"; "i")) or ((.reason // "") | test("vuln|vulnerab|cve"; "i")) or ((.rule // "") | test("vuln|vulnerab|cve"; "i")))] | length' hypatia-findings.json)
67-
INCIDENT_COUNT=$((SECRET_COUNT + VULNERABILITY_COUNT))
68104
69105
echo "critical=$CRITICAL" >> $GITHUB_OUTPUT
70106
echo "high=$HIGH" >> $GITHUB_OUTPUT
71107
echo "medium=$MEDIUM" >> $GITHUB_OUTPUT
72-
echo "secret_count=$SECRET_COUNT" >> $GITHUB_OUTPUT
73-
echo "vulnerability_count=$VULNERABILITY_COUNT" >> $GITHUB_OUTPUT
74-
echo "incident_count=$INCIDENT_COUNT" >> $GITHUB_OUTPUT
75108
76109
echo "## Hypatia Scan Results" >> $GITHUB_STEP_SUMMARY
77110
echo "- Total findings: $FINDING_COUNT" >> $GITHUB_STEP_SUMMARY
78111
echo "- Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY
79112
echo "- High: $HIGH" >> $GITHUB_STEP_SUMMARY
80113
echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY
81-
echo "- Secrets: $SECRET_COUNT" >> $GITHUB_STEP_SUMMARY
82-
echo "- Vulnerabilities: $VULNERABILITY_COUNT" >> $GITHUB_STEP_SUMMARY
83-
echo "- Incident findings (secret + vulnerability): $INCIDENT_COUNT" >> $GITHUB_STEP_SUMMARY
84-
85-
- name: Immediate dispatch to gitbot-fleet (incident findings)
86-
if: steps.scan.outputs.incident_count > 0
87-
env:
88-
DISPATCH_TOKEN: ${{ secrets.FARM_DISPATCH_TOKEN }}
89-
REPO: ${{ github.repository }}
90-
REF: ${{ github.ref }}
91-
SHA: ${{ github.sha }}
92-
RUN_ID: ${{ github.run_id }}
93-
INCIDENT_COUNT: ${{ steps.scan.outputs.incident_count }}
94-
SECRET_COUNT: ${{ steps.scan.outputs.secret_count }}
95-
VULNERABILITY_COUNT: ${{ steps.scan.outputs.vulnerability_count }}
96-
run: |
97-
set -euo pipefail
98-
if [ -z "${DISPATCH_TOKEN:-}" ]; then
99-
echo "::warning::FARM_DISPATCH_TOKEN not configured; skipping immediate cross-repo dispatch."
100-
exit 0
101-
fi
102-
103-
cat > dispatch-payload.json <<EOF
104-
{
105-
"event_type": "hypatia-security-alert",
106-
"client_payload": {
107-
"source_repo": "${REPO}",
108-
"ref": "${REF}",
109-
"sha": "${SHA}",
110-
"run_id": "${RUN_ID}",
111-
"incident_count": "${INCIDENT_COUNT}",
112-
"secret_count": "${SECRET_COUNT}",
113-
"vulnerability_count": "${VULNERABILITY_COUNT}",
114-
"artifact": "hypatia-findings"
115-
}
116-
}
117-
EOF
118-
119-
curl -fsSL \
120-
-X POST \
121-
-H "Authorization: token ${DISPATCH_TOKEN}" \
122-
-H "Accept: application/vnd.github+json" \
123-
https://api.github.com/repos/hyperpolymath/gitbot-fleet/dispatches \
124-
-d @dispatch-payload.json
125114
126115
- name: Upload findings artifact
127-
if: always()
128-
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
116+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
129117
with:
130118
name: hypatia-findings
131119
path: hypatia-findings.json
132120
retention-days: 90
133121

134-
- name: Publish non-incident findings to gitbot-fleet shared-context
135-
if: steps.scan.outputs.findings_count > 0 && steps.scan.outputs.incident_count == 0
122+
- name: Submit findings to gitbot-fleet (Phase 2)
123+
if: steps.scan.outputs.findings_count > 0
136124
env:
137-
DISPATCH_TOKEN: ${{ secrets.FARM_DISPATCH_TOKEN }}
138-
REPO: ${{ github.repository }}
139-
SHA: ${{ github.sha }}
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 }}
140130
run: |
141-
set -euo pipefail
142-
if [ -z "${DISPATCH_TOKEN:-}" ]; then
143-
echo "::warning::FARM_DISPATCH_TOKEN not configured; skipping non-incident publication."
144-
exit 0
145-
fi
131+
echo "📤 Submitting ${{ steps.scan.outputs.findings_count }} findings to gitbot-fleet..."
146132
147-
jq empty hypatia-findings.json
148-
149-
TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)"
150-
REPO_SLUG="$(echo "$REPO" | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')"
151-
TARGET_FILE="shared-context/findings/${REPO_SLUG}/${TIMESTAMP}.json"
152-
FLEET_DIR="/tmp/gitbot-fleet-${TIMESTAMP}-$$"
153-
154-
trap 'rm -rf "$FLEET_DIR"' EXIT
155-
git clone "https://x-access-token:${DISPATCH_TOKEN}@github.com/hyperpolymath/gitbot-fleet.git" "$FLEET_DIR"
156-
cd "$FLEET_DIR"
157-
158-
git checkout findings-submissions 2>/dev/null || git checkout -b findings-submissions
159-
mkdir -p "$(dirname "$TARGET_FILE")"
160-
161-
jq --arg repo "$REPO" --arg commit "$SHA" --arg submitted_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
162-
def submission_meta: {
163-
repo: $repo,
164-
commit: $commit,
165-
submitted_at: $submitted_at,
166-
scanner_version: "hypatia-v2"
167-
};
168-
if type == "array" then
169-
{findings: ., submission_metadata: submission_meta}
170-
elif type == "object" and (has("findings")) and (.findings | type == "array") then
171-
. + {submission_metadata: submission_meta}
172-
elif type == "object" then
173-
{findings: [.], submission_metadata: submission_meta}
174-
else
175-
error("Unsupported findings JSON shape")
176-
end
177-
' "$GITHUB_WORKSPACE/hypatia-findings.json" > "$TARGET_FILE"
178-
179-
ln -sf "$(basename "$TARGET_FILE")" "shared-context/findings/${REPO_SLUG}/latest.json"
180-
FINDING_COUNT="$(jq '.findings | length' "$TARGET_FILE")"
181-
182-
git add "$TARGET_FILE" "shared-context/findings/${REPO_SLUG}/latest.json"
183-
git config user.name "Hypatia Finding Submitter"
184-
git config user.email "hypatia@reposystem.dev"
185-
186-
if git diff --cached --quiet; then
187-
echo "No non-incident finding changes to publish."
188-
exit 0
189-
fi
190-
191-
git commit -m "findings: ${REPO} @ $(date +%Y-%m-%d)
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"
192136
193-
Submitted: ${FINDING_COUNT} findings
194-
Commit: ${SHA}
195-
Scanner: hypatia-v2
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"
196142
197-
Automated submission from GitHub Actions."
143+
# Cleanup
144+
rm -rf "$FLEET_DIR"
198145
199-
git push origin findings-submissions
146+
echo "✅ Finding submission complete"
200147
201-
- name: Check for critical issues
202-
if: steps.scan.outputs.incident_count > 0
148+
- name: Check for critical or high-severity issues
149+
if: steps.scan.outputs.critical > 0 || steps.scan.outputs.high > 0
203150
run: |
204-
echo "::error::Security incident findings detected (secrets/vulnerabilities)."
205-
echo "::error::Review hypatia-findings.json for details."
206-
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
207194
208195
- name: Generate scan report
209196
run: |
@@ -240,8 +227,14 @@ jobs:
240227
cat hypatia-report.md >> $GITHUB_STEP_SUMMARY
241228
242229
- 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.
243235
if: github.event_name == 'pull_request' && steps.scan.outputs.findings_count > 0
244-
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
236+
continue-on-error: true
237+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
245238
with:
246239
script: |
247240
const fs = require('fs');

0 commit comments

Comments
 (0)