From 341e10e8c20f91fad8f013caab8be4f373eb2dd9 Mon Sep 17 00:00:00 2001 From: AhmedElBanna80 Date: Thu, 22 Oct 2026 13:46:20 +0200 Subject: [PATCH 1/2] ci(security): govulncheck nightly over every Go module and both refs, gate on main (#2569) Brings the fail-closed gate and its dated exceptions to main, extends the nightly to ref x module, and lifts the gateway module to the go.mod/go.sum/Dockerfile already vetted on integration/v2 so its main leg is not red. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0198oVxWkEEDJJnVhES6ULnx --- .github/workflows/govulncheck-nightly.yml | 56 ++++---- packages/scale-zero-pg/gateway/Dockerfile | 6 +- packages/scale-zero-pg/gateway/go.mod | 14 +- packages/scale-zero-pg/gateway/go.sum | 28 ++-- scripts/govulncheck-gate.mjs | 154 ++++++++++++++++++++++ security/govulncheck-exceptions.json | 10 ++ tests/govulncheck-gate-scope.test.ts | 91 +++++++++++++ tests/govulncheck-gate.test.ts | 119 +++++++++++++++++ tests/govulncheck-nightly.test.ts | 38 +++++- 9 files changed, 463 insertions(+), 53 deletions(-) create mode 100644 scripts/govulncheck-gate.mjs create mode 100644 security/govulncheck-exceptions.json create mode 100644 tests/govulncheck-gate-scope.test.ts create mode 100644 tests/govulncheck-gate.test.ts diff --git a/.github/workflows/govulncheck-nightly.yml b/.github/workflows/govulncheck-nightly.yml index 0941a862d..b3a9a050c 100644 --- a/.github/workflows/govulncheck-nightly.yml +++ b/.github/workflows/govulncheck-nightly.yml @@ -1,14 +1,21 @@ name: govulncheck nightly # A dated govulncheck exception (security/govulncheck-exceptions.json) must stop -# passing ON its expiry date, not whenever the next PR happens to touch the Go -# template. go-template.yml only runs on path-filtered events, so this nightly -# runs the same fail-closed gate over BOTH lane refs, the way +# passing ON its expiry date, not whenever the next PR happens to touch Go code. +# The PR-time workflows are path-filtered, so this nightly runs the same +# fail-closed gate over BOTH lane refs and EVERY Go module, the way # mutation-prover-nightly.yml does: a `schedule:` trigger only fires from the # default branch's copy of this file (main), so the integration/v2 content it must -# prove is reached by checking the ref out explicitly. A ref that carries no Go -# template (main, today) skips its steps by FILE PRESENCE, so the lane turns on by -# itself when the template lands there. +# prove is reached by checking the ref out explicitly. Each leg runs THAT ref's +# own gate script and exceptions file. +# +# Fail-closed: every leg requires its module, the gate and the exceptions file and +# FAILS when one is missing. The one exemption is templates/go on main, which has +# no Go template yet; that single leg skips by FILE PRESENCE and turns on by itself +# when the template lands there. The operator and gateway modules exist on both +# refs, so they can never skip. +# +# This file is kept byte-identical on main and integration/v2. on: schedule: @@ -20,7 +27,7 @@ permissions: jobs: govulncheck: - name: govulncheck gate (${{ matrix.ref }}) + name: govulncheck gate (${{ matrix.ref }}, ${{ matrix.module }}) runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -29,40 +36,43 @@ jobs: fail-fast: false matrix: ref: [main, integration/v2] + module: [templates/go, packages/kn-next-operator, packages/scale-zero-pg/gateway] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ matrix.ref }} - # The skip-by-file-presence below is for main, which carries no Go template. - # On integration/v2 the template, the gate and the exceptions MUST exist: a - # missing one means the checkout is wrong, and a skip there would be a green - # run that proved nothing. - - name: Require the Go template and gate on integration/v2 - if: ${{ matrix.ref == 'integration/v2' }} + # Everything but the main/templates/go leg MUST find its module, the gate and + # the exceptions file: a missing one means the checkout is wrong, and a skip + # there would be a green run that proved nothing. + - name: Require the module, the gate and the exceptions + if: ${{ !(matrix.ref == 'main' && matrix.module == 'templates/go') }} + env: + MODULE: ${{ matrix.module }} + REF: ${{ matrix.ref }} run: | set -euo pipefail - fail() { echo "::error::$1 is missing on integration/v2; refusing to pass without scanning"; exit 1; } - test -f templates/go/go.mod || fail templates/go/go.mod + fail() { echo "::error::$1 is missing on $REF; refusing to pass without scanning"; exit 1; } + test -f "$MODULE/go.mod" || fail "$MODULE/go.mod" test -f scripts/govulncheck-gate.mjs || fail scripts/govulncheck-gate.mjs test -f security/govulncheck-exceptions.json || fail security/govulncheck-exceptions.json - - name: Set up Go (templates/go/go.mod) - if: ${{ hashFiles('templates/go/go.mod') != '' }} + - name: Set up Go (${{ matrix.module }}/go.mod) + if: ${{ hashFiles(format('{0}/go.mod', matrix.module)) != '' }} uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version-file: templates/go/go.mod - cache-dependency-path: templates/go/go.sum + go-version-file: ${{ matrix.module }}/go.mod + cache-dependency-path: ${{ matrix.module }}/go.sum - name: Setup Node.js - if: ${{ hashFiles('templates/go/go.mod') != '' }} + if: ${{ hashFiles(format('{0}/go.mod', matrix.module)) != '' }} uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '24' - name: govulncheck (pinned) through the fail-closed gate - if: ${{ hashFiles('templates/go/go.mod') != '' }} - working-directory: templates/go + if: ${{ hashFiles(format('{0}/go.mod', matrix.module)) != '' }} + working-directory: ${{ matrix.module }} run: | set -euo pipefail - go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -format json ./... | node "$GITHUB_WORKSPACE/scripts/govulncheck-gate.mjs" + go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -format json ./... | node "$GITHUB_WORKSPACE/scripts/govulncheck-gate.mjs" "${{ matrix.module }}" diff --git a/packages/scale-zero-pg/gateway/Dockerfile b/packages/scale-zero-pg/gateway/Dockerfile index 3df71200a..bcb6c0a5a 100644 --- a/packages/scale-zero-pg/gateway/Dockerfile +++ b/packages/scale-zero-pg/gateway/Dockerfile @@ -1,5 +1,6 @@ # Multi-stage build: static Go binary -> distroless. -FROM golang:1.26 AS build +# golang:1.26.9 (digest-pinned; tests/golang-from-lockstep.test.ts keeps it equal to go.mod) +FROM golang:1.26.9@sha256:37cfcca63222ec499bec5e8ad7aad52dbf7529db36d5812b8eae2f0621aa92d6 AS build WORKDIR /src # Cache modules first. @@ -14,7 +15,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /gateway ./c && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /writer-autoscaler ./cmd/writer-autoscaler \ && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /zone-operator ./cmd/zone-operator -FROM gcr.io/distroless/static:nonroot +# gcr.io/distroless/static:nonroot +FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3 COPY --from=build /gateway /gateway COPY --from=build /alertsink /alertsink # pageserver auto-failover controller; the 58-pswatcher Deployment overrides diff --git a/packages/scale-zero-pg/gateway/go.mod b/packages/scale-zero-pg/gateway/go.mod index 6a0b58b7f..f060ce489 100644 --- a/packages/scale-zero-pg/gateway/go.mod +++ b/packages/scale-zero-pg/gateway/go.mod @@ -1,11 +1,11 @@ module github.com/alpheya/scale-zero-pg/gateway -go 1.26 +go 1.26.9 require ( github.com/lib/pq v1.10.9 github.com/robfig/cron/v3 v3.0.1 - golang.org/x/sync v0.19.0 + golang.org/x/sync v0.23.0 k8s.io/api v0.34.1 k8s.io/apimachinery v0.34.1 k8s.io/client-go v0.34.1 @@ -27,7 +27,7 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/moby/spdystream v0.5.0 // indirect + github.com/moby/spdystream v0.5.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -37,11 +37,11 @@ require ( github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.38.0 // indirect + golang.org/x/net v0.60.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect diff --git a/packages/scale-zero-pg/gateway/go.sum b/packages/scale-zero-pg/gateway/go.sum index 50428dcb8..e091739ec 100644 --- a/packages/scale-zero-pg/gateway/go.sum +++ b/packages/scale-zero-pg/gateway/go.sum @@ -50,8 +50,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= -github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= +github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -104,34 +104,34 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= -golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/scripts/govulncheck-gate.mjs b/scripts/govulncheck-gate.mjs new file mode 100644 index 000000000..9b35bdd60 --- /dev/null +++ b/scripts/govulncheck-gate.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +/** + * Fail-closed govulncheck gate. + * + * govulncheck -format json ./... | node scripts/govulncheck-gate.mjs + * + * govulncheck's JSON mode always exits 0, so this gate does the failing: any + * CALLED vulnerability (a finding whose trace names a function) fails unless + * security/govulncheck-exceptions.json carries a live entry for its id. An + * entry is `{ id, expires, justification }`; a malformed entry, an expired + * entry, and an entry whose vulnerability is no longer called all FAIL, so an + * exception cannot outlive its reason or sit around unread. + * + * One exceptions file serves every Go module, so an entry may carry an optional + * `modules: [dir, ...]` scope (module directories relative to the repo root). + * Omitted means every module. The module being scanned is the first argument + * (or GOVULNCHECK_MODULE) and is REQUIRED on the command line, so a step that + * forgets it fails rather than silently applying every entry. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ID = /^GO-\d{4}-\d{4,}$/; +const DATE = /^\d{4}-\d{2}-\d{2}$/; +const KEYS = new Set(['id', 'expires', 'justification', 'modules']); + +/** Split govulncheck's stream of concatenated JSON objects. Throws if truncated. */ +export function parseStream(text) { + const out = []; + let depth = 0; + let start = -1; + let inStr = false; + let esc = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (inStr) { + if (esc) esc = false; + else if (c === '\\') esc = true; + else if (c === '"') inStr = false; + continue; + } + if (c === '"') inStr = true; + else if (c === '{') { + if (depth === 0) start = i; + depth++; + } else if (c === '}') { + depth--; + if (depth < 0) throw new Error('govulncheck output: unbalanced }'); + if (depth === 0) out.push(JSON.parse(text.slice(start, i + 1))); + } else if (depth === 0 && !/\s/.test(c)) { + throw new Error(`govulncheck output: unexpected character ${JSON.stringify(c)}`); + } + } + if (depth !== 0 || inStr) throw new Error('govulncheck output: truncated JSON'); + return out; +} + +export function evaluate(messages, entries, now = new Date(), module = undefined) { + const errors = []; + // Fail closed on a crashed or truncated govulncheck: an empty stream must not + // read as "no vulnerabilities". A real run opens with `config` and reaches the + // call-graph stage before any finding is emitted. + if (!messages.some((m) => m?.config)) { + errors.push('govulncheck produced no config message (crashed or no output)'); + } + if ( + !messages.some((m) => + /Checking the code against the vulnerabilities/.test(m?.progress?.message ?? ''), + ) + ) { + errors.push('govulncheck did not complete a scan (no call-graph stage reached)'); + } + const called = new Set(); + for (const m of messages) { + const f = m?.finding; + if (f?.osv && Array.isArray(f.trace) && f.trace.some((t) => t?.function)) called.add(f.osv); + } + + const live = new Set(); + const today = now.toISOString().slice(0, 10); + for (const e of entries) { + const label = `exception ${JSON.stringify(e?.id)}`; + const extra = Object.keys(e ?? {}).filter((k) => !KEYS.has(k)); + if (extra.length) errors.push(`${label}: unknown key(s) ${extra.join(', ')}`); + if (typeof e?.id !== 'string' || !ID.test(e.id)) { + errors.push(`${label}: id must look like GO-YYYY-NNNN`); + continue; + } + if ( + typeof e.expires !== 'string' || + !DATE.test(e.expires) || + Number.isNaN(Date.parse(e.expires)) + ) { + errors.push(`${label}: expires must be an ISO date (YYYY-MM-DD)`); + continue; + } + if (typeof e.justification !== 'string' || e.justification.trim().length < 20) { + errors.push(`${label}: justification must be at least 20 characters`); + continue; + } + if (e.expires < today) { + errors.push(`${label}: expired on ${e.expires}; bump the toolchain and remove it`); + continue; + } + if (e.modules !== undefined) { + if ( + !Array.isArray(e.modules) || + e.modules.length === 0 || + e.modules.some((m) => typeof m !== 'string' || m.trim() === '') + ) { + errors.push(`${label}: modules must be a non-empty array of module directories`); + continue; + } + // Out of scope for the module being scanned: it neither covers a finding nor + // can it be stale here (the module it names is scanned in its own leg). + if (module !== undefined && !e.modules.includes(module)) continue; + } + if (!called.has(e.id)) { + errors.push(`${label}: stale, ${e.id} is no longer called; remove the entry`); + continue; + } + live.add(e.id); + } + + for (const id of called) { + if (!live.has(id)) errors.push(`${id}: called vulnerability with no live exception`); + } + return { ok: errors.length === 0, errors, called: [...called] }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const module = process.argv[2] ?? process.env.GOVULNCHECK_MODULE; + if (!module) { + console.error( + 'govulncheck-gate: the module is required (argument or GOVULNCHECK_MODULE), e.g. templates/go', + ); + process.exit(1); + } + const file = resolve(import.meta.dirname, '../security/govulncheck-exceptions.json'); + const entries = JSON.parse(readFileSync(file, 'utf8')).allow; + if (!Array.isArray(entries)) throw new Error(`${file}: "allow" must be an array`); + // Async chunked read: a ~600 KB pipe through readFileSync(0) aborted node + // ("malloc(): invalid size") on the CI runner. + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const input = Buffer.concat(chunks).toString('utf8'); + const result = evaluate(parseStream(input), entries, new Date(), module); + if (!result.ok) { + for (const e of result.errors) console.error(`govulncheck-gate: ${e}`); + process.exit(1); + } + console.log(`govulncheck-gate: ok (called and excepted: ${result.called.join(', ') || 'none'})`); +} diff --git a/security/govulncheck-exceptions.json b/security/govulncheck-exceptions.json new file mode 100644 index 000000000..ef7068652 --- /dev/null +++ b/security/govulncheck-exceptions.json @@ -0,0 +1,10 @@ +{ + "$schema-note": "Dated exceptions for scripts/govulncheck-gate.mjs. A called Go vulnerability passes only through a live entry here. Each entry is { id (GO-YYYY-NNNN), expires (ISO date), justification (at least 20 characters) }; unknown keys, an expired entry, and an entry whose vulnerability is no longer called all FAIL the gate.", + "allow": [ + { + "id": "GO-2026-6809", + "expires": "2026-10-25", + "justification": "crypto/x509, fixed upstream in go1.26.10 (published 2026-10-21T02:17Z), held back by the 3-day release-age rule until 2026-10-24; the bump to 1.26.10 is tracked in #2567." + } + ] +} diff --git a/tests/govulncheck-gate-scope.test.ts b/tests/govulncheck-gate-scope.test.ts new file mode 100644 index 000000000..3ac1271c5 --- /dev/null +++ b/tests/govulncheck-gate-scope.test.ts @@ -0,0 +1,91 @@ +/** + * govulncheck exceptions are scoped per module. One exceptions file is shared by + * every Go module, and the gate fails an entry the scanned module does not call + * ("stale"), so an advisory only one module reaches needs a `modules` scope or it + * would red every other module's scan. An entry with no `modules` applies to all. + */ +import { describe, expect, it } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { evaluate } from '../scripts/govulncheck-gate.mjs'; + +const REPO_ROOT = resolve(import.meta.dirname, '..'); +const NOW = new Date('2026-10-22T00:00:00Z'); +const JUST = + 'fixed upstream in a later release, held back by the release-age rule, tracked in a ticket'; +const config = { config: { protocol_version: 'v1.0.0' } }; +const scanned = { progress: { message: 'Checking the code against the vulnerabilities...' } }; +const DONE = [config, scanned]; +const entry = (over: Record = {}) => ({ + id: 'GO-2026-6809', + expires: '2026-10-25', + justification: JUST, + ...over, +}); +const called = (id: string) => ({ finding: { osv: id, trace: [{ function: 'Serve' }] } }); +const OP = 'packages/kn-next-operator'; +const GW = 'packages/scale-zero-pg/gateway'; + +describe('exceptions are scoped per module', () => { + it('an operator-scoped entry does not make the gateway scan stale', () => { + const scoped = entry({ id: 'GO-2026-7000', modules: [OP] }); + expect(evaluate([...DONE, called('GO-2026-6809')], [entry(), scoped], NOW, GW).ok).toBe(true); + const opScan = [...DONE, called('GO-2026-6809'), called('GO-2026-7000')]; + expect(evaluate(opScan, [entry(), scoped], NOW, OP).ok).toBe(true); + }); + + it('an out-of-scope entry does not excuse a finding', () => { + const r = evaluate([...DONE, called('GO-2026-6809')], [entry({ modules: [OP] })], NOW, GW); + expect(r.ok).toBe(false); + expect(r.errors.join('\n')).toContain('no live exception'); + }); + + it('an in-scope entry that is no longer called is still stale', () => { + const r = evaluate(DONE, [entry({ modules: [OP] })], NOW, OP); + expect(r.ok).toBe(false); + expect(r.errors.join('\n')).toMatch(/stale/); + }); + + it('an entry without modules applies to every module', () => { + for (const m of [OP, GW, 'templates/go']) { + expect(evaluate([...DONE, called('GO-2026-6809')], [entry()], NOW, m).ok).toBe(true); + expect(evaluate(DONE, [entry()], NOW, m).ok).toBe(false); + } + }); + + it('a malformed modules scope fails', () => { + for (const modules of [[], 'templates/go', [''], [1]]) { + const r = evaluate([...DONE, called('GO-2026-6809')], [entry({ modules })], NOW, OP); + expect(r.ok, JSON.stringify(modules)).toBe(false); + } + }); + + it('the CLI refuses to run without the module being scanned', () => { + const run = (args: string[], env: Record = {}) => + spawnSync('node', [resolve(REPO_ROOT, 'scripts/govulncheck-gate.mjs'), ...args], { + input: `${JSON.stringify(config)}\n${JSON.stringify(scanned)}\n`, + encoding: 'utf8', + env: { PATH: process.env.PATH ?? '', ...env }, + }); + const none = run([]); + expect(none.status).not.toBe(0); + expect(none.stderr).toMatch(/module is required/); + // A completed scan with no findings: the committed entry is stale for it, so + // it fails on the entry, NOT on the missing module. + expect(run(['templates/go']).stderr).not.toMatch(/module is required/); + expect(run([], { GOVULNCHECK_MODULE: 'templates/go' }).stderr).not.toMatch( + /module is required/, + ); + }); +}); + +describe('the nightly passes the module being scanned', () => { + it('pipes the gate the matrix module', () => { + const wf = readFileSync( + resolve(REPO_ROOT, '.github/workflows/govulncheck-nightly.yml'), + 'utf8', + ); + expect(wf).toMatch(/govulncheck-gate\.mjs" "\$\{\{ matrix\.module \}\}"\s*$/m); + }); +}); diff --git a/tests/govulncheck-gate.test.ts b/tests/govulncheck-gate.test.ts new file mode 100644 index 000000000..294fa727e --- /dev/null +++ b/tests/govulncheck-gate.test.ts @@ -0,0 +1,119 @@ +/** + * govulncheck exceptions are explicit, dated and fail closed: a called + * vulnerability passes only through a live entry in + * security/govulncheck-exceptions.json; an expired, malformed or no-longer- + * reachable entry fails the gate, so an exception cannot outlive its reason. + */ +import { describe, expect, it } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { evaluate, parseStream } from '../scripts/govulncheck-gate.mjs'; + +const REPO_ROOT = resolve(import.meta.dirname, '..'); +const NOW = new Date('2026-10-22T00:00:00Z'); +const JUST = + 'fixed upstream in go1.26.10, held back by the release-age rule until 2026-10-24, tracked in #2567'; +const config = { config: { protocol_version: 'v1.0.0' } }; +const scanned = { progress: { message: 'Checking the code against the vulnerabilities...' } }; +const DONE = [config, scanned]; +const entry = (over: Record = {}) => ({ + id: 'GO-2026-6809', + expires: '2026-10-25', + justification: JUST, + ...over, +}); +const called = (id: string) => ({ finding: { osv: id, trace: [{ function: 'Serve' }] } }); +const importOnly = (id: string) => ({ finding: { osv: id, trace: [{ module: 'stdlib' }] } }); + +describe('govulncheck gate', () => { + it('fails an un-excepted called vulnerability', () => { + const r = evaluate([...DONE, called('GO-2026-6809')], [], NOW); + expect(r.ok).toBe(false); + expect(r.errors.join('\n')).toContain('GO-2026-6809'); + }); + + it('ignores findings that are not called symbols', () => { + expect(evaluate([...DONE, importOnly('GO-2026-1')], [], NOW).ok).toBe(true); + }); + + it('passes a called vulnerability covered by a live entry', () => { + expect(evaluate([...DONE, called('GO-2026-6809')], [entry()], NOW).ok).toBe(true); + }); + + it('fails once the entry has expired', () => { + const r = evaluate( + [...DONE, called('GO-2026-6809')], + [entry()], + new Date('2026-10-26T00:00:00Z'), + ); + expect(r.ok).toBe(false); + expect(r.errors.join('\n')).toMatch(/expired/); + }); + + it('fails a stale entry whose vulnerability is no longer called', () => { + const r = evaluate(DONE, [entry()], NOW); + expect(r.ok).toBe(false); + expect(r.errors.join('\n')).toMatch(/no longer|stale/i); + }); + + it('fails malformed entries: bad id, bad date, short justification, unknown key', () => { + for (const bad of [ + entry({ id: 'CVE-2026-1' }), + entry({ expires: 'soon' }), + entry({ expires: undefined }), + entry({ justification: 'later' }), + entry({ extra: 1 }), + ]) { + expect(evaluate([...DONE, called('GO-2026-6809')], [bad], NOW).ok).toBe(false); + } + }); + + it("parses govulncheck's concatenated pretty-printed JSON stream, braces in strings included", () => { + const s = '{\n "config": {"x": "a}b"}\n}\n{\n "finding": {"osv": "GO-1"}\n}\n'; + expect(parseStream(s)).toEqual([{ config: { x: 'a}b' } }, { finding: { osv: 'GO-1' } }]); + }); + + it('fails closed on an unparseable stream', () => { + expect(() => parseStream('{ "finding": ')).toThrow(); + }); +}); + +describe('the gate fails closed when govulncheck did not finish a scan', () => { + it('fails on an empty stream with an empty allow list', () => { + const r = evaluate([], [], NOW); + expect(r.ok).toBe(false); + expect(r.errors.join('\n')).toMatch(/config/); + }); + + it('fails on a config-only stream with an empty allow list', () => { + const r = evaluate([config], [], NOW); + expect(r.ok).toBe(false); + expect(r.errors.join('\n')).toMatch(/scan/); + }); + + it('fails when the scan never reached the call-graph stage even with findings', () => { + expect(evaluate([config, called('GO-2026-6809')], [entry()], NOW).ok).toBe(false); + }); + + it('fails when only an earlier progress stage was reached', () => { + const early = { progress: { message: 'Fetching vulnerabilities from the database...' } }; + expect(evaluate([config, early], [], NOW).ok).toBe(false); + }); + + it('passes a completed scan with no called findings and no exceptions', () => { + expect(evaluate([config, scanned], [], NOW).ok).toBe(true); + }); +}); + +describe('committed exceptions file and workflow wiring', () => { + const file = JSON.parse( + readFileSync(resolve(REPO_ROOT, 'security/govulncheck-exceptions.json'), 'utf8'), + ); + + it('carries the GO-2026-6809 entry expiring 2026-10-25 and no other', () => { + expect(file.allow).toHaveLength(1); + expect(file.allow[0].id).toBe('GO-2026-6809'); + expect(file.allow[0].expires).toBe('2026-10-25'); + expect(file.allow[0].justification).toContain('#2567'); + }); +}); diff --git a/tests/govulncheck-nightly.test.ts b/tests/govulncheck-nightly.test.ts index fba42fb2a..ebad64203 100644 --- a/tests/govulncheck-nightly.test.ts +++ b/tests/govulncheck-nightly.test.ts @@ -62,17 +62,39 @@ describe('govulncheck-nightly.yml', () => { } }); - it('FAILS on integration/v2 when the template, gate or exceptions are missing', () => { - const idx = job.steps.findIndex((s) => s.if?.includes("matrix.ref == 'integration/v2'")); - expect(idx, 'a step gated on the integration/v2 leg').toBeGreaterThan(-1); + it('scans EVERY Go module, on every ref', () => { + expect(job.strategy.matrix.module).toEqual([ + 'templates/go', + 'packages/kn-next-operator', + 'packages/scale-zero-pg/gateway', + ]); + }); + + it('the cron is unchanged', () => { + expect(wf.on.schedule).toEqual([{ cron: '13 2 * * *' }]); + }); + + it('passes the module to the gate', () => { + const gate = job.steps.find((s) => s.run?.includes('govulncheck@')); + expect(gate?.run).toMatch(/govulncheck-gate\.mjs" "\$\{\{ matrix\.module \}\}"/); + expect(gate?.['working-directory']).toBe('${{ matrix.module }}'); + }); + + it('FAILS on every leg but main/templates/go when the module, gate or exceptions are missing', () => { + const idx = job.steps.findIndex((s) => s.run?.includes('fail()')); + expect(idx, 'a require step').toBeGreaterThan(-1); const step = job.steps[idx]; + // Only the main/templates/go leg is exempt: the operator and gateway exist on + // main, so a hashFiles skip there would be a silent pass. + expect(step.if).toContain("matrix.ref == 'main' && matrix.module == 'templates/go'"); + expect(step.if).toMatch(/!\(/); expect(step.run).toContain('set -euo pipefail'); for (const f of [ - 'templates/go/go.mod', + '"$MODULE/go.mod"', 'scripts/govulncheck-gate.mjs', 'security/govulncheck-exceptions.json', ]) { - expect(step.run, f).toMatch(new RegExp(`test -f ${f.replaceAll('.', '\\.')} \\|\\| fail`)); + expect(step.run, f).toContain(`test -f ${f} || fail`); } expect(step.run).toMatch(/fail\(\) \{[^}]*exit 1/); // It runs before any step that is skipped by file presence. @@ -80,9 +102,11 @@ describe('govulncheck-nightly.yml', () => { expect(idx).toBeLessThan(firstSkippable); }); - it('skips the main leg by file presence, never the v2 leg', () => { + it('skips by file presence only on the module being scanned, and only after the require step', () => { const skippable = job.steps.filter((s) => s.if?.includes('hashFiles')); expect(skippable.length).toBeGreaterThanOrEqual(3); - for (const s of skippable) expect(s.if).toContain("hashFiles('templates/go/go.mod') != ''"); + for (const s of skippable) { + expect(s.if).toContain("hashFiles(format('{0}/go.mod', matrix.module)) != ''"); + } }); }); From a4de669f20185d03b6882832603f2397b7e85794 Mon Sep 17 00:00:00 2001 From: AhmedElBanna80 Date: Thu, 22 Oct 2026 17:49:14 +0200 Subject: [PATCH 2/2] ci(security): PR-time govulncheck for operator and gateway on main, typecheck fixes, reject unscanned exception scopes (#2569) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0198oVxWkEEDJJnVhES6ULnx --- .github/workflows/govulncheck-modules.yml | 82 ++++++++++++ scripts/govulncheck-gate.mjs | 63 ++++++++- security/govulncheck-modules.json | 9 ++ tests/govulncheck-all-modules.test.ts | 129 +++++++++++++++++++ tests/govulncheck-gate-known-modules.test.ts | 48 +++++++ tests/govulncheck-nightly.test.ts | 3 +- 6 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/govulncheck-modules.yml create mode 100644 security/govulncheck-modules.json create mode 100644 tests/govulncheck-all-modules.test.ts create mode 100644 tests/govulncheck-gate-known-modules.test.ts diff --git a/.github/workflows/govulncheck-modules.yml b/.github/workflows/govulncheck-modules.yml new file mode 100644 index 000000000..ebd1cd33b --- /dev/null +++ b/.github/workflows/govulncheck-modules.yml @@ -0,0 +1,82 @@ +name: govulncheck (Go modules) + +# Every Go module the repo ships is scanned by govulncheck through the same +# fail-closed gate (scripts/govulncheck-gate.mjs + security/govulncheck-exceptions.json) +# at the same pinned version. templates/go is scanned in go-template.yml; this +# workflow covers the other shipped modules. tests/govulncheck-all-modules.test.ts +# fails when a go.mod exists that is neither gated in CI nor listed as excluded +# (with a reason) in security/govulncheck-modules.json. +# +# The dated-exception nightly (govulncheck-nightly.yml) scans the same modules +# over both lane refs, so an exception expires ON its date. That file is kept +# byte-identical with main's copy. + +on: + pull_request: + paths: + - 'packages/kn-next-operator/**' + - 'packages/scale-zero-pg/gateway/**' + - '.github/workflows/govulncheck-modules.yml' + - 'scripts/govulncheck-gate.mjs' + - 'security/govulncheck-exceptions.json' + - 'security/govulncheck-modules.json' + - 'tests/govulncheck-all-modules.test.ts' + - 'tests/govulncheck-gate.test.ts' + - 'tests/govulncheck-gate-scope.test.ts' + - 'tests/govulncheck-gate-known-modules.test.ts' + push: + branches: [main, 'integration/**'] + paths: + - 'packages/kn-next-operator/**' + - 'packages/scale-zero-pg/gateway/**' + - '.github/workflows/govulncheck-modules.yml' + - 'scripts/govulncheck-gate.mjs' + - 'security/govulncheck-exceptions.json' + - 'security/govulncheck-modules.json' + - 'tests/govulncheck-all-modules.test.ts' + - 'tests/govulncheck-gate.test.ts' + - 'tests/govulncheck-gate-scope.test.ts' + - 'tests/govulncheck-gate-known-modules.test.ts' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: govulncheck-modules-${{ github.ref }} + cancel-in-progress: true + +jobs: + govulncheck: + name: govulncheck gate (${{ matrix.module }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + module: + - packages/kn-next-operator + - packages/scale-zero-pg/gateway + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Go (${{ matrix.module }}/go.mod) + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: ${{ matrix.module }}/go.mod + cache-dependency-path: ${{ matrix.module }}/go.sum + + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + + - name: govulncheck (pinned) through the fail-closed gate + working-directory: ${{ matrix.module }} + # JSON mode exits 0 on findings; the gate does the failing, and only + # through a dated entry in security/govulncheck-exceptions.json. + run: | + set -euo pipefail + go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -format json ./... | node "$GITHUB_WORKSPACE/scripts/govulncheck-gate.mjs" "${{ matrix.module }}" diff --git a/scripts/govulncheck-gate.mjs b/scripts/govulncheck-gate.mjs index 9b35bdd60..5ae283c19 100644 --- a/scripts/govulncheck-gate.mjs +++ b/scripts/govulncheck-gate.mjs @@ -17,8 +17,8 @@ * (or GOVULNCHECK_MODULE) and is REQUIRED on the command line, so a step that * forgets it fails rather than silently applying every entry. */ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; +import { posix, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const ID = /^GO-\d{4}-\d{4,}$/; @@ -56,7 +56,49 @@ export function parseStream(text) { return out; } -export function evaluate(messages, entries, now = new Date(), module = undefined) { +/** + * The modules an exception may be scoped to: canonical repo-relative directories + * (no `./`, trailing slash or `..`) that hold a go.mod and are not listed as + * excluded from the scan in security/govulncheck-modules.json (absent on a ref + * that has no exclusions). + * @param {string} root repo root + * @returns {{ has: (m: string) => boolean }} + */ +export function knownModules(root) { + const excludedFile = resolve(root, 'security/govulncheck-modules.json'); + const excluded = new Set( + existsSync(excludedFile) + ? (JSON.parse(readFileSync(excludedFile, 'utf8')).excluded ?? []).map((x) => x.path) + : [], + ); + return { + has: (m) => + typeof m === 'string' && + m !== '' && + posix.normalize(m) === m && + !m.startsWith('/') && + !m.endsWith('/') && + !m.startsWith('../') && + m !== '.' && + !excluded.has(m) && + existsSync(resolve(root, m, 'go.mod')), + }; +} + +/** + * @param {unknown[]} messages + * @param {unknown[]} entries + * @param {Date} [now] + * @param {string} [module] the module being scanned (repo-relative directory) + * @param {{ has: (m: string) => boolean }} [known] when given, scope values must be in it + */ +export function evaluate( + messages, + entries, + now = new Date(), + module = undefined, + known = undefined, +) { const errors = []; // Fail closed on a crashed or truncated govulncheck: an empty stream must not // read as "no vulnerabilities". A real run opens with `config` and reaches the @@ -112,6 +154,13 @@ export function evaluate(messages, entries, now = new Date(), module = undefined errors.push(`${label}: modules must be a non-empty array of module directories`); continue; } + const unknown = known ? e.modules.filter((m) => !known.has(m)) : []; + if (unknown.length) { + errors.push( + `${label}: modules ${unknown.map((m) => JSON.stringify(m)).join(', ')} is not a scanned module (use the exact repo-relative directory, no ./ or trailing slash)`, + ); + continue; + } // Out of scope for the module being scanned: it neither covers a finding nor // can it be stale here (the module it names is scanned in its own leg). if (module !== undefined && !e.modules.includes(module)) continue; @@ -145,7 +194,13 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); const input = Buffer.concat(chunks).toString('utf8'); - const result = evaluate(parseStream(input), entries, new Date(), module); + const result = evaluate( + parseStream(input), + entries, + new Date(), + module, + knownModules(resolve(import.meta.dirname, '..')), + ); if (!result.ok) { for (const e of result.errors) console.error(`govulncheck-gate: ${e}`); process.exit(1); diff --git a/security/govulncheck-modules.json b/security/govulncheck-modules.json new file mode 100644 index 000000000..42d1bac7a --- /dev/null +++ b/security/govulncheck-modules.json @@ -0,0 +1,9 @@ +{ + "$schema-note": "Go modules that are NOT govulncheck-gated in CI. tests/govulncheck-all-modules.test.ts requires every go.mod in the repo to be either gated in a workflow or listed here with a reason (at least 20 characters).", + "excluded": [ + { + "path": "spikes/zone-functions-coldstart/fn-go", + "reason": "Throwaway cold-start benchmark spike, never shipped or released, pinned to go 1.26.0 whose stdlib carries eight called advisories (measured 2026-10-22). Bumping a spike toolchain is not worth a gate." + } + ] +} diff --git a/tests/govulncheck-all-modules.test.ts b/tests/govulncheck-all-modules.test.ts new file mode 100644 index 000000000..0f8d547cd --- /dev/null +++ b/tests/govulncheck-all-modules.test.ts @@ -0,0 +1,129 @@ +/** + * Every go.mod in the repo is either govulncheck-gated in CI (through the + * fail-closed gate, at the one pinned version, under pipefail, on PRs and pushes + * touching the module) or listed in security/govulncheck-modules.json with a + * reason. A new, unlisted module fails here, so the scan can't silently cover + * only templates/go again. + */ +import { describe, expect, it } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import { readdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parse } from 'yaml'; + +const REPO_ROOT = resolve(import.meta.dirname, '..'); +const WF_DIR = resolve(REPO_ROOT, '.github/workflows'); + +const modules = execFileSync('git', ['ls-files', '--', '*go.mod'], { + cwd: REPO_ROOT, + encoding: 'utf8', +}) + .split('\n') + .filter((p) => p && /(^|\/)go\.mod$/.test(p) && !/(^|\/)(vendor|testdata|node_modules)\//.test(p)) + .map((p) => p.replace(/\/?go\.mod$/, '') || '.'); + +const excluded: { path: string; reason: string }[] = JSON.parse( + readFileSync(resolve(REPO_ROOT, 'security/govulncheck-modules.json'), 'utf8'), +).excluded; + +type Gated = { file: string; dir: string; wf: any; step: any }; + +function gatedModules(): Gated[] { + const out: Gated[] = []; + for (const file of readdirSync(WF_DIR).filter((f) => /\.ya?ml$/.test(f))) { + const wf = parse(readFileSync(resolve(WF_DIR, file), 'utf8')); + for (const job of Object.values(wf.jobs ?? {})) { + for (const step of job.steps ?? []) { + if (!step.run?.includes('govulncheck-gate.mjs') || !step.run.includes('govulncheck@')) + continue; + const wd: string = step['working-directory'] ?? '.'; + const dirs = wd.includes('${{ matrix.module }}') + ? (job.strategy?.matrix?.module ?? []) + : [wd]; + for (const dir of dirs) out.push({ file, dir, wf, step }); + } + } + } + return out; +} + +const NIGHTLY = 'govulncheck-nightly.yml'; +const allGated = gatedModules(); +// A schedule-only run is not PR-time coverage: a module must be gated by at least +// one PR/push workflow that is NOT the nightly. +const gated = allGated.filter((g) => g.file !== NIGHTLY); + +describe('govulncheck covers every Go module', () => { + it('finds the known modules (the scan itself is not vacuous)', () => { + expect(modules).toContain('packages/kn-next-operator'); + expect(modules).toContain('packages/scale-zero-pg/gateway'); + }); + + it('every go.mod is gated in CI or excluded with a reason', () => { + const gatedDirs = new Set(gated.map((g) => g.dir)); + const excludedDirs = new Set(excluded.map((e) => e.path)); + const missing = modules.filter((m) => !gatedDirs.has(m) && !excludedDirs.has(m)); + expect(missing).toEqual([]); + }); + + it('a module is not both gated and excluded, and no entry is stale', () => { + const gatedDirs = new Set(gated.map((g) => g.dir)); + for (const e of excluded) { + expect(gatedDirs.has(e.path), `${e.path} is gated AND excluded`).toBe(false); + expect(modules, `${e.path} is excluded but has no go.mod`).toContain(e.path); + expect(e.reason.length).toBeGreaterThanOrEqual(20); + } + }); + + it('every gated directory really is a module', () => { + for (const g of gated) expect(modules, `${g.file}: ${g.dir}`).toContain(g.dir); + }); + + it('each gated step is pipefail, fail-closed, and on one pinned version', () => { + const versions = new Set(); + for (const { file, step } of gated) { + expect(step.run, file).toContain('set -euo pipefail'); + expect(step.run, file).toMatch( + /govulncheck@v\d+\.\d+\.\d+ -format json \.\/\.\.\. \| node [^\n]*govulncheck-gate\.mjs/, + ); + versions.add(step.run.match(/govulncheck@(v\d+\.\d+\.\d+)/)[1]); + } + expect([...versions]).toEqual(['v1.9.0']); + }); + + it('every gated module is scanned on PRs and pushes that touch it', () => { + for (const { file, dir, wf } of gated) { + for (const ev of ['pull_request', 'push']) { + const paths: string[] = wf.on?.[ev]?.paths ?? []; + // A schedule-only workflow (the nightly) has no path filter; accept it + // only if it has no path filter on this event either. + const unfiltered = wf.on?.[ev] && !wf.on[ev].paths; + const covers = unfiltered || paths.some((p) => p === `${dir}/**`); + expect(covers, `${file} ${ev} must cover ${dir}/**`).toBe(true); + } + } + }); + + it('the nightly scans every gated module too, so an exception expires on its date', () => { + const nightly = new Set(allGated.filter((g) => g.file === NIGHTLY).map((g) => g.dir)); + for (const g of gated) expect(nightly.has(g.dir), `nightly must scan ${g.dir}`).toBe(true); + }); + + it('GO-2026-6809 is excepted for the shared gate (covers every module)', () => { + const ex = JSON.parse( + readFileSync(resolve(REPO_ROOT, 'security/govulncheck-exceptions.json'), 'utf8'), + ); + expect(ex.allow.map((a: any) => a.id)).toContain('GO-2026-6809'); + }); + + it('the new workflow holds read-only permissions and SHA-pinned actions', () => { + const text = readFileSync(resolve(WF_DIR, 'govulncheck-modules.yml'), 'utf8'); + expect(parse(text).permissions).toEqual({ contents: 'read' }); + const uses = [...text.matchAll(/^\s*-?\s*uses:\s*(\S+)(.*)$/gm)]; + expect(uses.length).toBeGreaterThan(0); + for (const [, ref, rest] of uses) { + expect(ref).toMatch(/^[\w.-]+\/[\w./-]+@[0-9a-f]{40}$/); + expect(rest).toMatch(/#\s*v\d/); + } + }); +}); diff --git a/tests/govulncheck-gate-known-modules.test.ts b/tests/govulncheck-gate-known-modules.test.ts new file mode 100644 index 000000000..bbc46ea38 --- /dev/null +++ b/tests/govulncheck-gate-known-modules.test.ts @@ -0,0 +1,48 @@ +/** + * An exception scoped to a module that is never scanned is skipped in every leg, + * so it is never stale and lingers forever. The gate therefore rejects any + * `modules` value that is not a known, scanned module, in its canonical spelling. + */ +import { describe, expect, it } from 'bun:test'; +import { resolve } from 'node:path'; +import { evaluate, knownModules } from '../scripts/govulncheck-gate.mjs'; + +const REPO_ROOT = resolve(import.meta.dirname, '..'); +const NOW = new Date('2026-10-22T00:00:00Z'); +const config = { config: { protocol_version: 'v1.0.0' } }; +const scanned = { progress: { message: 'Checking the code against the vulnerabilities...' } }; +const called = { finding: { osv: 'GO-2026-6809', trace: [{ function: 'Serve' }] } }; +const OP = 'packages/kn-next-operator'; +const entry = (modules: unknown) => ({ + id: 'GO-2026-6809', + expires: '2026-10-25', + justification: 'held back by the release-age rule until the fixed toolchain is allowed in', + modules, +}); +const known = knownModules(REPO_ROOT); +const run = (modules: unknown) => + evaluate([config, scanned, called], [entry(modules)], NOW, OP, known); + +describe('scope values must be known scanned modules', () => { + it('accepts the canonical spelling of a scanned module', () => { + expect(run([OP]).ok).toBe(true); + }); + + it('rejects a trailing slash, a ./ prefix and other non-canonical spellings', () => { + for (const bad of [`${OP}/`, `./${OP}`, `${OP}/.`, `../${OP}`, `/${OP}`]) { + const r = run([OP, bad]); + expect(r.ok, bad).toBe(false); + expect(r.errors.join('\n'), bad).toMatch(/not a scanned module/); + } + }); + + it('rejects a module that does not exist or has no go.mod', () => { + for (const bad of ['packages/nope', 'packages', 'docs']) { + expect(run([OP, bad]).ok, bad).toBe(false); + } + }); + + it('rejects a module the repo lists as excluded from the scan', () => { + expect(knownModules(REPO_ROOT).has('spikes/zone-functions-coldstart/fn-go')).toBe(false); + }); +}); diff --git a/tests/govulncheck-nightly.test.ts b/tests/govulncheck-nightly.test.ts index ebad64203..d368bc0c1 100644 --- a/tests/govulncheck-nightly.test.ts +++ b/tests/govulncheck-nightly.test.ts @@ -23,9 +23,10 @@ type Step = { uses?: string; run?: string; with?: Record; + 'working-directory'?: string; }; const job = Object.values(wf.jobs)[0] as { - strategy: { matrix: { ref: string[] }; 'fail-fast': boolean }; + strategy: { matrix: { ref: string[]; module: string[] }; 'fail-fast': boolean }; steps: Step[]; };