Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 90 additions & 103 deletions .github/workflows/hypatia-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,25 @@ on:
schedule:
- cron: '0 0 * * 0' # Weekly on Sunday
workflow_dispatch:
# Estate guardrail: cancel superseded runs so re-pushes don't pile up
# queued runs across the estate. Safe here because this workflow only
# performs read-only checks/lint/test/scan with no publish or mutation.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
# `pull-requests: write` is needed for the "Comment on PR with findings"
# step to POST a results summary. Note: on Dependabot PRs the token is
# downgraded to read-only regardless, so that step is also marked
# continue-on-error below.
# security-events: read lets the built-in GITHUB_TOKEN query this
# repo's own Dependabot alerts via the Hypatia DependabotAlerts rule
# (DA001-DA004). Without this, `scan_from_path` gets HTTP 403 and
# the rule silently returns no findings.
# See 007-lang/audits/audit-dependabot-automation-gap-2026-04-17.md.
security-events: read
# pull-requests: write lets the advisory "Comment on PR with findings"
# step post its summary. Without it the built-in GITHUB_TOKEN gets
# "Resource not accessible by integration" and (absent continue-on-error)
# hard-fails the scan — exactly what the gate-decoupling design forbids.
pull-requests: write

jobs:
Expand All @@ -26,7 +38,7 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # Full history for better pattern analysis

Expand All @@ -36,15 +48,9 @@ jobs:
elixir-version: '1.19.4'
otp-version: '28.3'

- name: Clone Hypatia (or use checkout when scanning hypatia itself)
- name: Clone Hypatia
run: |
# When scanning hypatia from inside hypatia, point $HOME/hypatia
# at the PR/branch checkout instead of cloning main — otherwise
# CLI changes can never pass their own gate (the scanner binary
# would always come from main and ignore new flags).
if [ "${{ github.repository }}" = "hyperpolymath/hypatia" ]; then
ln -sfn "${GITHUB_WORKSPACE}" "$HOME/hypatia"
elif [ ! -d "$HOME/hypatia" ]; then
if [ ! -d "$HOME/hypatia" ]; then
git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia"
fi

Expand All @@ -60,38 +66,16 @@ jobs:
- name: Run Hypatia scan
id: scan
env:
# Suppress the "Warning: Dependabot alerts unavailable: GITHUB_TOKEN
# not set" line so the run is silent-warning-free. The token is
# read-only by default and only used to query Dependabot alerts.
# Pass the built-in Actions token through to Hypatia so the
# DependabotAlerts rule can query this repo's own alerts.
# For cross-repo scanning (fleet-coordinator scan-supervised),
# a PAT with `security_events` scope is required instead.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Scanning repository: ${{ github.repository }}"

# Run scanner with --exit-zero so a findings-found exit-1 does
# NOT short-circuit the rest of this step under `set -e`. The
# downstream "Check for critical or high-severity issues" step
# is the explicit gate. See hyperpolymath/hypatia#213.
#
# Guard against the scanner producing no output (a crash, an
# unknown flag, etc.): if hypatia-findings.json is empty or
# missing after the run, fall back to "[]" so the jq calls
# below don't 9 the whole gate. We surface stderr so the
# underlying scanner failure is still visible in the log.
set +e
HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero \
> hypatia-findings.json 2> hypatia-scan.stderr
SCAN_EXIT=$?
set -e
echo "Scanner exit: $SCAN_EXIT"
if [ -s hypatia-scan.stderr ]; then
echo "--- scanner stderr ---"
cat hypatia-scan.stderr
echo "--- end stderr ---"
fi
if ! jq empty hypatia-findings.json 2>/dev/null; then
echo "Scanner did not produce valid JSON; defaulting to empty findings."
echo "[]" > hypatia-findings.json
fi
# Run scanner (exits non-zero when findings exist — suppress to continue)
HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json || true

# Count findings
FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0)
Expand All @@ -113,84 +97,88 @@ jobs:
echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY

- name: Upload findings artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: hypatia-findings
path: hypatia-findings.json
retention-days: 90

- name: Submit findings to gitbot-fleet (Phase 2)
if: steps.scan.outputs.findings_count > 0
# Phase 2 is the collaborative LEARNING side-channel ("bots share
# findings via gitbot-fleet"), not the security gate. The gate is
# the baseline-aware "Check for critical or high-severity issues"
# step below. A fleet-side regression (e.g. the submit script being
# moved/removed) must NEVER hard-fail every consuming repo's scan.
# Same reasoning as the "Comment on PR with findings" step.
# See hyperpolymath/hypatia#213 (gate decoupling) and the exit-127
# estate-wide breakage when gitbot-fleet/scripts/submit-finding.sh
# no longer existed on the default branch.
continue-on-error: true
env:
# All GitHub context values surface as env vars so the run
# block never interpolates `${{ … }}` inline (closes the
# workflow_audit/unsafe_curl_payload + actions_expression_injection
# findings).
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FLEET_PUSH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }}
FLEET_DISPATCH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
FINDINGS_COUNT: ${{ steps.scan.outputs.findings_count }}
run: |
echo "📤 Submitting ${{ steps.scan.outputs.findings_count }} findings to gitbot-fleet..."
echo "📤 Submitting $FINDINGS_COUNT findings to gitbot-fleet..."

