diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f62a9b3b..9e920e74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1083,6 +1083,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 @@ -1207,12 +1217,12 @@ jobs: rm -f "$merged" fi - # scripts/run-gosec.sh owns the pinned gosec version, toolchain, - # module list and rule set, shared with the pre-commit hook and - # `make security-scan-go` (issue #340). It installs gosec with the - # job's existing setup-go: the securego/gosec Docker action bundles - # its own Go toolchain, which cannot satisfy the module's go - # directive. + # scripts/run-gosec.sh owns the toolchain, module list and rule set, + # shared with the pre-commit hook and `make security-scan-go` (issue + # #340), and installs the gosec version in .gosec-version (issue + # #347). It installs gosec with the job's existing setup-go: the + # securego/gosec Docker action bundles its own Go toolchain, which + # cannot satisfy the module's go directive. # # gosec exits non-zero both for real findings and for a processing # error. The script still scans every module after a failure, and diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index e4e924e1..a974b49b 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 (via scripts/run-gosec.sh) installs its own copy under + # ~/.cache/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 00000000..25af363b --- /dev/null +++ b/.gosec-version @@ -0,0 +1 @@ +v2.28.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 59ddfc5d..b6e1616c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -168,8 +168,9 @@ repos: # their owning module (root or tests/e2e). # Fast: never whole-repo, always per-changed-package. # - # Version, toolchain and rule set (no excludes) live in - # scripts/run-gosec.sh, shared with `make security-scan-go` and CI. + # Toolchain and rule set (no excludes) live in scripts/run-gosec.sh, + # shared with `make security-scan-go` and CI; it installs the gosec + # version in .gosec-version. entry: bash scripts/gosec-hook.sh language: system pass_filenames: true diff --git a/Makefile b/Makefile index f49e1f94..a8accc31 100644 --- a/Makefile +++ b/Makefile @@ -11,9 +11,14 @@ 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 +# scripts/run-gosec.sh (the pre-commit hook, security-scan-go and CI). +# 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 @@ -159,8 +164,9 @@ complexity-report: # Security scanning security-scan: security-scan-go security-scan-docker security-scan-terraform -# scripts/run-gosec.sh owns the gosec pin, module list and rule set, shared -# with the pre-commit hook and the CI gosec step (issue #340). +# scripts/run-gosec.sh owns the module list and rule set and installs the +# gosec version in .gosec-version, shared with the pre-commit hook and the CI +# gosec step (issues #340, #347). security-scan-go: @scripts/run-gosec.sh diff --git a/scripts/check-gosec-pin.sh b/scripts/check-gosec-pin.sh new file mode 100755 index 00000000..304b4b6e --- /dev/null +++ b/scripts/check-gosec-pin.sh @@ -0,0 +1,149 @@ +#!/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. scripts/run-gosec.sh (which ci.yml's Security +# Scanning job, `make security-scan-go` and the pre-commit hook all call) and +# the Makefile's install-dev-tools target 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 7e6c1274..c1ca82ef 100755 --- a/scripts/gosec-hook.sh +++ b/scripts/gosec-hook.sh @@ -5,14 +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. # -# Rule set, gosec version and toolchain live in scripts/run-gosec.sh, shared -# with `make security-scan-go` and CI, so this hook enforces exactly what CI -# enforces (issue #340). This script only maps staged files to -# (module, package) pairs. +# Rule set, gosec version (read from .gosec-version, issue #347) and toolchain +# live in scripts/run-gosec.sh, shared with `make security-scan-go` and CI, so +# this hook enforces exactly what CI enforces (issue #340). This script only +# maps staged files to (module, package) pairs. # # 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). set -euo pipefail diff --git a/scripts/run-gosec.sh b/scripts/run-gosec.sh index 9bd4c5b6..86da93cb 100755 --- a/scripts/run-gosec.sh +++ b/scripts/run-gosec.sh @@ -19,6 +19,11 @@ # where is "root" for the root module and the module path with slashes # turned into dashes otherwise (tests/e2e -> tests-e2e). # +# The gosec version is read from .gosec-version at the repo root, the single +# source of truth that the Makefile's install-dev-tools also reads (issue +# #347). A missing or malformed file (anything but one vMAJOR.MINOR.PATCH line) +# is a setup failure, caught before Go runs or the cache is touched. +# # --print-versions prints gosec_version=, go_version= and bin_dir= lines (the # pinned gosec, the effective Go version and the binary cache directory) and # exits without installing or scanning. CI keys its gosec caches on them, so @@ -34,7 +39,6 @@ set -euo pipefail -GOSEC_VERSION="v2.28.0" # Every module in go.work. MODULES=(. tests/e2e) @@ -81,6 +85,16 @@ if [[ ${#scan_mods[@]} -eq 0 ]]; then for _ in "${MODULES[@]}"; do scan_pkgs+=(""); done fi +# Validate the pin before it reaches `go install` or the cache path: an empty +# or garbage value must not install some other gosec or escape the cache dir. +version_file="${REPO_ROOT}/.gosec-version" +[[ -f "$version_file" ]] || { echo "run-gosec: $version_file not found" >&2; exit 2; } +GOSEC_VERSION="$(cat "$version_file")" +if [[ ! "$GOSEC_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "run-gosec: $version_file must be one line like vMAJOR.MINOR.PATCH, got '${GOSEC_VERSION}'" >&2 + exit 2 +fi + if [[ -z "${GOTOOLCHAIN:-}" ]]; then go_directive=$(awk '$1=="go"{print $2; exit}' "${REPO_ROOT}/go.mod") [[ -n "$go_directive" ]] || { echo "run-gosec: no go directive in go.mod" >&2; exit 2; } diff --git a/scripts/security-scan.sh b/scripts/security-scan.sh index 2eb73427..cd08f055 100755 --- a/scripts/security-scan.sh +++ b/scripts/security-scan.sh @@ -36,9 +36,10 @@ print_section() { } # 1. Go Security Scanner (gosec) -# scripts/run-gosec.sh owns the gosec pin, module list and rule set, shared -# with the pre-commit hook, `make security-scan-go` and CI (issue #340). It -# installs the pinned gosec itself, so there is no not-installed branch. +# scripts/run-gosec.sh owns the module list and rule set, shared with the +# pre-commit hook, `make security-scan-go` and CI (issue #340). It installs +# the gosec version in .gosec-version itself (issue #347), so there is no +# not-installed branch. print_section "1. Go Security Scanner (gosec)" if "$(dirname "$0")/run-gosec.sh" 2>&1 | tee "$REPORT_DIR/gosec-report.txt"; then echo -e "${GREEN}✓ gosec scan completed${NC}" diff --git a/scripts/test-check-gosec-pin.sh b/scripts/test-check-gosec-pin.sh new file mode 100755 index 00000000..0b2185e0 --- /dev/null +++ b/scripts/test-check-gosec-pin.sh @@ -0,0 +1,302 @@ +#!/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/run-gosec.sh (called by the +# pre-commit hook, `make security-scan-go` and CI) really read .gosec-version (a +# changed file changes what they install, and a malformed one stops them), 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 + +# scripts/run-gosec.sh, which the pre-commit hook, `make security-scan-go` and +# CI all call: a missing or malformed file must stop it (exit 2) before it runs +# Go or touches its cache. HOME is redirected so no real cache is touched, and +# `go` is a stub that only records how it was called (it answers +# `go env GOVERSION` and fails everything else), so a script that ignores the +# file fails here quickly instead of downloading gosec. +reader_n=0 +# reader_fixture CONTENT: build a fixture repo; sets READER_DIR. +reader_fixture() { + local content="$1" + reader_n=$((reader_n + 1)) + local dir="${WORK}/reader-${reader_n}" + mkdir -p "${dir}/scripts" "${dir}/home" "${dir}/bin" + git -C "$dir" init -q >/dev/null 2>&1 + cp "${SCRIPT_DIR}/run-gosec.sh" "${SCRIPT_DIR}/gosec-hook.sh" "${dir}/scripts/" + 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"\n[ "$1 $2" = "env GOVERSION" ] && { echo go1.26.9; exit 0; }\nexit 1\n' \ + "${dir}/go-calls" > "${dir}/bin/go" + chmod +x "${dir}/bin/go" + READER_DIR="$dir" +} + +# run_reader SCRIPT ARGS...: run a copied script in the fixture; sets READER_EXIT. +run_reader() { + READER_EXIT=0 + (cd "$READER_DIR" && env -u XDG_CACHE_HOME HOME="${READER_DIR}/home" \ + PATH="${READER_DIR}/bin:${PATH}" GOTOOLCHAIN=local \ + bash "$@" >/dev/null 2>&1) || READER_EXIT=$? +} + +# reader_reject_case LABEL CONTENT SCRIPT ARGS... +reader_reject_case() { + local label="$1" content="$2" + shift 2 + reader_fixture "$content" + run_reader "$@" + local dir="$READER_DIR" actual="$READER_EXIT" + if [[ -e "${dir}/go-calls" || -e "${dir}/home/.cache" ]]; then + echo "FAIL: $label (go ran or the cache was created before the version was validated)" + (( fail++ )) || true + return + fi + record "$label" 2 "$actual" +} +reader_reject_case "run-gosec.sh stops with exit 2 when .gosec-version is missing" \ + "" scripts/run-gosec.sh +reader_reject_case "run-gosec.sh stops with exit 2 on an unprefixed version (2.28.0)" \ + "2.28.0" scripts/run-gosec.sh +reader_reject_case "run-gosec.sh stops with exit 2 on a non-version (latest)" \ + "latest" scripts/run-gosec.sh +reader_reject_case "the pre-commit hook stops with exit 2 when .gosec-version is missing" \ + "" scripts/gosec-hook.sh main.go + +# reader_install_case LABEL SCRIPT ARGS...: with a valid file the script must +# install exactly that version, into a private dir under the gosec cache. The +# stub `go` fails the install, so the script then exits 2; what matters is the +# recorded call. +reader_install_case() { + local label="$1" + shift + reader_fixture "v9.8.7" + run_reader "$@" + local want_re="^GOBIN=${READER_DIR}/home/\.cache/gosec/\.install\.[^ /]+ install github\.com/securego/gosec/v2/cmd/gosec@v9\.8\.7\$" + if [[ -f "${READER_DIR}/go-calls" ]] && grep -q -E "$want_re" "${READER_DIR}/go-calls"; then + record "$label" 0 0 + else + echo "FAIL: $label (go calls: $(cat "${READER_DIR}/go-calls" 2>/dev/null || echo none))" + (( fail++ )) || true + fi +} +reader_install_case "run-gosec.sh installs the version in .gosec-version" scripts/run-gosec.sh +reader_install_case "the pre-commit hook installs the version in .gosec-version" \ + scripts/gosec-hook.sh main.go + +# ---- the real tree ---------------------------------------------------------- + +run_case "this repository pins gosec only in .gosec-version" 0 + +echo "" +echo "Results: ${pass} passed, ${fail} failed." +[[ "$fail" -eq 0 ]] diff --git a/scripts/test-run-gosec.sh b/scripts/test-run-gosec.sh index f96766f7..2a77725a 100755 --- a/scripts/test-run-gosec.sh +++ b/scripts/test-run-gosec.sh @@ -21,9 +21,10 @@ trap 'rm -rf "$work"' EXIT export GOTOOLCHAIN=local GOPROXY=off XDG_CACHE_HOME="$work/cache" HOME="$work/home" mkdir -p "$HOME" -# The pin is read from run-gosec.sh, so a version bump needs no test change. -# A missing pin is reported below; the other cases still run. -gosec_version="$(sed -n 's/^GOSEC_VERSION="\(.*\)"$/\1/p' "$SCRIPT_DIR/run-gosec.sh" 2>/dev/null || true)" +# The pin is read from .gosec-version, as run-gosec.sh does, so a version bump +# needs no test change. A missing pin is reported below; the other cases still +# run. +gosec_version="$(cat "$REPO_ROOT/.gosec-version" 2>/dev/null || true)" fake_dir="$XDG_CACHE_HOME/gosec/${gosec_version}-$(go env GOVERSION)" mkdir -p "$fake_dir" cat >"$fake_dir/gosec" <<'FAKE' @@ -47,9 +48,9 @@ pass() { echo "PASS: $1"; } failed() { echo "FAIL: $1"; fail=1; } if [[ -n "$gosec_version" ]]; then - pass "run-gosec.sh pins GOSEC_VERSION ($gosec_version)" + pass ".gosec-version pins gosec ($gosec_version)" else - failed "run-gosec.sh pins GOSEC_VERSION" + failed ".gosec-version pins gosec" fi # run LABEL WANT_RC CMD... : runs CMD with a fresh log, checks its exit code.