@@ -4,24 +4,29 @@ name: Hypatia Security Scan
44
55on :
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
1414permissions :
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
1722jobs :
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