diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index f84a996..8859f9c 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -42,7 +42,11 @@ workflows: '.github/workflows/guix-policy.yml': - 'actions/checkout@v7.0.1' '.github/workflows/hypatia-scan.yml': - - 'metadatastician/standards@46ee18323305d5b36e2eef936e47dd54c39ea736' + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' + - 'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370' '.github/workflows/label-triage.yml': [] '.github/workflows/labels.yml': [] '.github/workflows/lock-sync-gate.yml': [] diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index d7e97c8..0dd9b95 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -1,18 +1,34 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # -# Standalone Hypatia security scan (push / PR / weekly). This is NOT a duplicate -# of the `hypatia-scan` job in `static-analysis-gate.yml`: that job exists to -# feed the gitbot-fleet LEARNING pipeline (via `deposit-findings`), whereas this -# workflow is the repo's independent security scan. Same tool, two consumers. -# See .github/workflows/README.adoc. +# Self-contained Hypatia neurosymbolic security scan + SARIF upload (`category: +# hypatia`) satisfying the `Hypatia` code-scanning tool requirement in the +# `Base` branch ruleset on `main`, as well as push, weekly schedule, and manual +# dispatch runs. +# +# This is the ONLY workflow that runs Hypatia (static-analysis-gate.yml used to +# run it a second time at the same pin, with a second cache, its own duplicate +# "fail on critical" gate and a differently-shaped output). See the header of +# fork-governance.yml for the layering. +# +# Two outputs matter downstream: +# * the SARIF, for code scanning; +# * `hypatia-findings.relativized.json` in the `hypatia-scan-findings` +# artifact, which the Fork governance verdict grid reads. The artifact name +# matches hypatia-scan-reusable.yml's, so adopting that reusable later needs +# no change in fork-governance.yml. +# +# `name:` is a cross-workflow EDGE: verdict-grid-publish.yml names "Hypatia +# Security Scan" in its `workflow_run.workflows`. Renaming this workflow without +# renaming it there silently stops the grid re-rendering; +# scripts/check-workflow-edges.sh fails the CICD Squabbler gate when the two +# disagree. name: Hypatia Security Scan on: + pull_request: push: branches: [main, master, develop] - pull_request: - branches: [main, master] schedule: - cron: '0 0 * * 0' workflow_dispatch: @@ -20,16 +36,135 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true + permissions: - actions: read # required by the reusable workflow (staleness check reads workflow runs) contents: read - security-events: write jobs: hypatia: - uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@46ee18323305d5b36e2eef936e47dd54c39ea736 - with: - # The scanner the verdict grid names (fork-governance.yml and - # verdict-grid-publish.yml, --hypatia-ref). Without it the reusable runs - # its own default (adb2f41 at 46ee183), not the commit the grid claims. - hypatia-ref: be98f74ce93df200752f3798efa1305bfa3e0e10 + name: Hypatia Neurosymbolic Analysis + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + security-events: write + vulnerability-alerts: read # Dependabot alerts, for Hypatia's DA rules + env: + HYPATIA_SHA: 'be98f74ce93df200752f3798efa1305bfa3e0e10' + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Elixir 1.19.4 / OTP 28.5.0.8 for Hypatia scanner + uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 + with: + elixir-version: '1.19.4' + otp-version: '28.5.0.8' + + - name: Cache pinned Hypatia build + id: cache-hypatia + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/hypatia + ~/.hex + ~/.mix + key: hypatia-sarif-${{ runner.os }}-otp28.5.0.8-elx1194-${{ env.HYPATIA_SHA }} + + - name: Build pinned Hypatia scanner + run: | + set -euo pipefail + if [ ! -x "$HOME/hypatia/hypatia" ] || [ ! -x "$HOME/hypatia/hypatia-cli.sh" ]; then + rm -rf "$HOME/hypatia" + git init "$HOME/hypatia" + git -C "$HOME/hypatia" remote add origin https://github.com/hyperpolymath/hypatia.git + git -C "$HOME/hypatia" fetch --depth=1 origin "$HYPATIA_SHA" + git -C "$HOME/hypatia" checkout --detach FETCH_HEAD + ( + cd "$HOME/hypatia" + mix local.hex --force + mix local.rebar --force + mix deps.get + MIX_NO_PUBSUB=1 mix escript.build + ) + chmod +x "$HOME/hypatia/hypatia" "$HOME/hypatia/hypatia-cli.sh" + fi + + - name: Run Hypatia JSON and SARIF analysis + id: scan + env: + # The API-backed rules (code-scanning, Dependabot, secret-scanning, + # branch-protection, baseline-health) return no findings without a + # token. Scoped to this step. The job token reaches code-scanning + # (security-events) and Dependabot (vulnerability-alerts) alerts; + # it cannot read secret-scanning alerts, which need an App or PAT. + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json + jq -e 'type == "array"' hypatia-findings.json >/dev/null + + HYPATIA_FORMAT=sarif "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia.sarif + jq -e '.runs | type == "array" and length > 0' hypatia.sarif >/dev/null + + # Repo-relative paths, because the verdict grid's delta column compares + # findings against `git diff --name-only`, which is repo-relative: an + # absolute path would never match, and every finding would be counted + # as "standing" instead of "in this change". + jq --arg ws "$GITHUB_WORKSPACE/" ' + map(if (.file | type) == "string" then + .file |= (if startswith($ws) then .[($ws | length):] else . end) + else . end)' hypatia-findings.json > hypatia-findings.relativized.json + + CRITICAL="$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json)" + HIGH="$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json)" + TOTAL="$(jq 'length' hypatia-findings.json)" + { + echo "critical=$CRITICAL" + echo "high=$HIGH" + echo "total=$TOTAL" + } >> "$GITHUB_OUTPUT" + + # Evidence for the verdict grid. Uploaded even when the scan step failed: + # a missing artifact and a failed scan are different facts, and the grid + # can only say "orange" for the first. `if-no-files-found: warn` because a + # scan that never produced a file is already visible as a red step here. + - name: Upload Hypatia findings for the verdict grid + if: ${{ always() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: hypatia-scan-findings + path: | + hypatia-findings.json + hypatia-findings.relativized.json + if-no-files-found: warn + retention-days: 90 + + - name: Upload Hypatia SARIF to GitHub Code Scanning + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + sarif_file: hypatia.sarif + category: hypatia + + - name: Enforce zero critical Hypatia findings + env: + CRITICAL_COUNT: ${{ steps.scan.outputs.critical }} + HIGH_COUNT: ${{ steps.scan.outputs.high }} + TOTAL_COUNT: ${{ steps.scan.outputs.total }} + run: | + set -euo pipefail + { + echo "## Hypatia Neurosymbolic Scan" + echo "" + echo "- Critical: ${CRITICAL_COUNT}" + echo "- High: ${HIGH_COUNT}" + echo "- Total findings: ${TOTAL_COUNT}" + } >> "$GITHUB_STEP_SUMMARY" + if [ "$CRITICAL_COUNT" != "0" ]; then + echo "::error::Hypatia reported ${CRITICAL_COUNT} critical finding(s)" + exit 1 + fi