diff --git a/.github/workflows/cve-scan.yml b/.github/workflows/cve-scan.yml new file mode 100644 index 0000000000..d3cddf4008 --- /dev/null +++ b/.github/workflows/cve-scan.yml @@ -0,0 +1,173 @@ +name: CVE Scan + +# D95 (pre-11-02 launch hardening). Three guards, each a separate job so its +# permissions are exactly what that path needs: +# - pr-gate (pull_request): fail only on a NEWLY-INTRODUCED fixable critical +# CVE — the PR head is diffed against its base branch, so a pre-existing +# critical in a lockfile does not fail unrelated PRs. `--ignore-unfixed` +# keeps the unfixable base-OS wall out of the gate. contents:read only. +# - nightly-deps (schedule/dispatch): full dependency scan, SARIF to the +# Security tab. contents:read + security-events:write. +# - images (schedule/dispatch): image scan of the three live images at the +# tag the cluster is actually running. A pull or scan failure fails the job +# (no silent green). contents:read + id-token:write (WIF). +# +# Supply chain: Trivy runs as its official image pinned by DIGEST (immutable, +# content-addressed) rather than a mutable tag or the setup-trivy binary +# installer — directly in response to the March 2026 trivy-action/setup-trivy +# tag-poisoning compromise (GHSA-69fq-xp46-6x23), where version TAGS were +# force-pushed to malicious commits and Docker Hub tags 0.69.5/0.69.6 were +# attacker-pushed. A sha256 digest cannot be re-pointed, so it is immune to +# that class of attack. 0.76.1 is a current post-remediation release pulled +# from GHCR (GitHub immutable releases). Every other third-party action is +# pinned to a full commit SHA. Registry host is assembled from REGION + the +# DEV_GCP_PROJECT_ID secret so no project identifier is committed to this +# public repo. + +on: + pull_request: + schedule: + - cron: '17 9 * * *' # 09:17 UTC nightly + workflow_dispatch: + inputs: + image_tag: + description: 'Image tag to scan (default: the tag the cluster is running)' + required: false + +# No workflow-level permissions — each job declares only what it needs. +permissions: {} + +concurrency: + group: cve-scan-${{ github.ref }} + cancel-in-progress: true + +env: + # Trivy 0.76.1, GHCR, pinned by digest — see the supply-chain note above. + TRIVY_IMG: 'ghcr.io/aquasecurity/trivy@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c' + REGION: us-central1 + CLUSTER: commonly-dev + +jobs: + pr-gate: + name: PR gate — newly-introduced fixable criticals + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 # need base history for the diff + - name: Diff fixable criticals against base + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_TOKEN: ${{ github.token }} # raises Trivy DB pull rate limit + run: | + set -euo pipefail + # Fail open on a tool error (checkout, Trivy DB fetch, image pull, + # docker, the diff itself), fail closed only on a real finding: an + # outage must not red every PR. + tool_error() { echo "::warning::CVE gate skipped: $1"; exit 0; } + git worktree add -q /tmp/base "$BASE_SHA" || tool_error "could not check out base $BASE_SHA" + mkdir -p /tmp/out + scan() { # $1 = host dir to scan, $2 = output filename under /tmp/out + docker run --rm -e GITHUB_TOKEN -v "$1":/scan:ro -v /tmp/out:/out \ + "$TRIVY_IMG" fs --scanners vuln --severity CRITICAL --ignore-unfixed \ + --quiet --format json -o "/out/$2" /scan + } + ids() { python3 -c "import json,sys;d=json.load(open(sys.argv[1]));print('\n'.join(sorted({v['VulnerabilityID']+'|'+v.get('PkgName','') for r in d.get('Results') or [] for v in r.get('Vulnerabilities') or []})))" "$1"; } + echo "scanning PR head"; scan "$PWD" head.json || tool_error "scan of the PR head failed" + echo "scanning base $BASE_SHA"; scan /tmp/base base.json || tool_error "scan of base failed" + export LC_ALL=C # comm and sort must agree on order, whatever the runner locale + ids /tmp/out/head.json | sort > head.ids || tool_error "could not read the head scan" + ids /tmp/out/base.json | sort > base.ids || tool_error "could not read the base scan" + new=$(comm -23 head.ids base.ids) || tool_error "could not compare the scans" + if [ -n "$new" ]; then + echo "::error::PR introduces fixable CRITICAL CVE(s) not on base:"; echo "$new" + exit 1 + fi + echo "No newly-introduced fixable critical CVEs." + + nightly-deps: + name: Nightly dependency scan (SARIF) + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Dep scan (SARIF) + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p /tmp/out + docker run --rm -e GITHUB_TOKEN -v "$PWD":/scan:ro -v /tmp/out:/out \ + "$TRIVY_IMG" fs --scanners vuln --severity CRITICAL,HIGH --ignore-unfixed \ + --quiet --format sarif -o /out/trivy-fs.sarif /scan + cp /tmp/out/trivy-fs.sarif trivy-fs.sarif + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@4bfa242aa49c5f7c6770b98670d354a7dfac05f8 # v3 + with: + sarif_file: trivy-fs.sarif + category: trivy-fs + + images: + name: Live image scan (nightly) + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # WIF only + steps: + - name: Authenticate to Google Cloud (WIF) + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2 + with: + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }} + - uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2 + - name: Get GKE credentials + uses: google-github-actions/get-gke-credentials@64bc7249bbcf78056bb92f14d3cedc2da193946c # v2 + with: + cluster_name: ${{ env.CLUSTER }} + location: ${{ env.REGION }} + - name: Scan live images + env: + PROJECT_ID: ${{ secrets.DEV_GCP_PROJECT_ID }} + TAG_INPUT: ${{ github.event.inputs.image_tag }} + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + REG="${REGION}-docker.pkg.dev/${PROJECT_ID}/docker" + if [ -n "${TAG_INPUT}" ]; then + TAG="${TAG_INPUT}" + else + img=$(kubectl get deploy backend -n "${CLUSTER}" -o jsonpath='{.spec.template.spec.containers[0].image}') + TAG="${img##*:}" + fi + [ -n "${TAG}" ] || { echo "::error::could not resolve an image tag"; exit 1; } + # AR pull creds for Trivy (runs in a container, so gcloud cred-helper + # is not reachable): username/password env, password kept off argv. + TRIVY_USERNAME=oauth2accesstoken + TRIVY_PASSWORD=$(gcloud auth print-access-token) + export TRIVY_USERNAME TRIVY_PASSWORD + mkdir -p /tmp/out + echo "## CVE image scan — tag \`${TAG}\`" >> "$GITHUB_STEP_SUMMARY" + echo "| image | CRIT (fixable) | HIGH (fixable) |" >> "$GITHUB_STEP_SUMMARY" + echo "|---|---|---|" >> "$GITHUB_STEP_SUMMARY" + fail=0 + for n in commonly-backend commonly-bot commonly-frontend; do + img="${REG}/${n}:${TAG}" + if ! docker run --rm -e GITHUB_TOKEN -e TRIVY_USERNAME -e TRIVY_PASSWORD \ + -v /tmp/out:/out "$TRIVY_IMG" image --scanners vuln --ignore-unfixed \ + --severity CRITICAL,HIGH --quiet --format json -o "/out/${n}.json" "$img"; then + echo "| ${n} | pull/scan FAILED | - |" >> "$GITHUB_STEP_SUMMARY" + fail=1; continue + fi + crit=$(python3 -c "import json;d=json.load(open('/tmp/out/${n}.json'));print(sum(1 for r in d.get('Results') or [] for v in r.get('Vulnerabilities') or [] if v.get('Severity')=='CRITICAL'))") + high=$(python3 -c "import json;d=json.load(open('/tmp/out/${n}.json'));print(sum(1 for r in d.get('Results') or [] for v in r.get('Vulnerabilities') or [] if v.get('Severity')=='HIGH'))") + echo "| ${n} | ${crit} | ${high} |" >> "$GITHUB_STEP_SUMMARY" + [ "${crit}" -gt 0 ] && fail=1 + done + [ "${fail}" = 0 ] || { echo "::error::a live image failed to scan or has a fixable critical CVE"; exit 1; } diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000000..cd97993d69 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,8 @@ +# Accepted / deferred CVEs for the CVE Scan gate (D95). Review before 11-02. +# The gate runs with --ignore-unfixed, so upstream-unfixed CVEs already do not +# fire; these lines document the notable ones found in the 2026-10-26 baseline +# so a future fix (a new base image) is a conscious removal, not a surprise. +# +# backend image, no upstream fix as of 2026-10-26: +CVE-2026-58016 # libglib2.0 — no fixed version published +CVE-2023-6879 # libaom3 — no fixed version published