# Clone gitbot-fleet to temp directory
# Clone gitbot-fleet to temp directory. A clone failure (network,
# repo gone) is non-fatal: learning submission is best-effort.
FLEET_DIR="/tmp/gitbot-fleet-$$"
git clone https://github.com/hyperpolymath/gitbot-fleet.git "$FLEET_DIR"
if ! git clone --depth 1 https://github.com/hyperpolymath/gitbot-fleet.git "$FLEET_DIR"; then
echo "::warning::Could not clone gitbot-fleet — skipping Phase 2 learning submission (non-fatal)."
exit 0
fi

# The submission script's location in gitbot-fleet has drifted
# before (it was absent from the default branch, which exit-127'd
# every consuming repo's scan). Probe known locations rather than
# hard-coding one path, and skip gracefully if none is present.
SUBMIT_SCRIPT=""
for cand in \
"$FLEET_DIR/scripts/submit-finding.sh" \
"$FLEET_DIR/scripts/submit_finding.sh" \
"$FLEET_DIR/bin/submit-finding.sh" \
"$FLEET_DIR/submit-finding.sh"; do
if [ -f "$cand" ]; then
SUBMIT_SCRIPT="$cand"
break
fi
done

if [ -z "$SUBMIT_SCRIPT" ]; then
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."
rm -rf "$FLEET_DIR"
exit 0
fi

# Run submission script. Pass the findings path as ABSOLUTE —
# submit-finding.sh cd's into its own working dir before reading
# the file, so a relative path would resolve to the wrong place
# and the script fails with "No such file or directory".
bash "$FLEET_DIR/scripts/submit-finding.sh" "$GITHUB_WORKSPACE/hypatia-findings.json"
# the script cd's into its own working dir before reading the
# file, so a relative path would resolve to the wrong place.
# A submission-script failure is logged but non-fatal.
if bash "$SUBMIT_SCRIPT" "$GITHUB_WORKSPACE/hypatia-findings.json"; then
echo "✅ Finding submission complete"
else
echo "::warning::gitbot-fleet submission script exited non-zero — Phase 2 learning submission skipped (non-fatal)."
fi

# Cleanup
rm -rf "$FLEET_DIR"

echo "✅ Finding submission complete"

- name: Check for critical or high-severity issues
if: steps.scan.outputs.critical > 0 || steps.scan.outputs.high > 0
- name: Check for critical issues
if: steps.scan.outputs.critical > 0
run: |
echo "Total critical/high: ${{ steps.scan.outputs.critical }} critical, ${{ steps.scan.outputs.high }} high"

# Baseline-aware gate: pre-existing accepted findings live in
# .hypatia-baseline.json (committed). New critical/high findings
# not in the baseline still fail the build. Findings are matched
# on (severity, rule_module, type, file) tuple with absolute
# build paths normalised to repo-relative.
if [ -f .hypatia-baseline.json ]; then
# Normalise + project the FINDING IDENTITY tuple from the current
# scan. Identity is (severity, rule_module, type, file) — `action`
# is remediation guidance that can legitimately drift between
# scanner versions (e.g. "flag" -> "create_branch") and is NOT
# part of what makes two findings the same.
jq '[ .[] | select(.severity == "critical" or .severity == "high")
| {severity, rule_module, type,
file: (.file | sub("^/home/runner/work/[^/]+/[^/]+/"; "")
| sub("^/github/workspace/"; "")) } ]' \
hypatia-findings.json > findings-current.json

# Subtract baseline. A current finding is "new" iff there's no
# baseline element with the same identity tuple. Baseline entries
# may include extra fields (e.g. `action`); strip them before the
# comparison so legacy baselines keep working.
jq --slurpfile base .hypatia-baseline.json \
'($base[0] | map({severity, rule_module, type, file})) as $bk
| map(. as $f | select(($bk | any(. == $f)) | not))' \
findings-current.json > findings-new.json
new_count=$(jq 'length' findings-new.json)

if [ "$new_count" -gt 0 ]; then
echo "::error::$new_count new critical/high finding(s) outside the baseline:"
jq -r '.[] | " [\(.severity)] \(.rule_module)/\(.type) — \(.file)"' findings-new.json
echo
echo "If these are intentional, regenerate .hypatia-baseline.json:"
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"
exit 1
fi
echo "All critical/high findings present in baseline — gate passes."
else
echo "No .hypatia-baseline.json — failing on any critical/high (legacy behaviour)."
echo "Review hypatia-findings.json for details"
exit 1
fi
echo "⚠️ Critical security issues found!"
echo "Review hypatia-findings.json for details"
# Don't fail the build yet - just warn
# exit 1

- name: Generate scan report
run: |
Expand Down Expand Up @@ -227,14 +215,13 @@ jobs:
cat hypatia-report.md >> $GITHUB_STEP_SUMMARY

- name: Comment on PR with findings
# Dependabot PRs always run with a read-only token regardless of the
# workflow's declared permissions, so the createComment call below
# would 403 on every dep-bump PR. The PR comment is informational
# (the check result is already visible in the PR UI); we don't want
# its absence to block merge.
if: github.event_name == 'pull_request' && steps.scan.outputs.findings_count > 0
# Advisory only — posting findings as a PR comment must never gate
# the scan (hypatia#213 gate decoupling). Belt-and-braces alongside
# the pull-requests: write permission above: a token/API hiccup or
# a fork PR (read-only token) skips the comment, not the check.
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v7
with:
script: |
const fs = require('fs');
Expand Down Expand Up @@ -264,4 +251,4 @@ jobs:
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});
});
Loading