diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b885e287e..ebdd8f1ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -714,6 +714,13 @@ jobs: cd frontend && npm audit --audit-level=high fi + - name: Run gosec wrapper self-tests + # Pins that the pre-commit hook, `make security-scan-go` and the step + # below all run gosec through scripts/run-gosec.sh with one rule set + # (issue #340). Uses a fake gosec, so it is fast and offline. + if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' + run: bash scripts/test-run-gosec.sh + - name: Run gosec Security Scanner id: gosec # always(): don't let an earlier scanner's failure (e.g. npm audit) @@ -721,45 +728,33 @@ jobs: # Still requires checkout and Go setup to have succeeded. if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' run: | - # 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 - # Collect SARIF for both platform-owned modules before uploading. + # 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. # # gosec exits non-zero both for real findings and for a processing - # error. A bare `set -e` loop aborts at the first non-zero module, - # skipping the merge below entirely -- the upload step then fails - # on a missing file instead of surfacing the actual finding - # (issue #1717). Guarding the gosec call in `if ! ( ... )` exempts - # it from errexit so every module still gets scanned and merged; - # `status` records whether the job should still fail at the end. - set -e + # error. The script still scans every module after a failure, and + # `|| status=1` exempts it from errexit so the merge below always + # runs (issue #1717); `status` records whether the job should still + # fail at the end. status=0 - for mod in . tests/e2e; do - tag=$(echo "$mod" | tr './' '--' | sed 's/^-/root/') - out="$RUNNER_TEMP/gosec-${tag}.sarif" - echo "==> gosec in $mod" - if ! (cd "$mod" && gosec -fmt sarif -out "$out" ./...); then - echo "::warning::gosec exited non-zero in $mod (findings or a scan error)" - status=1 - fi - if [ ! -f "$out" ]; then - echo "::error::gosec produced no SARIF output for $mod" - status=1 - continue - fi + scripts/run-gosec.sh --sarif-dir "$RUNNER_TEMP" || status=1 + shopt -s nullglob + for out in "$RUNNER_TEMP"/gosec-*.sarif; do + tag=$(basename "$out" .sarif) # Code scanning rejects a SARIF file whose runs share a category # (github.blog changelog 2025-07-21), so give each module's run a # unique automationDetails.id before merging. - jq --arg id "gosec-${tag}/" '.runs |= map(.automationDetails = {id: $id})' \ + jq --arg id "${tag}/" '.runs |= map(.automationDetails = {id: $id})' \ "$out" > "$out.tmp" && mv "$out.tmp" "$out" done # Merge per-module SARIF runs into one file for the upload step. # Only modules that actually produced a file are included; if # gosec crashed before writing any of them, fail loud here rather # than uploading an empty result silently. - shopt -s nullglob sarif_files=("$RUNNER_TEMP"/gosec-*.sarif) if [ "${#sarif_files[@]}" -eq 0 ]; then echo "::error::no gosec SARIF output was produced by any module" >&2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d4316a818..59ddfc5dc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -168,11 +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. - # - # Exclusion rationale and flag list live in scripts/gosec-hook.sh. - # Keep in sync with the exclude= flags there. + # Version, toolchain and rule set (no excludes) live in + # scripts/run-gosec.sh, shared with `make security-scan-go` and CI. entry: bash scripts/gosec-hook.sh language: system pass_filenames: true diff --git a/Makefile b/Makefile index f3fa6a545..f49e1f94a 100644 --- a/Makefile +++ b/Makefile @@ -159,14 +159,10 @@ 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). security-scan-go: - @echo "Running gosec..." - @if command -v gosec > /dev/null; then \ - gosec -fmt=json -out=gosec-report.json -exclude=G101,G104,G115,G204,G301,G304,G402,G505 ./...; \ - echo "✓ Go security scan complete: gosec-report.json"; \ - else \ - echo "gosec not installed. Install: make install-dev-tools"; \ - fi + @scripts/run-gosec.sh security-scan-docker: @echo "Running trivy..." diff --git a/scripts/gosec-hook.sh b/scripts/gosec-hook.sh index eec9a143e..7e6c12746 100755 --- a/scripts/gosec-hook.sh +++ b/scripts/gosec-hook.sh @@ -5,71 +5,28 @@ # 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. +# 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. # # 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. -# -# Exclusion rationale (kept in sync with .pre-commit-config.yaml): -# G101 - variable names containing password/secret/token -> false positives -# G104 - unchecked errors -> covered by errcheck -# G115 - integer overflow -> safe conversions flagged -# G117 - unsafe pointer arithmetic -> vendor/generated code -# G118 - net/http serve without timeout -> timeouts set at handler level -# G122 - unsafe operations -> low-level helpers, pre-existing -# G204 - subprocess with variable -> CLI tool needs dynamic commands -# G301 - dir permissions > 0750 -> acceptable for dev tooling -# G304 - file path from variable -> CLI reads user-specified paths -# G402 - TLS MinVersion not set -> handled by cloud SDK defaults -# G505 - import of crypto/sha1 -> checksums, not security primitives -# G702 - TLS InsecureSkipVerify -> test helpers only, pre-existing -# G703 - unhandled defer error -> deferred close errors logged separately -# G705 - unhandled goroutine error -> pre-existing pattern -# G706 - ignored errors -> pre-existing; covered by go vet errcheck set -euo pipefail -GOSEC_VERSION="2.28.0" -GOSEC_BIN="${HOME}/.cache/pre-commit-gosec/v${GOSEC_VERSION}/gosec" - -GOSEC_EXCLUDE="G101,G104,G115,G117,G118,G122,G204,G301,G304,G402,G505,G702,G703,G705,G706" +RUN_GOSEC="$(dirname "$0")/run-gosec.sh" -# Nested module roots, matching the module loop in .github/workflows/ci.yml. -MODULE_DIRS="tests/e2e" +# Nested module roots (every module but the root one), owned by run-gosec.sh. +all_modules="$(bash "$RUN_GOSEC" --list-modules)" +MODULE_DIRS="" +while IFS= read -r m; do + [[ "$m" == "." ]] || MODULE_DIRS+="$m " +done <<< "$all_modules" # ---- helpers ---------------------------------------------------------------- -ensure_gosec() { - local need_install=0 - if [[ -x "$GOSEC_BIN" ]]; then - # gosec built via `go install` embeds "dev" in -h regardless of tag; - # read the real module version from the binary's build info instead. - 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 - need_install=1 - fi - else - need_install=1 - fi - - if [[ $need_install -eq 1 ]]; then - echo "pre-commit/gosec: installing gosec@v${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}" || { - echo "pre-commit/gosec: install failed (is Go on PATH?)" >&2 - exit 2 - } - fi -} - # Print the module root (relative to repo root) that owns a given relative file # path, or empty string when the file belongs to the root module. module_for() { @@ -86,12 +43,13 @@ module_for() { [[ $# -eq 0 ]] && exit 0 -REPO_ROOT="$(git rev-parse --show-toplevel)" - # Filter: skip deleted files and files under testdata/. live_files=() for f in "$@"; do [[ -f "$f" ]] || continue + case "$f" in + *[[:space:]]*) echo "gosec-hook: path contains whitespace, cannot scan: $f" >&2; exit 2 ;; + esac case "$f" in */testdata/*) continue ;; testdata/*) continue ;; @@ -101,8 +59,6 @@ done [[ ${#live_files[@]} -eq 0 ]] && exit 0 -ensure_gosec - # Build "module|package" pairs from the live file list. # Package path is relative to the owning module root, in ./pkg notation. pairs_raw=() @@ -134,26 +90,14 @@ pairs_sorted=$(printf '%s\n' "${pairs_raw[@]}" | sort -u) # Enumerate unique module roots. mods=$(printf '%s\n' "$pairs_sorted" | cut -d'|' -f1 | sort -u) -fail=0 +run_args=() while IFS= read -r mod; do pkgs=$(printf '%s\n' "$pairs_sorted" \ | awk -F'|' -v m="$mod" '$1==m{print $2}' \ | tr '\n' ' ') - mod_dir="${REPO_ROOT}${mod:+/${mod}}" - - echo "gosec [${mod:-.}]: scanning package(s): $pkgs" >&2 - - # pkgs intentionally unquoted: space-separated package paths, no glob chars. - # shellcheck disable=SC2086 - if ! (cd "$mod_dir" && "$GOSEC_BIN" \ - -quiet \ - -exclude-dir=.legacy \ - -exclude-dir=.dev-notes \ - -exclude-dir=vendor \ - "-exclude=${GOSEC_EXCLUDE}" \ - $pkgs); then - fail=1 - fi + read -r -a pkg_list <<< "$pkgs" + # An empty module is the root module. + run_args+=(--module "${mod:-.}" "${pkg_list[@]}") done <<< "$mods" -exit $fail +exec bash "$RUN_GOSEC" "${run_args[@]}" diff --git a/scripts/run-gosec.sh b/scripts/run-gosec.sh new file mode 100755 index 000000000..120867266 --- /dev/null +++ b/scripts/run-gosec.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# scripts/run-gosec.sh +# +# Single source of truth for running gosec in this repository. The pre-commit +# hook (scripts/gosec-hook.sh), `make security-scan-go` and the CI gosec step +# all call this script, so a local pass predicts CI (issue #340). +# +# Rule set: gosec defaults, no -exclude. Suppress a reviewed false positive +# inline with `#nosec Gxxx -- reason`, never by excluding a rule here. +# +# Usage: +# run-gosec.sh [--sarif-dir DIR] [--module MOD [PKG...]]... +# run-gosec.sh --list-modules +# +# With no --module every module is scanned whole (./...). PKG paths are +# relative to the module root (for example ./internal/api); default ./... +# --sarif-dir writes DIR/gosec-.sarif instead of printing text findings, +# where is "root" for the root module and the module path with slashes +# turned into dashes otherwise (tests/e2e -> tests-e2e). +# +# Exit codes: 0 clean, 1 findings or a scan error, 2 setup/usage failure. +# +# The gosec binary is cached per (gosec version, effective Go version), because +# gosec cannot read export data produced by a newer toolchain than the one it +# was built with: such a binary scans nothing and reports 0 issues. The +# toolchain defaults to the go directive of the root go.mod, which matches the +# GO_VERSION that CI's setup-go installs. + +set -euo pipefail + +GOSEC_VERSION="v2.28.0" +# Every module in go.work. +MODULES=(. tests/e2e) + +if [[ "${1:-}" == "--list-modules" ]]; then + printf '%s\n' "${MODULES[@]}" + exit 0 +fi + +REPO_ROOT="$(git rev-parse --show-toplevel)" + +sarif_dir="" +declare -a scan_mods=() scan_pkgs=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --sarif-dir) + [[ $# -ge 2 ]] || { echo "run-gosec: --sarif-dir needs a directory" >&2; exit 2; } + sarif_dir="$2"; shift 2 ;; + --module) + [[ $# -ge 2 ]] || { echo "run-gosec: --module needs a module path" >&2; exit 2; } + scan_mods+=("$2"); shift 2 + cur="" + while [[ $# -gt 0 && "$1" != --* ]]; do + case "$1" in + *[[:space:]]*) echo "run-gosec: package path contains whitespace: $1" >&2; exit 2 ;; + esac + cur+="${cur:+ }$1" + shift + done + scan_pkgs+=("$cur") ;; + *) echo "run-gosec: unknown argument: $1" >&2; exit 2 ;; + esac +done + +if [[ ${#scan_mods[@]} -eq 0 ]]; then + scan_mods=("${MODULES[@]}") + for _ in "${MODULES[@]}"; do scan_pkgs+=(""); done +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; } + export GOTOOLCHAIN="go${go_directive}" +fi + +# Key the cache on the Go version that actually builds gosec, not the requested +# string (GOTOOLCHAIN=local would otherwise hide a Go upgrade). +go_version=$(go env GOVERSION) || { echo "run-gosec: go env GOVERSION failed (is Go on PATH?)" >&2; exit 2; } +cache_root="${XDG_CACHE_HOME:-${HOME}/.cache}/gosec" +gosec_dir="${cache_root}/${GOSEC_VERSION}-${go_version}" +gosec_bin="${gosec_dir}/gosec" + +if [[ ! -x "$gosec_bin" ]]; then + echo "run-gosec: installing gosec@${GOSEC_VERSION} (${go_version}) -> ${gosec_dir}" >&2 + mkdir -p "$cache_root" + # Install into a private dir and rename, so an interrupted or parallel + # install never leaves a truncated binary at the trusted path. + tmp_dir=$(mktemp -d "${cache_root}/.install.XXXXXX") + if ! GOBIN="$tmp_dir" go install "github.com/securego/gosec/v2/cmd/gosec@${GOSEC_VERSION}"; then + rm -rf "$tmp_dir" + echo "run-gosec: install failed (is Go on PATH?)" >&2 + exit 2 + fi + mkdir -p "$gosec_dir" + mv -f "$tmp_dir/gosec" "$gosec_bin" + rmdir "$tmp_dir" +fi + +[[ -z "$sarif_dir" ]] || mkdir -p "$sarif_dir" + +fail=0 +for i in "${!scan_mods[@]}"; do + mod="${scan_mods[$i]}" + pkgs="${scan_pkgs[$i]:-./...}" + args=(-exclude-dir=.legacy -exclude-dir=.dev-notes -exclude-dir=vendor) + out="" + if [[ -n "$sarif_dir" ]]; then + tag="${mod//\//-}" + [[ "$mod" == "." ]] && tag="root" + out="${sarif_dir}/gosec-${tag}.sarif" + rm -f "$out" + args+=(-fmt sarif -out "$out") + else + # -quiet would also skip writing the SARIF file when there are no findings. + args+=(-quiet) + fi + + echo "gosec [${mod}]: scanning ${pkgs}" >&2 + # pkgs intentionally unquoted: space-separated package paths, no glob chars. + # shellcheck disable=SC2086 + if ! (cd "${REPO_ROOT}/${mod}" && "$gosec_bin" "${args[@]}" $pkgs); then + echo "::warning::gosec exited non-zero in ${mod} (findings or a scan error)" >&2 + fail=1 + fi + if [[ -n "$out" && ! -f "$out" ]]; then + echo "::error::gosec produced no SARIF output for ${mod}" >&2 + fail=1 + fi +done + +exit $fail diff --git a/scripts/security-scan.sh b/scripts/security-scan.sh index d49dfaec2..2eb734278 100755 --- a/scripts/security-scan.sh +++ b/scripts/security-scan.sh @@ -36,18 +36,15 @@ 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. print_section "1. Go Security Scanner (gosec)" -if command_exists gosec; then - if gosec -fmt=json -out="$REPORT_DIR/gosec-report.json" -exclude-dir=vendor -exclude-dir=testdata ./...; then - echo -e "${GREEN}✓ gosec scan completed${NC}" - # Also generate human-readable output - gosec -fmt=text -out="$REPORT_DIR/gosec-report.txt" -exclude-dir=vendor -exclude-dir=testdata ./... || true - else - echo -e "${RED}✗ gosec found security issues${NC}" - OVERALL_STATUS=1 - fi +if "$(dirname "$0")/run-gosec.sh" 2>&1 | tee "$REPORT_DIR/gosec-report.txt"; then + echo -e "${GREEN}✓ gosec scan completed${NC}" else - echo -e "${YELLOW}⚠ gosec not installed. Install: go install github.com/securego/gosec/v2/cmd/gosec@latest${NC}" + echo -e "${RED}✗ gosec found security issues${NC}" + OVERALL_STATUS=1 fi # 2. Static Analysis (staticcheck) diff --git a/scripts/test-run-gosec.sh b/scripts/test-run-gosec.sh new file mode 100755 index 000000000..587091573 --- /dev/null +++ b/scripts/test-run-gosec.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# test-run-gosec.sh +# +# Self-tests for scripts/run-gosec.sh and the three callers that must share its +# rule set: the pre-commit hook (scripts/gosec-hook.sh), `make security-scan-go` +# and the CI gosec step (issue #340). +# +# The run-gosec.sh cases plant a fake gosec at the script's cache path (under a +# temporary XDG_CACHE_HOME) that logs "|" per call. GOPROXY=off +# makes any attempt to install a real gosec fail fast instead of reaching the +# network. +# +# Exits 0 when all cases pass; exits 1 on any failure. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" +work="$(mktemp -d "${TMPDIR:-/tmp}/run-gosec-test.XXXXXX")" +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)" +fake_dir="$XDG_CACHE_HOME/gosec/${gosec_version}-$(go env GOVERSION)" +mkdir -p "$fake_dir" +cat >"$fake_dir/gosec" <<'FAKE' +#!/usr/bin/env bash +# Fake gosec: log the call, write the SARIF file unless told not to, and fail +# when the working directory ends in FAKE_FAIL_IN. +echo "$(pwd -P)|$*" >>"$FAKE_LOG" +prev="" +for a in "$@"; do + if [[ "$prev" == "-out" && "${FAKE_NO_SARIF:-}" != "1" ]]; then echo '{"runs":[]}' >"$a"; fi + prev="$a" +done +if [[ -n "${FAKE_FAIL_IN:-}" && "$(pwd -P)" == *"/$FAKE_FAIL_IN" ]]; then exit 1; fi +exit 0 +FAKE +chmod +x "$fake_dir/gosec" +export FAKE_LOG="$work/calls.log" + +fail=0 +pass() { echo "PASS: $1"; } +failed() { echo "FAIL: $1"; fail=1; } + +if [[ -n "$gosec_version" ]]; then + pass "run-gosec.sh pins GOSEC_VERSION ($gosec_version)" +else + failed "run-gosec.sh pins GOSEC_VERSION" +fi + +# run LABEL WANT_RC CMD... : runs CMD with a fresh log, checks its exit code. +rc=0 +run() { + local label="$1" want="$2" + shift 2 + : >"$FAKE_LOG" + rc=0 + "$@" >"$work/out.txt" 2>&1 || rc=$? + if [[ "$rc" -ne "$want" ]]; then + failed "$label (exit $rc, want $want)" + sed 's/^/ /' "$work/out.txt" + return 1 + fi +} + +# expect_calls LABEL LINE... : the log holds exactly these calls, in order. +expect_calls() { + local label="$1" + shift + local want got + want="$(printf '%s\n' "$@")" + got="$(cat "$FAKE_LOG")" + if [[ "$got" == "$want" ]]; then + pass "$label" + else + failed "$label" + printf ' want:\n%s\n got:\n%s\n' "$want" "$got" + fi +} + +E2E="$REPO_ROOT/tests/e2e" +DIRS="-exclude-dir=.legacy -exclude-dir=.dev-notes -exclude-dir=vendor" + +# --- run-gosec.sh ------------------------------------------------------------ + +if run "full scan" 0 bash "$SCRIPT_DIR/run-gosec.sh"; then + expect_calls "full scan covers every go.work module with no -exclude" \ + "$REPO_ROOT|$DIRS -quiet ./..." \ + "$E2E|$DIRS -quiet ./..." +fi + +if run "sarif scan" 0 bash "$SCRIPT_DIR/run-gosec.sh" --sarif-dir "$work/sarif"; then + expect_calls "sarif scan covers every module with no -exclude" \ + "$REPO_ROOT|$DIRS -fmt sarif -out $work/sarif/gosec-root.sarif ./..." \ + "$E2E|$DIRS -fmt sarif -out $work/sarif/gosec-tests-e2e.sarif ./..." + if [[ -f "$work/sarif/gosec-root.sarif" && -f "$work/sarif/gosec-tests-e2e.sarif" ]]; then + pass "sarif files keep the gosec-root / gosec-tests-e2e names" + else + failed "sarif files keep the gosec-root / gosec-tests-e2e names" + fi +fi + +if FAKE_FAIL_IN="${REPO_ROOT##*/}" run "a finding in the root module fails the run" 1 \ + bash "$SCRIPT_DIR/run-gosec.sh"; then + expect_calls "a finding in the root module still scans tests/e2e" \ + "$REPO_ROOT|$DIRS -quiet ./..." \ + "$E2E|$DIRS -quiet ./..." +fi + +if FAKE_NO_SARIF=1 run "a module without SARIF output fails the run" 1 \ + bash "$SCRIPT_DIR/run-gosec.sh" --sarif-dir "$work/sarif2"; then + pass "missing SARIF output is an error" +fi + +if run "selected packages" 0 bash "$SCRIPT_DIR/run-gosec.sh" \ + --module . ./internal/api ./cmd/server --module tests/e2e .; then + expect_calls "selected packages are scanned per module" \ + "$REPO_ROOT|$DIRS -quiet ./internal/api ./cmd/server" \ + "$E2E|$DIRS -quiet ." +fi + +if run "whitespace package path" 2 bash "$SCRIPT_DIR/run-gosec.sh" --module . "./a b"; then + pass "a package path with whitespace is a usage error" +fi + +if run "unknown argument" 2 bash "$SCRIPT_DIR/run-gosec.sh" -exclude=G104; then + pass "extra gosec flags such as -exclude are rejected" +fi + +# --- pre-commit hook ----------------------------------------------------------- + +# Invoked through run (below), which shellcheck cannot see. +# shellcheck disable=SC2329 +hook() { (cd "$REPO_ROOT" && bash scripts/gosec-hook.sh "$@"); } + +if run "hook maps staged files" 0 hook internal/api/router.go internal/api/claimed_row_check.go \ + cmd/server/main.go tests/e2e/e2e_test.go scripts/testdata/none.go does/not/exist.go; then + expect_calls "hook scans each changed package with the shared rule set" \ + "$REPO_ROOT|$DIRS -quiet ./cmd/server ./internal/api" \ + "$E2E|$DIRS -quiet ." +fi + +if run "hook with only deleted files" 0 hook does/not/exist.go; then + expect_calls "hook runs no scan when nothing survives filtering" +fi + +if FAKE_FAIL_IN="${REPO_ROOT##*/}" run "hook fails on a finding" 1 hook internal/api/router.go; then + pass "hook propagates gosec findings" +fi + +# --- Makefile and CI ------------------------------------------------------------ + +make_cmds="$(make --no-print-directory -C "$REPO_ROOT" -n security-scan-go 2>&1)" +if [[ "$make_cmds" == "scripts/run-gosec.sh" ]]; then + pass "make security-scan-go runs only scripts/run-gosec.sh" +else + failed "make security-scan-go runs only scripts/run-gosec.sh (got: $make_cmds)" +fi + +# The body of the CI step, up to the next step. +ci_step="$(awk ' + /^ - name: Run gosec Security Scanner$/ { on = 1; next } + on && /^ - name: / { exit } + on { print } +' "$REPO_ROOT/.github/workflows/ci.yml")" +ci_code="$(printf '%s\n' "$ci_step" | grep -v '^[[:space:]]*#' || true)" +# The literal text of the call, $RUNNER_TEMP included. +# shellcheck disable=SC2016 +if [[ "$ci_code" == *'scripts/run-gosec.sh --sarif-dir "$RUNNER_TEMP"'* ]]; then + pass "CI gosec step calls scripts/run-gosec.sh" +else + failed "CI gosec step calls scripts/run-gosec.sh" +fi +# Messages may name gosec; commands may not (a direct call, an install or a +# rule-set flag would be a second rule set). +if printf '%s\n' "$ci_code" | grep -v 'echo ' | + grep -Eq '(^[[:space:]]*|[;&|(][[:space:]]*)gosec[[:space:]]|securego/gosec|-exclude'; then + failed "CI gosec step neither installs nor runs gosec itself" +else + pass "CI gosec step neither installs nor runs gosec itself" +fi + +# No other gosec rule set may creep back in next to the shared one. +stray="$(cd "$REPO_ROOT" && grep -nE -- '-exclude=G[0-9]|GOSEC_EXCLUDE' \ + Makefile .pre-commit-config.yaml .github/workflows/*.yml scripts/*.sh 2>/dev/null \ + | grep -vE '^scripts/(run-gosec|test-run-gosec)\.sh:' || true)" +if [[ -z "$stray" ]]; then + pass "no gosec -exclude list outside run-gosec.sh" +else + failed "no gosec -exclude list outside run-gosec.sh" + printf '%s\n' "$stray" | sed 's/^/ /' +fi + +# Nor may anything else run gosec itself (a `gosec -flag` or `gosec ./...` call +# in code, not in a comment). +direct="$(cd "$REPO_ROOT" && grep -nE -- '(^|[^-/[:alnum:]_.])gosec[[:space:]]+(-|\./)' \ + Makefile .github/workflows/*.yml scripts/*.sh 2>/dev/null \ + | grep -vE '^scripts/(run-gosec|test-run-gosec)\.sh:' \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true)" +if [[ -z "$direct" ]]; then + pass "nothing but run-gosec.sh runs gosec" +else + failed "nothing but run-gosec.sh runs gosec" + printf '%s\n' "$direct" | sed 's/^/ /' +fi + +exit "$fail"