diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 3f49b7cd..860a2b79 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -1,4 +1,4 @@ -# SPDX-License-Identifier: MPL-2.0 +# SPDX-License-Identifier: PMPL-1.0-or-later # Hypatia Neurosymbolic CI/CD Security Scan name: Hypatia Security Scan @@ -10,19 +10,31 @@ 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 # Hypatia queries Dependabot alerts via the GraphQL API + # 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: scan: name: Hypatia Neurosymbolic Analysis runs-on: ubuntu-latest - env: - # Hypatia's CLI calls `gh api` for Dependabot alert lookups; without this - # env var it logs "Dependabot alerts unavailable: GITHUB_TOKEN not set". - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout repository @@ -37,74 +49,55 @@ jobs: otp-version: '28.3' - name: Clone Hypatia - id: clone_hypatia - continue-on-error: true run: | - HYPATIA_DIR="${GITHUB_WORKSPACE}/../hypatia" - if [ ! -d "$HYPATIA_DIR" ]; then - git clone --depth 1 https://github.com/hyperpolymath/hypatia.git "$HYPATIA_DIR" || { - echo "::warning::Hypatia clone failed — scan will be skipped" - exit 0 - } + if [ ! -d "$HOME/hypatia" ]; then + git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia" fi - echo "HYPATIA_DIR=$HYPATIA_DIR" >> "$GITHUB_ENV" - echo "hypatia_ready=true" >> "$GITHUB_OUTPUT" - name: Build Hypatia scanner (if needed) - if: steps.clone_hypatia.outputs.hypatia_ready == 'true' - id: build_hypatia - continue-on-error: true run: | - cd "$HYPATIA_DIR" - if [ ! -f hypatia ] && [ ! -f hypatia-v2 ]; then - echo "Building hypatia-v2 scanner..." - mix deps.get --quiet || { echo "::warning::mix deps.get failed"; exit 0; } - mix escript.build || { echo "::warning::mix escript.build failed"; exit 0; } + cd "$HOME/hypatia" + if [ ! -f hypatia ]; then + echo "Building hypatia scanner..." + mix deps.get + mix escript.build fi - echo "scanner_ready=true" >> "$GITHUB_OUTPUT" - name: Run Hypatia scan id: scan - if: steps.build_hypatia.outputs.scanner_ready == 'true' + 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 — failures here must not red the check; scanner-infra - # issues are tracked upstream and surface as a warning instead. - HYPATIA_FORMAT=json "$HYPATIA_DIR/hypatia-cli.sh" scan . > hypatia-findings.json 2>scan-stderr.log || { - echo "::warning::hypatia-cli.sh exited non-zero — see scan-stderr.log" - echo "[]" > hypatia-findings.json - } - - # If the scanner wrote something that isn't a JSON array, normalise to []. - if ! jq -e 'type == "array"' hypatia-findings.json >/dev/null 2>&1; then - echo "::warning::hypatia-findings.json is not a JSON array — treating as empty" - 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) + HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json) + MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json) - FINDING_COUNT=$(jq 'length' hypatia-findings.json 2>/dev/null || echo 0) - 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) - - { - echo "findings_count=$FINDING_COUNT" - echo "critical=$CRITICAL" - echo "high=$HIGH" - echo "medium=$MEDIUM" - } >> "$GITHUB_OUTPUT" - - { - echo "## Hypatia Scan Results" - echo "- Total findings: $FINDING_COUNT" - echo "- Critical: $CRITICAL" - echo "- High: $HIGH" - echo "- Medium: $MEDIUM" - } >> "$GITHUB_STEP_SUMMARY" + 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 - if: steps.build_hypatia.outputs.scanner_ready == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: hypatia-findings path: hypatia-findings.json @@ -112,32 +105,73 @@ jobs: - 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. Private repo — if unauthenticated, - # skip with a warning rather than failing the whole workflow. + # 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 https://github.com/hyperpolymath/gitbot-fleet.git "$FLEET_DIR" 2>/dev/null; then - echo "::warning::gitbot-fleet clone failed (likely private/no auth) — skipping submission" + 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 - # Run submission script - bash "$FLEET_DIR/scripts/submit-finding.sh" "$(pwd)/hypatia-findings.json" || { - echo "::warning::submit-finding.sh failed — see step log" - } + # 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" - echo "✅ Finding submission complete" - - name: Check for critical issues if: steps.scan.outputs.critical > 0 run: | @@ -182,6 +216,11 @@ jobs: - 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: | @@ -212,4 +251,4 @@ jobs: repo: context.repo.repo, issue_number: context.issue.number, body: comment - }); + }); \ No newline at end of file