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
288 changes: 260 additions & 28 deletions .github/workflows/hypatia-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,34 @@ 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
# security-events: read lets the built-in GITHUB_TOKEN query this
# repo\'s own Dependabot alerts via the Hypatia DependabotAlerts rule.
security-events: read
# security-events: write serves two purposes (write implies read):
# 1. read β€” lets the built-in GITHUB_TOKEN query this repo's own
# Dependabot alerts via the Hypatia DependabotAlerts rule
# (DA001-DA004). Without read, `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.
# 2. write β€” lets the "Upload SARIF to code scanning" step publish
# Hypatia findings to the Security β†’ Code scanning page so they
# are triaged/deduplicated like CodeQL alerts instead of living
# only in a build artifact nobody is required to look at.
# See hyperpolymath/burble#35 (SARIF integration).
# This is a single-job workflow, so job-level scoping would not
# narrow the grant further; it stays workflow-level and documented.
security-events: write
# 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:
scan:
Expand All @@ -24,15 +46,15 @@ jobs:

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

- name: Setup Elixir for Hypatia scanner
uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.18.2
with:
elixir-version: '1.19.4'
otp-version: '28.3'
elixir-version: '1.18'
otp-version: '27'

- name: Clone Hypatia
run: |
Expand All @@ -41,22 +63,27 @@ jobs:
fi

- name: Build Hypatia scanner (if needed)
working-directory: /home/runner/hypatia
run: |
if [ ! -f hypatia-v2 ]; then
echo "Building hypatia-v2 scanner..."
cd "$HOME/hypatia"
if [ ! -f hypatia ]; then
echo "Building hypatia scanner..."
mix deps.get
mix escript.build
mv hypatia ../hypatia-v2
fi

- name: Run Hypatia scan
id: scan
env:
# 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
HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . > hypatia-findings.json
# 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 @@ -78,40 +105,235 @@ jobs:
echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY

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

- name: Convert Hypatia findings to SARIF
# Always runs (no findings_count guard): an EMPTY SARIF run is
# valid and intentional β€” uploading it clears stale Hypatia
# alerts from the code-scanning page when a repo goes clean.
# The converter is dependency-free Node (Node ships on
# ubuntu-latest; no npm install β€” estate npm ban respected) and
# is hardened against the heterogeneous Hypatia JSON schema:
# most findings are {rule_module,severity,type,file,reason,
# action}; only some carry an integer `line`; `file` may be
# empty or absolute. See lib/hypatia/cli.ex (collect_findings).
run: |
cat > "$RUNNER_TEMP/hypatia-sarif.cjs" <<'CJS'
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const ws = process.env.GITHUB_WORKSPACE || process.cwd();

let findings = [];
try {
const parsed = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8'));
if (Array.isArray(parsed)) findings = parsed;
} catch (_) {
// Scanner unavailable / empty / malformed -> empty SARIF.
// Intentionally clears stale alerts rather than erroring.
findings = [];
}

// Mirrors Hypatia's own "github" annotation mapping
// (lib/hypatia/cli.ex output/2): critical|high -> error,
// medium -> warning, everything else -> note.
const levelFor = (sev) => {
switch (String(sev || '').toLowerCase()) {
case 'critical':
case 'high': return 'error';
case 'medium': return 'warning';
default: return 'note';
}
};

