Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions .github/workflows/cve-scan.yml
Original file line number Diff line number Diff line change
@@ -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; }
8 changes: 8 additions & 0 deletions .trivyignore
Original file line number Diff line number Diff line change
@@ -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
Loading