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
314 changes: 19 additions & 295 deletions .github/workflows/hypatia-scan.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
# // Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
# SPDX-License-Identifier: MPL-2.0
# Thin wrapper around hyperpolymath/standards hypatia-scan-reusable.yml.
# See standards#191 for the reusable's purpose and design.

# hypatia-scan.yml — thin wrapper calling the shared Hypatia neurosymbolic scan
# in hyperpolymath/standards (hypatia-scan-reusable.yml) instead of carrying the
# per-repo copy. Pinned to the same standards commit as governance.yml and
# scorecard.yml so the estate moves in lockstep. See standards#191 for the
# reusable's purpose and design.
#
# secrets: inherit passes GITHUB_TOKEN and HYPATIA_DISPATCH_PAT through to the
# reusable — the latter is needed by the gitbot-fleet Phase 2 learning
# submission (best-effort / non-fatal). The reusable keeps Hypatia advisory:
# critical findings surface on the code-scanning page (category: hypatia) but
# do not fail the check; tighten via branch protection, not here (hypatia#213).
name: Hypatia Security Scan
on:
push:
Expand All @@ -17,301 +25,17 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# Caller is the ceiling for the reusable's GITHUB_TOKEN; the single job below
# inherits these. The reusable needs:
# security-events: write — upload SARIF + read Dependabot alerts
# pull-requests: write — post the findings comment on PRs
# contents: read — checkout
permissions:
contents: read
security-events: write
pull-requests: write
jobs:
scan:
name: Hypatia Neurosymbolic Analysis
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # 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 \"\U0001F4E4 Submitting $FINDINGS_COUNT findings to gitbot-fleet...\"\n\n# Clone gitbot-fleet to temp directory. A clone failure (network,\n# repo gone) is non-fatal: learning submission is best-effort.\nFLEET_DIR=\"/tmp/gitbot-fleet-$$\"\nif ! git clone --depth 1 https://github.com/hyperpolymath/gitbot-fleet.git \"$FLEET_DIR\"; then\n echo \"::warning::Could not clone gitbot-fleet — skipping Phase 2 learning submission (non-fatal).\"\n exit 0\nfi\n\n# The submission script's location in gitbot-fleet has drifted\n# before (it was absent from the default branch, which exit-127'd\n# every consuming repo's scan). Probe known locations rather than\n# hard-coding one path, and skip gracefully if none is present.\nSUBMIT_SCRIPT=\"\"\nfor cand in \\\n \"$FLEET_DIR/scripts/submit-finding.sh\" \\\n \"$FLEET_DIR/scripts/submit_finding.sh\" \\\n \"$FLEET_DIR/bin/submit-finding.sh\" \\\n \"$FLEET_DIR/submit-finding.sh\"; do\n if [ -f \"$cand\" ]; then\n SUBMIT_SCRIPT=\"$cand\"\n break\n fi\ndone\n\nif [ -z \"$SUBMIT_SCRIPT\" ]; then\n 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.\"\n rm -rf \"$FLEET_DIR\"\n exit 0\nfi\n\n# Run submission script. Pass the findings path as ABSOLUTE —\n# the script cd's into its own working dir before reading the\n# file, so a relative path would resolve to the wrong place.\n# A submission-script failure is logged but non-fatal.\nif bash \"$SUBMIT_SCRIPT\" \"$GITHUB_WORKSPACE/hypatia-findings.json\"; then\n echo \"✅ Finding submission complete\"\nelse\n echo \"::warning::gitbot-fleet submission script exited non-zero — Phase 2 learning submission skipped (non-fatal).\"\nfi\n\n# Cleanup\nrm -rf \"$FLEET_DIR\"\n"
- 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
with:
script: "const fs = require('fs');\nconst findings = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8'));\n\nconst critical = findings.filter(f => f.severity === 'critical').length;\nconst high = findings.filter(f => f.severity === 'high').length;\n\nlet comment = `## \U0001F50D Hypatia Security Scan\\n\\n`;\ncomment += `**Findings:** ${findings.length} issues detected\\n\\n`;\ncomment += `| Severity | Count |\\n|----------|-------|\\n`;\ncomment += `| \U0001F534 Critical | ${critical} |\\n`;\ncomment += `| \U0001F7E0 High | ${high} |\\n`;\ncomment += `| \U0001F7E1 Medium | ${findings.length - critical - high} |\\n\\n`;\n\nif (critical > 0) {\n comment += `⚠️ **Action Required:** Critical security issues found!\\n\\n`;\n}\n\ncomment += `<details><summary>View findings</summary>\\n\\n`;\ncomment += `\\`\\`\\`json\\n${JSON.stringify(findings.slice(0, 10), null, 2)}\\n\\`\\`\\`\\n`;\ncomment += `</details>\\n\\n`;\ncomment += `*Powered by Hypatia Neurosymbolic CI/CD Intelligence*`;\n\ngithub.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n body: comment\n});"
uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@861b5e911d9e5dcfb3c0ab3dd2a9a3c8fd0a1613
secrets: inherit
Loading