From 4e7e4460280534c51c66696e23f5d0a42914977f Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 26 May 2026 11:59:23 +0100 Subject: [PATCH 1/2] chore(ci): convert hypatia-scan.yml to thin wrapper of standards reusable Replaces ~416 lines of duplicated Hypatia scan plumbing with a 29-line wrapper calling hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml at SHA 2569c10e831e293f9dd6580d82a494aca039deee (standards#191 HEAD). Behaviour-preserving: same triggers, same concurrency group, same permissions, same secrets passthrough. Refs standards#191. --- .github/workflows/hypatia-scan.yml | 407 +---------------------------- 1 file changed, 11 insertions(+), 396 deletions(-) diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index dfacf3b..9e384bc 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -1,414 +1,29 @@ # SPDX-License-Identifier: MPL-2.0 -# Hypatia Neurosymbolic CI/CD Security Scan +# Thin wrapper around hyperpolymath/standards hypatia-scan-reusable.yml. +# See standards#191 for the reusable's purpose and design. + name: Hypatia Security Scan on: push: - branches: [ main, master, develop ] + branches: [main, master, develop] pull_request: - branches: [ main, master ] + branches: [main, master] schedule: - - cron: '0 0 * * 0' # Weekly on Sunday + - cron: '0 0 * * 0' 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. + +# Estate guardrail: cancel superseded runs so re-pushes don't pile up. 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 - - steps: - - name: Checkout repository - 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.18' - otp-version: '27' - - - name: Clone Hypatia - run: | - if [ ! -d "$HOME/hypatia" ]; then - git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia" - fi - - - name: Build Hypatia scanner (if needed) - run: | - cd "$HOME/hypatia" - if [ ! -f hypatia ]; then - echo "Building hypatia scanner..." - mix deps.get - mix escript.build - 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 (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) - 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 - echo "medium=$MEDIUM" >> $GITHUB_OUTPUT - - echo "## Hypatia Scan Results" >> $GITHUB_STEP_SUMMARY - echo "- Total findings: $FINDING_COUNT" >> $GITHUB_STEP_SUMMARY - echo "- Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY - echo "- High: $HIGH" >> $GITHUB_STEP_SUMMARY - echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY - - - name: Upload findings artifact - 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 $FINDINGS_COUNT findings to gitbot-fleet..." - - # 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-$$" - 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 — - # 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" - - - 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 "::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: | - cat << EOF > hypatia-report.md - # Hypatia Security Scan Report - - **Repository:** ${{ github.repository }} - **Scan Date:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") - **Commit:** ${{ github.sha }} - - ## Summary - - | Severity | Count | - |----------|-------| - | Critical | ${{ steps.scan.outputs.critical }} | - | High | ${{ steps.scan.outputs.high }} | - | Medium | ${{ steps.scan.outputs.medium }} | - | **Total**| ${{ steps.scan.outputs.findings_count }} | - - ## Next Steps - - 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 - - These findings feed Hypatia's learning engine to improve future rules. - - --- - *Powered by [Hypatia](https://github.com/hyperpolymath/hypatia) - Neurosymbolic CI/CD Intelligence* - EOF - - cat hypatia-report.md >> $GITHUB_STEP_SUMMARY - - - name: Comment on PR with findings - 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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v7 - with: - script: | - const fs = require('fs'); - const findings = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8')); - - const critical = findings.filter(f => f.severity === 'critical').length; - const high = findings.filter(f => f.severity === 'high').length; - - let comment = `## 🔍 Hypatia Security Scan\n\n`; - comment += `**Findings:** ${findings.length} issues detected\n\n`; - comment += `| Severity | Count |\n|----------|-------|\n`; - comment += `| 🔴 Critical | ${critical} |\n`; - comment += `| 🟠 High | ${high} |\n`; - comment += `| 🟡 Medium | ${findings.length - critical - high} |\n\n`; - - if (critical > 0) { - comment += `⚠️ **Action Required:** Critical security issues found!\n\n`; - } - - comment += `
View findings\n\n`; - comment += `\`\`\`json\n${JSON.stringify(findings.slice(0, 10), null, 2)}\n\`\`\`\n`; - comment += `
\n\n`; - comment += `*Powered by Hypatia Neurosymbolic CI/CD Intelligence*`; - - github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: comment - }); \ No newline at end of file + hypatia: + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@2569c10e831e293f9dd6580d82a494aca039deee + secrets: inherit From 2008b34f7e4129aa293fbf32435208dca186eb3e Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 26 May 2026 13:17:27 +0100 Subject: [PATCH 2/2] chore(ci): retarget hypatia-scan wrapper to standards#193 HEAD Standards #191 was closed in favour of #193 (parallel-session implementation with simpler API: zero inputs except runs-on). Repointing the wrapper at #193 HEAD 97df762107501909f50bb770e9bc200b6c415600 so it picks up the merged reusable once #193 lands. Refs standards#193. --- .github/workflows/hypatia-scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 9e384bc..1d6417e 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -25,5 +25,5 @@ permissions: jobs: hypatia: - uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@2569c10e831e293f9dd6580d82a494aca039deee + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@97df762107501909f50bb770e9bc200b6c415600 secrets: inherit