From 46cbf54570e0e7c38e674afefbdcc05566d2383f Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Tue, 20 Oct 2026 16:24:09 +0100 Subject: [PATCH] ci: single-source the gosec version in .gosec-version The gosec version was hard-coded in ci.yml, pre-commit.yml, the Makefile and scripts/gosec-hook.sh, plus comments that restated it. A bump that missed one site would leave local scans and CI's SARIF upload on different gosec rule sets. - Add .gosec-version (v2.28.0, unchanged) as the only place the version is written. ci.yml's Security Scanning job, the Makefile and the pre-commit hook read it; the hook validates it and stops with exit 2 on a missing or malformed file. - Delete pre-commit.yml's gosec install step and drop gosec from its tool-cache key. The gosec hook is SKIPped in that job and installs its own copy under ~/.cache anyway, so ~/go/bin/gosec was never run. - Fix the stale comments: pre-commit.yml no longer claims to track a securego/gosec Action, and the Makefile no longer claims sync with database-migration.yml, which pins none of these tools. - security-scan.sh no longer suggests installing gosec@latest. - Add scripts/check-gosec-pin.sh, run in the Security Scanning job: it fails on a gosec version literal or a non-variable gosec ref in any tracked file, and on an install site that does not read .gosec-version. scripts/test-check-gosec-pin.sh covers both directions and asserts that the Makefile and the hook really read the file. Closes #347 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 15 +- .github/workflows/pre-commit.yml | 42 ++--- .gosec-version | 1 + .pre-commit-config.yaml | 4 +- Makefile | 8 +- scripts/check-gosec-pin.sh | 148 ++++++++++++++++ scripts/gosec-hook.sh | 43 +++-- scripts/security-scan.sh | 2 +- scripts/test-check-gosec-pin.sh | 282 +++++++++++++++++++++++++++++++ 9 files changed, 507 insertions(+), 38 deletions(-) create mode 100644 .gosec-version create mode 100755 scripts/check-gosec-pin.sh create mode 100755 scripts/test-check-gosec-pin.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b1e8106e..25a62166c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -706,6 +706,16 @@ jobs: with: go-version: ${{ env.GO_VERSION }} + - name: Assert the gosec version is pinned only in .gosec-version + # Issue #347: the version used to be hard-coded in four files, so a + # bump that missed one left local scans and this job's SARIF upload on + # different gosec rule sets. Fast (git grep only). always(): a finding + # here must not skip the scanners below; the job still fails. + if: always() && steps.checkout.outcome == 'success' + run: | + bash scripts/check-gosec-pin.sh + bash scripts/test-check-gosec-pin.sh + - name: Run govulncheck CVE scanner (all modules) # always(): each scanner step below is independent evidence for the # Security tab. Without this, one scanner failing (e.g. a live npm @@ -749,7 +759,10 @@ jobs: # Install pinned gosec using the job's existing setup-go. # The securego/gosec Docker action bundles its own Go toolchain which # cannot satisfy the module's go directive, causing a toolchain mismatch. - go install github.com/securego/gosec/v2/cmd/gosec@v2.28.0 + # The version lives only in .gosec-version (issue #347), shared with + # the Makefile and scripts/gosec-hook.sh. + gosec_version="$(cat .gosec-version)" + go install "github.com/securego/gosec/v2/cmd/gosec@${gosec_version}" # Collect SARIF for both platform-owned modules before uploading. # # gosec exits non-zero both for real findings and for a processing diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index e4e924e13..d05ba90ea 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -121,27 +121,26 @@ jobs: echo "tflint --init failed after 6 attempts — GitHub release API likely unavailable" >&2 exit 1 - # Cache the installed tool binaries (gosec, gocyclo). Keyed on the - # pinned version strings so a tool-version bump still triggers a - # fresh install. Both binaries land in ~/go/bin which setup-go@v6 - # already adds to PATH. Restored BEFORE the install steps so the - # `if: cache-hit != 'true'` guards below can short-circuit them on - # cache-hit runs (the `go install` invocations cost ~3-5s each - # even when the module cache is warm; skipping them on cache-hit - # is worth the extra `if`). - - name: Cache Go-installed tools (gosec, gocyclo) + # Cache the installed tool binaries (gocyclo). Keyed on the pinned + # version string so a tool-version bump still triggers a fresh + # install. The binary lands in ~/go/bin which setup-go@v6 already + # adds to PATH. Restored BEFORE the install step so the + # `if: cache-hit != 'true'` guard below can short-circuit it on + # cache-hit runs (the `go install` invocation costs ~3-5s even + # when the module cache is warm; skipping it on cache-hit is worth + # the extra `if`). + # + # gosec is deliberately NOT installed here (issue #347): the `gosec` + # hook is SKIPped in this job (see the pre-commit step below), and + # the hook installs its own copy under ~/.cache/pre-commit-gosec + # anyway, so a ~/go/bin/gosec was never run. ci.yml's Security + # Scanning job is the gosec gate. + - name: Cache Go-installed tools (gocyclo) id: cache-go-tools uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/go/bin - key: go-tools-${{ runner.os }}-gosec-v2.28.0-gocyclo-v0.6.0 - - - name: Install gosec - if: steps.cache-go-tools.outputs.cache-hit != 'true' - # Pinned to the same version ci.yml's `securego/gosec` Action uses, - # so an upstream gosec release with rule changes can't silently - # downgrade the gate between the two workflows. - run: go install github.com/securego/gosec/v2/cmd/gosec@v2.28.0 + key: go-tools-${{ runner.os }}-gocyclo-v0.6.0 - name: Install gocyclo if: steps.cache-go-tools.outputs.cache-hit != 'true' @@ -286,10 +285,11 @@ jobs: # modules at once, and gosec's whole-repo analysis exhausts the # runner's memory — the job dies with "The runner has received a # shutdown signal" at this step on every run. gosec is NOT dropped: - # the dedicated `Security Scanning` job in ci.yml runs gosec v2.28.0 - # per-module (SARIF) as the authoritative gate, so this only removes - # the duplicate that OOMs CI — the same dedup rationale as the - # already-skipped `terraform_validate` (covered by Validate Terraform). + # the dedicated `Security Scanning` job in ci.yml runs gosec (the + # version in .gosec-version) per-module (SARIF) as the + # authoritative gate, so this only removes the duplicate that OOMs + # CI — the same dedup rationale as the already-skipped + # `terraform_validate` (covered by Validate Terraform). # Local developers still get the fast per-changed-package gosec hook. SKIP: terraform_validate,gosec GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gosec-version b/.gosec-version new file mode 100644 index 000000000..25af363b4 --- /dev/null +++ b/.gosec-version @@ -0,0 +1 @@ +v2.28.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d4316a818..42d210ec7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -168,8 +168,8 @@ repos: # their owning module (root or tests/e2e). # Fast: never whole-repo, always per-changed-package. # - # gosec v2.28.0 is auto-installed to - # ~/.cache/pre-commit-gosec/v2.28.0/gosec on first use. + # The gosec version in .gosec-version is auto-installed to + # ~/.cache/pre-commit-gosec//gosec on first use. # # Exclusion rationale and flag list live in scripts/gosec-hook.sh. # Keep in sync with the exclude= flags there. diff --git a/Makefile b/Makefile index f3fa6a545..16cb53831 100644 --- a/Makefile +++ b/Makefile @@ -11,9 +11,13 @@ BUILD_TIME?=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ') GIT_SHA?=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) # Dev tool versions - keep in sync with the CI pins in -# .github/workflows/ci.yml, pre-commit.yml and database-migration.yml +# .github/workflows/ci.yml and pre-commit.yml GOLANGCI_LINT_VERSION?=v2.10.1 -GOSEC_VERSION?=v2.28.0 +# gosec has one source of truth, .gosec-version, also read by ci.yml and +# scripts/gosec-hook.sh. scripts/check-gosec-pin.sh fails CI if the version +# is written anywhere else (issue #347). +GOSEC_VERSION_FILE:=$(dir $(abspath $(lastword $(MAKEFILE_LIST)))).gosec-version +GOSEC_VERSION?=$(shell cat $(GOSEC_VERSION_FILE)) GOCYCLO_VERSION?=v0.6.0 # golang-migrate deliberately has no version variable: it is installed as a # package of this module (see install-tools), so its version and its whole diff --git a/scripts/check-gosec-pin.sh b/scripts/check-gosec-pin.sh new file mode 100755 index 000000000..667dc3b4d --- /dev/null +++ b/scripts/check-gosec-pin.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# check-gosec-pin.sh +# +# Fails when the gosec version is written anywhere other than .gosec-version. +# +# .gosec-version (repo root, one line such as `v2.28.0`) is the single source of +# truth for the gosec version. ci.yml's Security Scanning job, the Makefile's +# install-dev-tools target and the pre-commit hook (scripts/gosec-hook.sh) all +# read it. Before issue #347 the same version was hard-coded in four places +# (ci.yml, pre-commit.yml, the Makefile and gosec-hook.sh) plus comments that +# restated it, so a bump only had to miss one of them for local scans and the +# CI scan that uploads to GitHub Security to run different rule sets. +# +# Every tracked text file is scanned (git grep), not a list of known pin sites, +# so a new workflow or script that hard-codes the version is caught without +# anyone remembering to add it here. Findings: +# +# 1. .gosec-version is missing, untracked, or not exactly `vMAJOR.MINOR.PATCH`. +# 2. A version literal written next to "gosec" (any case): an install ref +# (`gosec@v2.28.0`), an assignment (`GOSEC_VERSION?=v2.28.0`, +# `GOSEC_VERSION="2.28.0"`), a cache key (`gosec-v2.28.0`), a path +# (`pre-commit-gosec/v2.28.0/`), an image tag (`securego/gosec:2.28.0`), +# a go.mod requirement, or prose (`runs gosec v2.28.0`). Prose counts too: +# a comment that restates the version goes stale on the next bump. +# 3. A gosec install or action ref that names anything other than a variable, +# such as `gosec@latest`, `gosec@master` or `securego/gosec@`. +# 4. An install site that does not mention .gosec-version, i.e. one that +# takes its version from somewhere else. An install site is any file that +# names the gosec command at a version (`securego/gosec/v2/cmd/gosec@`, as +# `go install` and `go run` do; matched per line, so a `go install \` +# continued onto the next line still counts). At least one must exist, so +# a scan that silently matched nothing cannot pass. +# +# Not scanned: CHANGELOG.md and docs/audits/, which record history and may name +# the versions that were current at the time, and this script and its self-test +# (scripts/test-check-gosec-pin.sh), which spell out the patterns. +# +# LIMITATIONS. A version stored in a variable whose name does not contain +# "gosec" (e.g. `V=2.28.0` then `gosec@$V`) is not caught by rule 2; rule 4 +# still requires that file to mention .gosec-version. +# +# Usage: check-gosec-pin.sh [ROOT] +# ROOT defaults to this repository. It must be the top level of a git work +# tree; only tracked files are scanned. The self-test points it at fixtures. +# +# Exit codes: 0 clean, 1 findings, 2 usage or setup error. + +set -euo pipefail + +if [[ $# -gt 1 ]]; then + echo "Usage: $0 [ROOT]" >&2 + exit 2 +fi +case "${1:-}" in + -*) echo "Unknown flag: $1" >&2; exit 2 ;; +esac + +ROOT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +if [[ ! -d "$ROOT" ]]; then + echo "ERROR: not a directory: $ROOT" >&2 + exit 2 +fi +ROOT="$(cd "$ROOT" && pwd -P)" +toplevel="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "$toplevel" || "$(cd "$toplevel" && pwd -P)" != "$ROOT" ]]; then + echo "ERROR: $ROOT is not the top level of a git work tree" >&2 + exit 2 +fi + +VERSION_FILE=".gosec-version" +# The guard and its self-test spell out the patterns they look for. +EXCLUDES=( + ":(exclude)CHANGELOG.md" + ":(exclude)docs/audits/**" + ":(exclude)scripts/check-gosec-pin.sh" + ":(exclude)scripts/test-check-gosec-pin.sh" +) + +# Rule 2: "gosec", then any non-space run (e.g. `_VERSION`, `/v2/cmd/gosec`), +# an optional word "version", separators, then a version literal. +LITERAL_RE="gosec[^[:space:]]*([[:space:]]+version)?[\"'@:=?/_[:space:]-]+v?[0-9]+\.[0-9]+\.[0-9]+" +# Rule 3: a gosec ref whose value after `@` does not start with `$`. +REF_RE="gosec[^[:space:]@]*@[\"']?[^$\"'[:space:]]" +# Rule 4: an install site. +INSTALL_RE="securego/gosec(/v[0-9]+)?/cmd/gosec@" + +findings=() + +# git grep exits 1 for "no match"; anything above that is a real error. +grep_tree() { + local rc=0 out + out="$(git -C "$ROOT" grep "$@" -- . "${EXCLUDES[@]}")" || rc=$? + if [[ $rc -gt 1 ]]; then + echo "ERROR: git grep failed (exit $rc) in $ROOT" >&2 + exit 2 + fi + # $(...) strips the final newline; put it back so `read` sees the last line. + [[ -n "$out" ]] && printf '%s\n' "$out" + return 0 +} + +# Rule 1. +if [[ ! -f "$ROOT/$VERSION_FILE" ]]; then + findings+=("$VERSION_FILE: missing") +elif ! git -C "$ROOT" ls-files --error-unmatch -- "$VERSION_FILE" >/dev/null 2>&1; then + findings+=("$VERSION_FILE: not tracked by git, so CI's checkout would not have it") +else + version="$(cat "$ROOT/$VERSION_FILE")" + if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + findings+=("$VERSION_FILE: must be one line of the form vMAJOR.MINOR.PATCH, got '${version}'") + fi +fi + +# Rules 2 and 3. +while IFS= read -r hit; do + [[ -n "$hit" ]] && findings+=("$hit [hard-coded gosec version]") +done < <(grep_tree -n -I -i -E "$LITERAL_RE") +while IFS= read -r hit; do + [[ -n "$hit" ]] && findings+=("$hit [gosec ref not read from $VERSION_FILE]") +done < <(grep_tree -n -I -i -E "$REF_RE") + +# Rule 4. +install_sites=0 +while IFS= read -r f; do + [[ -z "$f" ]] && continue + install_sites=$((install_sites + 1)) + if ! git -C "$ROOT" grep -q -F "$VERSION_FILE" -- "$f"; then + findings+=("$f: installs gosec but never mentions $VERSION_FILE") + fi +done < <(grep_tree -l -I -i -E "$INSTALL_RE") +if [[ $install_sites -eq 0 ]]; then + findings+=("no file names securego/gosec/v2/cmd/gosec@...; the scan found no install site, so it cannot vouch for any") +fi + +if [[ ${#findings[@]} -gt 0 ]]; then + { + echo "FAILED: the gosec version must be written only in $VERSION_FILE." + echo "" + printf ' %s\n' "${findings[@]}" + echo "" + echo "To bump gosec, edit $VERSION_FILE and nothing else. Install sites read it:" + echo " go install \"github.com/securego/gosec/v2/cmd/gosec@\$(cat $VERSION_FILE)\"" + echo "See the header of scripts/check-gosec-pin.sh for what is checked." + } >&2 + exit 1 +fi + +echo "OK: gosec ${version} is pinned only in $VERSION_FILE; ${install_sites} install site(s) read it." diff --git a/scripts/gosec-hook.sh b/scripts/gosec-hook.sh index eec9a143e..94935ef5e 100755 --- a/scripts/gosec-hook.sh +++ b/scripts/gosec-hook.sh @@ -5,13 +5,15 @@ # files, resolved to their owning module. Fast by design: never scans the whole # repo; each commit triggers at most one gosec invocation per affected module. # -# Version pin: gosec v2.28.0 (matches the CI pin in ci.yml, bumped by #1384). -# Installed on first use to ~/.cache/pre-commit-gosec/v2.28.0/gosec; never -# modifies the system-wide gosec binary. +# Version pin: read from .gosec-version at the repo root, the single source of +# truth that ci.yml and the Makefile also read (issue #347). Installed on first +# use to ~/.cache/pre-commit-gosec//gosec; never modifies the +# system-wide gosec binary. # # Called by pre-commit with pass_filenames: true and files: \.go$. # Exits 0 when no staged .go files survive filtering (deleted / testdata). -# Exits 1 on any gosec finding; exits 2 on setup failure. +# Exits 1 on any gosec finding; exits 2 on setup failure (including a missing +# or malformed .gosec-version). # # Exclusion rationale (kept in sync with .pre-commit-config.yaml): # G101 - variable names containing password/secret/token -> false positives @@ -32,8 +34,9 @@ set -euo pipefail -GOSEC_VERSION="2.28.0" -GOSEC_BIN="${HOME}/.cache/pre-commit-gosec/v${GOSEC_VERSION}/gosec" +# GOSEC_VERSION (vMAJOR.MINOR.PATCH) and GOSEC_BIN are set by read_gosec_version. +GOSEC_VERSION="" +GOSEC_BIN="" GOSEC_EXCLUDE="G101,G104,G115,G117,G118,G122,G204,G301,G304,G402,G505,G702,G703,G705,G706" @@ -42,6 +45,23 @@ MODULE_DIRS="tests/e2e" # ---- helpers ---------------------------------------------------------------- +# Read and validate the pin. A malformed file must stop the hook, not turn into +# a `go install` of an empty or garbage version, or a cache path outside the +# per-version directory. +read_gosec_version() { + local file="$1/.gosec-version" + if [[ ! -f "$file" ]]; then + echo "pre-commit/gosec: $file not found" >&2 + exit 2 + fi + GOSEC_VERSION="$(cat "$file")" + if [[ ! "$GOSEC_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "pre-commit/gosec: $file must be one line like vMAJOR.MINOR.PATCH, got '${GOSEC_VERSION}'" >&2 + exit 2 + fi + GOSEC_BIN="${HOME}/.cache/pre-commit-gosec/${GOSEC_VERSION}/gosec" +} + ensure_gosec() { local need_install=0 if [[ -x "$GOSEC_BIN" ]]; then @@ -50,9 +70,9 @@ ensure_gosec() { local installed_ver installed_ver=$(go version -m "$GOSEC_BIN" 2>/dev/null \ | awk '$1=="mod" && $2~/gosec/{print $3}') - # installed_ver is e.g. "v2.28.0"; compare against "v$GOSEC_VERSION". - if [[ "$installed_ver" != "v${GOSEC_VERSION}" ]]; then - echo "pre-commit/gosec: cached binary is ${installed_ver:-unknown}, need v${GOSEC_VERSION}; reinstalling" >&2 + # installed_ver and GOSEC_VERSION both carry the leading "v". + if [[ "$installed_ver" != "${GOSEC_VERSION}" ]]; then + echo "pre-commit/gosec: cached binary is ${installed_ver:-unknown}, need ${GOSEC_VERSION}; reinstalling" >&2 need_install=1 fi else @@ -60,10 +80,10 @@ ensure_gosec() { fi if [[ $need_install -eq 1 ]]; then - echo "pre-commit/gosec: installing gosec@v${GOSEC_VERSION} -> $(dirname "$GOSEC_BIN")" >&2 + echo "pre-commit/gosec: installing gosec@${GOSEC_VERSION} -> $(dirname "$GOSEC_BIN")" >&2 mkdir -p "$(dirname "$GOSEC_BIN")" GOBIN="$(dirname "$GOSEC_BIN")" go install \ - "github.com/securego/gosec/v2/cmd/gosec@v${GOSEC_VERSION}" || { + "github.com/securego/gosec/v2/cmd/gosec@${GOSEC_VERSION}" || { echo "pre-commit/gosec: install failed (is Go on PATH?)" >&2 exit 2 } @@ -101,6 +121,7 @@ done [[ ${#live_files[@]} -eq 0 ]] && exit 0 +read_gosec_version "$REPO_ROOT" ensure_gosec # Build "module|package" pairs from the live file list. diff --git a/scripts/security-scan.sh b/scripts/security-scan.sh index d49dfaec2..1ba625aa4 100755 --- a/scripts/security-scan.sh +++ b/scripts/security-scan.sh @@ -47,7 +47,7 @@ if command_exists gosec; then OVERALL_STATUS=1 fi else - echo -e "${YELLOW}⚠ gosec not installed. Install: go install github.com/securego/gosec/v2/cmd/gosec@latest${NC}" + echo -e "${YELLOW}⚠ gosec not installed. Install the pinned version (.gosec-version): make install-dev-tools${NC}" fi # 2. Static Analysis (staticcheck) diff --git a/scripts/test-check-gosec-pin.sh b/scripts/test-check-gosec-pin.sh new file mode 100755 index 000000000..81fc99364 --- /dev/null +++ b/scripts/test-check-gosec-pin.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +# test-check-gosec-pin.sh +# +# Exercises check-gosec-pin.sh in BOTH directions: a tree that pins gosec only +# in .gosec-version must pass, and each way of hard-coding the version again +# must fail. A guard that only ever returns "clean" is indistinguishable from a +# broken one, so the failing cases are the ones that give this check its value. +# +# It also asserts that the Makefile and scripts/gosec-hook.sh really read +# .gosec-version (a changed file changes what they install, and a malformed one +# stops the hook), so the readers cannot quietly go back to a literal. +# +# Fixtures are throwaway git repos under a temp dir; nothing touches the real +# tree. This file is exempt from the guard because it spells out the patterns. +# +# Exits 0 when all cases pass; exits 1 on any failure. + +# Fixture contents are literal text (`${{ }}`, `$(...)`, `${V}`) that must reach +# the files unexpanded, so single quotes are intended throughout. +# shellcheck disable=SC2016 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +CHECK="${SCRIPT_DIR}/check-gosec-pin.sh" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Keep the user's git config (hooks, excludes, templates) out of the fixtures. +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 + +pass=0 +fail=0 + +record() { + local label="$1" expected="$2" actual="$3" + if [[ "$actual" -eq "$expected" ]]; then + echo "PASS: $label" + (( pass++ )) || true + else + echo "FAIL: $label (expected exit $expected, got $actual)" + (( fail++ )) || true + fi +} + +run_case() { + local label="$1" expected="$2" + shift 2 + local actual=0 + bash "$CHECK" "$@" >/dev/null 2>&1 || actual=$? + record "$label" "$expected" "$actual" +} + +# A minimal tree that pins gosec correctly: the version file plus one install +# site that reads it. +n=0 +new_repo() { + n=$((n + 1)) + local dir="${WORK}/repo-${n}" + mkdir -p "${dir}/.github/workflows" + git -C "$dir" init -q >/dev/null 2>&1 + printf 'v2.28.0\n' > "${dir}/.gosec-version" + cat > "${dir}/.github/workflows/ci.yml" <<'EOF' +jobs: + scan: + steps: + - run: | + gosec_version="$(cat .gosec-version)" + go install "github.com/securego/gosec/v2/cmd/gosec@${gosec_version}" +EOF + REPO="$dir" +} + +track() { git -C "$REPO" add -A; } + +# add_file PATH CONTENT: write a file into the current fixture and track it. +add_file() { + mkdir -p "$(dirname "${REPO}/$1")" + printf '%s\n' "$2" > "${REPO}/$1" + track +} + +# ---- clean trees ------------------------------------------------------------ + +new_repo; track +run_case "a tree that pins gosec only in .gosec-version exits 0" 0 "$REPO" + +new_repo +add_file CHANGELOG.md "- ci: bump gosec to v2.28.0" +add_file docs/audits/audit.md 'the CI step runs `gosec@v2.28.0`' +run_case "CHANGELOG.md and docs/audits/ may name historical versions" 0 "$REPO" + +new_repo +add_file Makefile 'GOSEC_VERSION_FILE:=.gosec-version +GOSEC_VERSION?=$(shell cat $(GOSEC_VERSION_FILE)) +install: + go install github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION)' +run_case "a Makefile install site that reads .gosec-version exits 0" 0 "$REPO" + +# Neighbors of the pin that are not gosec versions must not be reported. +new_repo +add_file .github/workflows/pre-commit.yml ' - run: go install github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 + key: go-tools-${{ runner.os }}-gocyclo-v0.6.0 + sarif_file: ${{ runner.temp }}/gosec-results.sarif + uses: github/codeql-action/upload-sarif@7211b7c8 # v4.36.0 + # gosec v2 rules, see G104' +run_case "other tools' pins and gosec mentions without a version exit 0" 0 "$REPO" + +# ---- hard-coded versions ---------------------------------------------------- + +literal_case() { + local label="$1" path="$2" content="$3" + new_repo + add_file "$path" "$content" + run_case "$label exits 1" 1 "$REPO" +} + +literal_case "a literal install ref (gosec@v2.28.0)" .github/workflows/x.yml \ + '# reads .gosec-version + run: go install github.com/securego/gosec/v2/cmd/gosec@v2.28.0' +literal_case "a Makefile assignment (GOSEC_VERSION?=v2.28.0)" Makefile \ + 'GOSEC_VERSION?=v2.28.0' +literal_case "a shell assignment (GOSEC_VERSION=\"2.28.0\")" scripts/hook.sh \ + 'GOSEC_VERSION="2.28.0"' +literal_case "a YAML env value (GOSEC_VERSION: 'v2.28.0')" .github/workflows/x.yml \ + " GOSEC_VERSION: 'v2.28.0'" +literal_case "a cache key (gosec-v2.28.0)" .github/workflows/x.yml \ + ' key: go-tools-${{ runner.os }}-gosec-v2.28.0-gocyclo-v0.6.0' +literal_case "a cache path (pre-commit-gosec/v2.28.0/)" .pre-commit-config.yaml \ + ' # ~/.cache/pre-commit-gosec/v2.28.0/gosec on first use.' +literal_case "a prose comment (runs gosec v2.28.0)" .github/workflows/x.yml \ + ' # the Security Scanning job runs gosec v2.28.0 per module' +literal_case "a prose comment (gosec version 2.28.0)" docs/DEVELOPMENT.md \ + 'CI uses gosec version 2.28.0.' +literal_case "a mixed-case, unprefixed mention (GoSec 2.28.0)" README.md \ + 'GoSec 2.28.0 is what CI runs' +literal_case "an image tag (securego/gosec:2.28.0)" Dockerfile \ + 'FROM securego/gosec:2.28.0' +literal_case "a go.mod tool requirement" go.mod \ + 'require github.com/securego/gosec/v2 v2.28.0' +literal_case "a docs/ file outside docs/audits/" docs/notes.md \ + 'run `gosec@v2.28.0` locally' + +# ---- refs that bypass the file ---------------------------------------------- + +literal_case "an unpinned install (gosec@latest)" scripts/x.sh \ + '# .gosec-version +echo "Install: go install github.com/securego/gosec/v2/cmd/gosec@latest"' +literal_case "a GitHub Action ref (securego/gosec@)" .github/workflows/x.yml \ + ' uses: securego/gosec@6decf96c3d272d5a8bbdcf9fddb5789d0be16a8d' + +literal_case "an install site that never mentions .gosec-version" scripts/x.sh \ + 'GOBIN=/tmp go install "github.com/securego/gosec/v2/cmd/gosec@${V}"' +literal_case "a continued install line that never mentions .gosec-version" scripts/x.sh \ + 'GOBIN=/tmp go install \ + "github.com/securego/gosec/v2/cmd/gosec@${V}"' + +new_repo +rm "${REPO}/.github/workflows/ci.yml" +track +run_case "a tree with no gosec install site at all exits 1" 1 "$REPO" + +# ---- the version file itself ------------------------------------------------ + +new_repo +rm "${REPO}/.gosec-version" +track +run_case "a missing .gosec-version exits 1" 1 "$REPO" + +new_repo +track +git -C "$REPO" rm -q --cached .gosec-version +run_case "an untracked .gosec-version exits 1" 1 "$REPO" + +for bad in '2.28.0' 'v2.28' 'latest' '' 'v2.28.0 # pinned' $'v2.28.0\nv2.29.0' $'v2.28.0\r'; do + new_repo + printf '%s\n' "$bad" > "${REPO}/.gosec-version" + track + run_case "a malformed .gosec-version ($(printf '%q' "$bad")) exits 1" 1 "$REPO" +done + +# ---- usage errors ----------------------------------------------------------- + +new_repo; track +mkdir -p "${REPO}/sub" +run_case "unknown flag exits 2" 2 --bogus +run_case "two arguments exit 2" 2 "$REPO" "$REPO" +run_case "a missing directory exits 2" 2 "${WORK}/does-not-exist" +run_case "a directory below the work tree top level exits 2" 2 "${REPO}/sub" +mkdir -p "${WORK}/not-a-repo" +run_case "a directory that is not a git work tree exits 2" 2 "${WORK}/not-a-repo" + +# ---- the readers really read the file --------------------------------------- + +# The Makefile: a different version in the file must change what +# install-dev-tools installs. A literal in the Makefile would not follow it. +if command -v make >/dev/null 2>&1; then + mk="${WORK}/makefile-reader" + mkdir -p "$mk" + cp "${REPO_ROOT}/Makefile" "${mk}/Makefile" + printf 'v9.8.7\n' > "${mk}/.gosec-version" + actual=1 + # GOSEC_VERSION is unset so the Makefile's own default applies. The output is + # captured first: `grep -q` closing the pipe early would SIGPIPE make and, + # under pipefail, fail the case for the wrong reason. + recipe="$(cd "$mk" && env -u GOSEC_VERSION make -s -n install-dev-tools 2>/dev/null || true)" + if grep -q -F 'github.com/securego/gosec/v2/cmd/gosec@v9.8.7' <<< "$recipe"; then + actual=0 + fi + record "make install-dev-tools installs the version in .gosec-version" 0 "$actual" +else + echo "FAIL: make is not installed, cannot check the Makefile reader" + (( fail++ )) || true +fi + +# The pre-commit hook: a missing or malformed file must stop it (exit 2) before +# it installs or runs anything. HOME is redirected so no real cache is touched, +# and `go` is a stub that only records how it was called, so a hook that +# ignores the file fails here quickly instead of downloading gosec. +hook_n=0 +# hook_fixture CONTENT: build a fixture repo for the hook; sets HOOK_DIR. +hook_fixture() { + local content="$1" + hook_n=$((hook_n + 1)) + local dir="${WORK}/hook-${hook_n}" + mkdir -p "${dir}/scripts" "${dir}/home" "${dir}/bin" + git -C "$dir" init -q >/dev/null 2>&1 + cp "${SCRIPT_DIR}/gosec-hook.sh" "${dir}/scripts/gosec-hook.sh" + printf 'package main\n' > "${dir}/main.go" + if [[ "$content" != "" ]]; then + printf '%s\n' "$content" > "${dir}/.gosec-version" + fi + printf '#!/bin/sh\necho "GOBIN=$GOBIN $*" >> "%s"\nexit 1\n' "${dir}/go-calls" > "${dir}/bin/go" + chmod +x "${dir}/bin/go" + HOOK_DIR="$dir" +} + +# run_hook: run the copied hook on main.go; sets HOOK_EXIT. +run_hook() { + HOOK_EXIT=0 + (cd "$HOOK_DIR" && HOME="${HOOK_DIR}/home" PATH="${HOOK_DIR}/bin:${PATH}" \ + bash scripts/gosec-hook.sh main.go >/dev/null 2>&1) || HOOK_EXIT=$? +} + +hook_case() { + local label="$1" content="$2" + hook_fixture "$content" + run_hook + local dir="$HOOK_DIR" actual="$HOOK_EXIT" + if [[ -e "${dir}/go-calls" || -e "${dir}/home/.cache" ]]; then + echo "FAIL: $label (the hook ran go or created its cache before validating the version)" + (( fail++ )) || true + return + fi + record "$label" 2 "$actual" +} +hook_case "the hook stops with exit 2 when .gosec-version is missing" "" +hook_case "the hook stops with exit 2 on an unprefixed version (2.28.0)" "2.28.0" +hook_case "the hook stops with exit 2 on a non-version (latest)" "latest" + +# With a valid file the hook must install exactly that version, into the +# per-version cache directory. The stub `go` fails the install, so the hook +# then exits 2; what matters is the recorded call. +hook_fixture "v9.8.7" +run_hook +want="GOBIN=${HOOK_DIR}/home/.cache/pre-commit-gosec/v9.8.7 install github.com/securego/gosec/v2/cmd/gosec@v9.8.7" +if [[ -f "${HOOK_DIR}/go-calls" ]] && grep -q -x -F "$want" "${HOOK_DIR}/go-calls"; then + record "the hook installs the version in .gosec-version" 0 0 +else + echo "FAIL: the hook installs the version in .gosec-version (go calls: $(cat "${HOOK_DIR}/go-calls" 2>/dev/null || echo none))" + (( fail++ )) || true +fi + +# ---- the real tree ---------------------------------------------------------- + +run_case "this repository pins gosec only in .gosec-version" 0 + +echo "" +echo "Results: ${pass} passed, ${fail} failed." +[[ "$fail" -eq 0 ]]