From a09bf79e97950b91f05ee9519a148e2452d03dd7 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 17 May 2026 03:53:51 +0100 Subject: [PATCH] fix(ci): sync hypatia-scan.yml to canonical (kill cd-scanner build drift) The build step did `cd scanner` / built `hypatia-v2` against a path that no longer exists in the hypatia repo (mix.exs is at root), so the Hypatia Neurosymbolic Analysis lane exited 1 every run. The env.HOME and Phase-2 sweeps never normalised this older build-step drift. Replace with the canonical rsr-template-repo hypatia-scan.yml. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/hypatia-scan.yml | 342 ++++++++++++++++++++++------- 1 file changed, 259 insertions(+), 83 deletions(-) diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 00453be..a895ce4 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -10,113 +10,89 @@ 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: 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: name: Hypatia Neurosymbolic Analysis runs-on: ubuntu-latest - # Non-blocking: the scanner is fetched and built from an external repo - # (hyperpolymath/hypatia) and run with --exit-zero; failures here are in - # the external clone/build/run, not in this repository's content, and - # must not gate merges. Every fragile step is marked continue-on-error so - # the job still runs, surfaces findings in the summary, and concludes - # green — mirroring the non-blocking canary precedent (#39), which uses - # step-level continue-on-error rather than the job-level form (the latter - # leaves the check reporting `failure`). - - # Single source of truth for the scanner checkout path. The build step - # previously used `${{ env.HOME }}` (the workflow `env` context has no - # HOME, so it expanded to an empty string → working-directory `/hypatia`, - # which does not exist → "No such file or directory"). github.workspace - # resolves consistently in both `${{ }}` expressions and the shell. - env: - HYPATIA_DIR: ${{ github.workspace }}/.hypatia-scanner steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # Full history for better pattern analysis - name: Setup Elixir for Hypatia scanner - continue-on-error: true uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.18.2 with: - # Hypatia's mix.exs requires `elixir ~> 1.14`. The previous pins - # (Elixir 1.19.4 / OTP 28.3) are not in setup-beam's version index, - # so the step failed in ~15s before any scan ran. Use known-good - # stable lines; major.minor lets setup-beam pick the latest patch. - elixir-version: '1.17' + elixir-version: '1.18' otp-version: '27' - name: Clone Hypatia - continue-on-error: true run: | if [ ! -d "$HOME/hypatia" ]; then git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia" fi - name: Build Hypatia scanner (if needed) - continue-on-error: true - working-directory: ${{ env.HOME }}/hypatia run: | - # The Hypatia mix project lives at the repo root (there is no - # scanner/ subdirectory). escript.build emits ./hypatia, which - # hypatia-cli.sh prefers (hypatia-v2 is only a legacy fallback). - if [ ! -x hypatia ] && [ ! -x hypatia-v2 ]; then - echo "Building hypatia escript..." + cd "$HOME/hypatia" + if [ ! -f hypatia ]; then + echo "Building hypatia scanner..." mix deps.get mix escript.build fi - # No explicit build step: hypatia-cli.sh self-builds the escript - # (`mix deps.get && mix escript.build` at the repo root, with `mix` - # provided by setup-beam) and falls back to the bundled bash scanner - # if the build is unavailable. The previous step hard-coded a - # `cd scanner` subdir that no longer exists in the upstream layout, - # which broke the job; delegating to the CLI keeps this robust to - # upstream layout changes. - name: Run Hypatia scan id: scan - continue-on-error: true env: - # Suppress the Dependabot "GITHUB_TOKEN not set" warning. + # 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 }}" - # This is an advisory, non-gating scan: it is invoked with - # --exit-zero ("emit findings, never fail step") and the downstream - # "Check for critical issues" step deliberately only warns. The - # external hypatia toolchain (separate repo) can still exit non-zero - # on a build/runtime hiccup; do not let that fail this advisory job. - set +e - HYPATIA_FORMAT=json "$HYPATIA_DIR/hypatia-cli.sh" scan . --exit-zero \ - > hypatia-findings.json 2> hypatia-scan.log - rc=$? - set -e - if [ "$rc" -ne 0 ]; then - echo "::warning::Hypatia scanner exited ${rc}; treating as advisory (findings not gated)." - tail -n 40 hypatia-scan.log 2>/dev/null || true - fi - - # Guarantee valid JSON so the jq steps below (and the upload/summary - # steps) are robust whether or not the scanner produced output. - if ! jq -e . hypatia-findings.json >/dev/null 2>&1; then - 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) echo "findings_count=$FINDING_COUNT" >> $GITHUB_OUTPUT # Extract severity counts - CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json 2>/dev/null || echo 0) - HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json 2>/dev/null || echo 0) - MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json 2>/dev/null || echo 0) + CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json) + HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json) + MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json) echo "critical=$CRITICAL" >> $GITHUB_OUTPUT echo "high=$HIGH" >> $GITHUB_OUTPUT @@ -129,46 +105,237 @@ jobs: echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY - name: Upload findings artifact - continue-on-error: true 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) - continue-on-error: true 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 - continue-on-error: true 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 - continue-on-error: true run: | cat << EOF > hypatia-report.md # Hypatia Security Scan Report @@ -188,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 @@ -203,9 +375,13 @@ jobs: cat hypatia-report.md >> $GITHUB_STEP_SUMMARY - name: Comment on PR with findings - continue-on-error: true 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');