// SARIF artifactLocation.uri must be a repo-relative POSIX
// path. Hypatia may emit absolute paths (scanned under
// $GITHUB_WORKSPACE) or "" / "." for repo-level findings.
const relUri = (file) => {
if (!file) return '.';
let f = String(file);
if (path.isAbsolute(f)) {
const rel = path.relative(ws, f);
f = (rel && !rel.startsWith('..')) ? rel : path.basename(f);
}
f = f.replace(/\\/g, '/').replace(/^\.\//, '');
return f || '.';
};

const rules = new Map();
const results = findings.map((f) => {
const mod = String(f.rule_module || 'hypatia');
const type = String(f.type || 'finding');
const ruleId = `hypatia/${mod}/${type}`;
const level = levelFor(f.severity);
if (!rules.has(ruleId)) {
rules.set(ruleId, {
id: ruleId,
name: `${mod}.${type}`,
shortDescription: { text: `Hypatia ${mod}: ${type}` },
defaultConfiguration: { level }
});
}
const uri = relUri(f.file);
const msg = String(f.reason || f.type || 'Hypatia finding');
const startLine =
Number.isInteger(f.line) && f.line > 0 ? f.line : 1;
// Stable cross-run fingerprint for dedupe (no line, so a
// moved finding in the same file/rule stays one alert).
const fp = crypto
.createHash('sha256')
.update([ruleId, uri, type, msg].join('|'))
.digest('hex');
return {
ruleId,
level,
message: { text: msg },
locations: [
{
physicalLocation: {
artifactLocation: { uri },
region: { startLine }
}
}
],
partialFingerprints: { 'hypatiaFindingHash/v1': fp }
};
});

const sarif = {
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
version: '2.1.0',
runs: [
{
tool: {
driver: {
name: 'Hypatia',
informationUri: 'https://github.com/hyperpolymath/hypatia',
rules: Array.from(rules.values())
}
},
results
}
]
};

fs.writeFileSync('hypatia.sarif', JSON.stringify(sarif, null, 2));
console.log(`hypatia.sarif written: ${results.length} result(s).`);
CJS
node "$RUNNER_TEMP/hypatia-sarif.cjs"

- name: Upload SARIF to GitHub code scanning
# Fork PRs get a read-only GITHUB_TOKEN, so security-events:write
# is unavailable and upload-sarif cannot publish β€” skip there
# rather than hard-fail (the push/schedule run on the default
# branch is the authoritative upload). Same-repo PRs and pushes
# do upload. This step is deliberately NOT continue-on-error:
# if the security-surface integration breaks we want a loud red,
# not a silently-ungated scanner (the exact failure mode #35
# exists to end). The empty-SARIF "clear stale alerts" path is
# handled in the converter above and does not error here.
if: >-
always() &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork != true)
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.28.1
with:
sarif_file: hypatia.sarif
# Distinct category so Hypatia results coexist with CodeQL's
# (codeql.yml) instead of overwriting them on the same surface.
category: hypatia

- 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
bash "$FLEET_DIR/scripts/submit-finding.sh" hypatia-findings.json
# Run submission script. Pass the findings path as ABSOLUTE β€”
# 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 issues
if: steps.scan.outputs.critical > 0
# GATING POLICY (explicit, by design β€” not an oversight):
# Hypatia is ADVISORY here. Critical findings are surfaced
# (step annotation + SARIF alert on the code-scanning page +
# PR comment) but do NOT fail this check. Enforcement is
# delegated to the code-scanning surface: tighten by adding a
# branch-protection "required" status on the `hypatia` SARIF
# category, not by reintroducing an `exit 1` here. This keeps
# the gate decision in one auditable place (hypatia#213 gate
# decoupling) and lets a repo opt into fail-on-critical without
# editing this canonical workflow. To change the policy, change
# branch protection β€” deliberately no commented-out `exit 1`.
run: |
echo "⚠️ Critical security issues found!"
echo "Review hypatia-findings.json for details"
# Don't fail the build yet - just warn
# exit 1
echo "::warning::Hypatia found critical security issue(s) β€” advisory."
echo "See the Security β†’ Code scanning page (category: hypatia)"
echo "and the hypatia-findings.json artifact for details."

- name: Generate scan report
run: |
Expand All @@ -133,9 +355,14 @@ jobs:

## Next Steps

1. Review findings in the artifact: hypatia-findings.json
2. Auto-fixable issues will be addressed by robot-repo-automaton (Phase 3)
3. Manual review required for complex issues
1. Triage findings on the **Security β†’ Code scanning** page
(SARIF category \`hypatia\`) β€” dismiss/track them there like
CodeQL alerts.
2. The full finding set is also attached as the
\`hypatia-findings.json\` build artifact for offline review.
3. Findings are **advisory** today (surfaced, not gated); the
gating policy is documented in the workflow's "Check for
critical issues" step.

## Learning

Expand All @@ -149,7 +376,12 @@ jobs:

- name: Comment on PR with findings
if: github.event_name == 'pull_request' && steps.scan.outputs.findings_count > 0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
# 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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v7
with:
script: |
const fs = require('fs');
Expand Down