Repository navigation
Add lock-driven skill key rotation #14079
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Security Scan | |
| on: | |
| # Called by run-on-main.yml on push to main, where image-build-and-push gates | |
| # on the result. There is deliberately no `push:` trigger here as well — that | |
| # would run every scan twice for each push to main. | |
| workflow_call: | |
| workflow_dispatch: | |
| # PRs trigger this directly rather than through run-on-pr.yml, for the same | |
| # reason. Unfiltered by branch, to match the coverage run-on-pr.yml gave. | |
| pull_request: | |
| schedule: | |
| # Run daily at 2 AM UTC | |
| - cron: '0 2 * * *' | |
| # Cancel superseded PR runs; keep other events independent. | |
| concurrency: | |
| group: security-scan-${{ github.event.pull_request.number || github.run_id }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| # security-events: write is granted per job rather than here, so only the two | |
| # jobs that upload SARIF can write security events. | |
| permissions: | |
| contents: read | |
| jobs: | |
| grype-repo-scan: | |
| name: Grype Repository Scan | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| security-events: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| - name: Run Grype vulnerability scanner | |
| id: grype-scan | |
| uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2 | |
| with: | |
| path: "." | |
| output-format: "sarif" | |
| fail-build: false | |
| - name: Upload Grype scan results to GitHub Security tab | |
| uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 | |
| if: always() | |
| with: | |
| sarif_file: ${{ steps.grype-scan.outputs.sarif }} | |
| category: "grype" | |
| zizmor: | |
| name: GitHub Actions Static Analysis | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| security-events: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| # This job only reads the workflow files; it never pushes. | |
| persist-credentials: false | |
| # Reports at every severity, including the low and informational findings | |
| # the gate below does not block on, so the Security tab keeps the full | |
| # picture. This step does not gate: zizmor suppresses its findings exit | |
| # code when the output format is SARIF, which is what advanced-security | |
| # selects. The upload happens inside the action. | |
| - name: Report findings | |
| uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 | |
| with: | |
| version: "1.30.1" | |
| inputs: .github/ | |
| persona: regular | |
| # The gate. `main` is clear of medium-and-above findings, so anything | |
| # this reports was introduced by the change under review. | |
| # | |
| # advanced-security: false is what makes this block — it swaps SARIF for | |
| # human-readable output, which restores the findings exit code, and | |
| # avoids uploading a second, severity-filtered SARIF over the first. | |
| # | |
| # The remaining low and informational findings are deliberate and are | |
| # documented where they sit: checkouts whose credentials are in use, and | |
| # expressions carrying values GitHub constrains to characters that cannot | |
| # reach a shell. Lowering this threshold means dealing with those first. | |
| - name: Fail on new findings | |
| uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 | |
| with: | |
| version: "1.30.1" | |
| inputs: .github/ | |
| persona: regular | |
| min-severity: medium | |
| advanced-security: false | |
| govulncheck: | |
| name: Go Vulnerability Check | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| - name: Run govulncheck | |
| uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1 | |
| with: | |
| go-version-input: 'stable' | |
| go-package: ./... | |
| repo-checkout: false | |
| output-format: json | |
| output-file: govulncheck-output.json | |
| - name: Check for vulnerabilities (with exclusions) | |
| run: | | |
| set -euo pipefail | |
| # GO-2026-5932: golang.org/x/crypto/openpgp is deprecated-by-design | |
| # ("unsafe, not maintained, should not be used"). No fixed version | |
| # exists and none is planned. | |
| # | |
| # ToolHive does not import openpgp. It arrives four levels up: | |
| # pkg/skills/signer -> sigstore-go/pkg/sign -> rekor/pkg/pki | |
| # -> rekor/pkg/pki/pgp -> golang.org/x/crypto/openpgp | |
| # rekor/pkg/pki is a pluggable signature-format registry, so | |
| # importing it links every format, PGP included. Every govulncheck | |
| # trace is package-init reachability, not a call: the code is | |
| # linked but never parses PGP data on any ToolHive path. | |
| # | |
| # REMOVAL TRIGGER: rekor migrated to ProtonMail/go-crypto/openpgp | |
| # in sigstore/rekor#2883 (merged 2026-07-15), which landed after | |
| # v1.5.3 (2026-07-02) and so is not in any release yet. Bump rekor | |
| # past v1.5.3 once a release contains it, then drop this entry. | |
| # | |
| # GO-2026-6354 (CVE-2026-78662) and GO-2026-6355 (CVE-2026-56855): | |
| # both are DoS in golang.org/x/crypto/ssh, where a malicious SSH | |
| # peer deadlocks a connection. Both are fixed in x/crypto v0.56.0. | |
| # | |
| # We cannot take that fix yet. x/crypto v0.56.0 declares `go | |
| # 1.26.0`, and Go orders the bare language version `1.26` below | |
| # the release `1.26.0`, so the toolchain refuses to build until | |
| # go.mod says `1.26.0`. The lint workflow's "Check go.mod version | |
| # format" step forbids a patch version in the go directive, so | |
| # that bump cannot land. | |
| # | |
| # ToolHive never opens an SSH connection. x/crypto/ssh is linked | |
| # only because go-git registers every transport at package init: | |
| # pkg/git.Clone -> go-git CloneContext | |
| # -> transport registry -> plumbing/transport/ssh | |
| # Every caller of pkg/git.Clone routes through | |
| # gitresolver.CloneConfigForRef, and ParseGitReference | |
| # (pkg/skills/gitresolver/reference.go) hardcodes the clone scheme | |
| # to https (http under TOOLHIVE_DEV). No user-supplied ssh:// or | |
| # git@host: URL can reach the SSH transport, so neither DoS is | |
| # reachable on any ToolHive path. | |
| # | |
| # REMOVAL TRIGGER: `go 1.27` sorts above `1.26.0`, so moving the | |
| # go directive to 1.27 satisfies x/crypto v0.56.0 without a patch | |
| # version. Bump x/crypto and drop both entries then. | |
| IGNORED_VULNS="GO-2026-5932 GO-2026-6354 GO-2026-6355" | |
| # Show the raw output for debugging | |
| # A scan that produced nothing is a failed scan, not a clean one. | |
| # Without this, a crashed or truncated run yields an empty finding | |
| # list indistinguishable from "no vulnerabilities" and the gate | |
| # goes green. | |
| if [ ! -s govulncheck-output.json ]; then | |
| echo "::error::govulncheck produced no output; refusing to report success" | |
| exit 1 | |
| fi | |
| echo "::group::govulncheck raw output" | |
| cat govulncheck-output.json | |
| echo "::endgroup::" | |
| # Extract vulnerability IDs that have actual findings (called symbols). | |
| # The JSON has "finding" objects with an "osv" field only for | |
| # vulnerabilities whose vulnerable code paths are actually called. | |
| # | |
| # jq failures must be fatal. A trailing `|| true` on the pipeline | |
| # would swallow a parse error and hand us an empty list, which is | |
| # how malformed output could pass the gate. | |
| if ! jq -r 'select(.finding != null) | .finding.osv' govulncheck-output.json > found-osv.txt; then | |
| echo "::error::could not parse govulncheck output; treating as a failed scan" | |
| exit 1 | |
| fi | |
| # grep exiting 1 means "no matches", which is legitimately clean — | |
| # that is the only non-zero status tolerated here. | |
| FOUND_VULNS=$(sort -u found-osv.txt | { grep -E '^GO-' || true; }) | |
| if [ -z "$FOUND_VULNS" ]; then | |
| echo "✅ No vulnerabilities found" | |
| exit 0 | |
| fi | |
| echo "Found vulnerabilities: $FOUND_VULNS" | |
| # Check if all found vulnerabilities are in the ignore list | |
| UNIGNORED="" | |
| for vuln in $FOUND_VULNS; do | |
| if ! echo "$IGNORED_VULNS" | grep -qw "$vuln"; then | |
| UNIGNORED="$UNIGNORED $vuln" | |
| fi | |
| done | |
| UNIGNORED=$(echo "$UNIGNORED" | xargs) | |
| if [ -z "$UNIGNORED" ]; then | |
| echo "⚠️ All vulnerabilities are ignored: $FOUND_VULNS" | |
| exit 0 | |
| fi | |
| echo "❌ Vulnerabilities need attention: $UNIGNORED" | |
| exit 1 |