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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 22 additions & 27 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -714,52 +714,47 @@ 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)
# skip Go SAST coverage. The job still fails if gosec itself fails.
# 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
Expand Down
7 changes: 2 additions & 5 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 3 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand Down
94 changes: 19 additions & 75 deletions scripts/gosec-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 ;;
Expand All @@ -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=()
Expand Down Expand Up @@ -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[@]}"
132 changes: 132 additions & 0 deletions scripts/run-gosec.sh
Original file line number Diff line number Diff line change
@@ -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-<tag>.sarif instead of printing text findings,
# where <tag> 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
Loading
Loading