Skip to content

Add lock-driven skill key rotation #14079

Add lock-driven skill key rotation

Add lock-driven skill key rotation #14079

Workflow file for this run

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