|
| 1 | +#!/usr/bin/env bash |
| 2 | +# scripts/gosec-hook.sh |
| 3 | +# |
| 4 | +# Pre-commit gosec hook: runs gosec on only the Go packages that contain staged |
| 5 | +# files, resolved to their owning module. Fast by design: never scans the whole |
| 6 | +# repo; each commit triggers at most one gosec invocation per affected module. |
| 7 | +# |
| 8 | +# Version pin: gosec v2.28.0 (matches the CI pin in ci.yml, bumped by #1384). |
| 9 | +# Installed on first use to ~/.cache/pre-commit-gosec/v2.28.0/gosec; never |
| 10 | +# modifies the system-wide gosec binary. |
| 11 | +# |
| 12 | +# Called by pre-commit with pass_filenames: true and files: \.go$. |
| 13 | +# Exits 0 when no staged .go files survive filtering (deleted / testdata). |
| 14 | +# Exits 1 on any gosec finding; exits 2 on setup failure. |
| 15 | +# |
| 16 | +# Exclusion rationale (kept in sync with .pre-commit-config.yaml): |
| 17 | +# G101 - variable names containing password/secret/token -> false positives |
| 18 | +# G104 - unchecked errors -> covered by errcheck |
| 19 | +# G115 - integer overflow -> safe conversions flagged |
| 20 | +# G117 - unsafe pointer arithmetic -> vendor/generated code |
| 21 | +# G118 - net/http serve without timeout -> timeouts set at handler level |
| 22 | +# G122 - unsafe operations -> low-level helpers, pre-existing |
| 23 | +# G204 - subprocess with variable -> CLI tool needs dynamic commands |
| 24 | +# G301 - dir permissions > 0750 -> acceptable for dev tooling |
| 25 | +# G304 - file path from variable -> CLI reads user-specified paths |
| 26 | +# G402 - TLS MinVersion not set -> handled by cloud SDK defaults |
| 27 | +# G505 - import of crypto/sha1 -> checksums, not security primitives |
| 28 | +# G702 - TLS InsecureSkipVerify -> test helpers only, pre-existing |
| 29 | +# G703 - unhandled defer error -> deferred close errors logged separately |
| 30 | +# G705 - unhandled goroutine error -> pre-existing pattern |
| 31 | +# G706 - ignored errors -> pre-existing; covered by go vet errcheck |
| 32 | + |
| 33 | +set -euo pipefail |
| 34 | + |
| 35 | +GOSEC_VERSION="2.28.0" |
| 36 | +GOSEC_BIN="${HOME}/.cache/pre-commit-gosec/v${GOSEC_VERSION}/gosec" |
| 37 | + |
| 38 | +GOSEC_EXCLUDE="G101,G104,G115,G117,G118,G122,G204,G301,G304,G402,G505,G702,G703,G705,G706" |
| 39 | + |
| 40 | +# Module roots in longest-prefix order (so "providers/azure" is checked before |
| 41 | +# a hypothetical "providers" root). Must mirror the per-module loop in |
| 42 | +# .github/workflows/ci.yml (root, pkg, providers/{aws,azure,gcp}, tests/e2e) |
| 43 | +# so a changed file under tests/e2e is scanned from within its own module. |
| 44 | +MODULE_DIRS="tests/e2e providers/azure providers/aws providers/gcp pkg" |
| 45 | + |
| 46 | +# ---- helpers ---------------------------------------------------------------- |
| 47 | + |
| 48 | +ensure_gosec() { |
| 49 | + local need_install=0 |
| 50 | + if [[ -x "$GOSEC_BIN" ]]; then |
| 51 | + # gosec built via `go install` embeds "dev" in -h regardless of tag; |
| 52 | + # read the real module version from the binary's build info instead. |
| 53 | + local installed_ver |
| 54 | + installed_ver=$(go version -m "$GOSEC_BIN" 2>/dev/null \ |
| 55 | + | awk '$1=="mod" && $2~/gosec/{print $3}') |
| 56 | + # installed_ver is e.g. "v2.28.0"; compare against "v$GOSEC_VERSION". |
| 57 | + if [[ "$installed_ver" != "v${GOSEC_VERSION}" ]]; then |
| 58 | + echo "pre-commit/gosec: cached binary is ${installed_ver:-unknown}, need v${GOSEC_VERSION}; reinstalling" >&2 |
| 59 | + need_install=1 |
| 60 | + fi |
| 61 | + else |
| 62 | + need_install=1 |
| 63 | + fi |
| 64 | + |
| 65 | + if [[ $need_install -eq 1 ]]; then |
| 66 | + echo "pre-commit/gosec: installing gosec@v${GOSEC_VERSION} -> $(dirname "$GOSEC_BIN")" >&2 |
| 67 | + mkdir -p "$(dirname "$GOSEC_BIN")" |
| 68 | + GOBIN="$(dirname "$GOSEC_BIN")" go install \ |
| 69 | + "github.com/securego/gosec/v2/cmd/gosec@v${GOSEC_VERSION}" || { |
| 70 | + echo "pre-commit/gosec: install failed (is Go on PATH?)" >&2 |
| 71 | + exit 2 |
| 72 | + } |
| 73 | + fi |
| 74 | +} |
| 75 | + |
| 76 | +# Print the module root (relative to repo root) that owns a given relative file |
| 77 | +# path, or empty string when the file belongs to the root module. |
| 78 | +module_for() { |
| 79 | + local f="$1" mod |
| 80 | + for mod in $MODULE_DIRS; do |
| 81 | + case "$f" in |
| 82 | + "$mod"/*) printf '%s' "$mod"; return ;; |
| 83 | + esac |
| 84 | + done |
| 85 | + printf '' |
| 86 | +} |
| 87 | + |
| 88 | +# ---- main ------------------------------------------------------------------- |
| 89 | + |
| 90 | +[[ $# -eq 0 ]] && exit 0 |
| 91 | + |
| 92 | +REPO_ROOT="$(git rev-parse --show-toplevel)" |
| 93 | + |
| 94 | +# Filter: skip deleted files and files under testdata/. |
| 95 | +live_files=() |
| 96 | +for f in "$@"; do |
| 97 | + [[ -f "$f" ]] || continue |
| 98 | + case "$f" in |
| 99 | + */testdata/*) continue ;; |
| 100 | + testdata/*) continue ;; |
| 101 | + esac |
| 102 | + live_files+=("$f") |
| 103 | +done |
| 104 | + |
| 105 | +[[ ${#live_files[@]} -eq 0 ]] && exit 0 |
| 106 | + |
| 107 | +ensure_gosec |
| 108 | + |
| 109 | +# Build "module|package" pairs from the live file list. |
| 110 | +# Package path is relative to the owning module root, in ./pkg notation. |
| 111 | +pairs_raw=() |
| 112 | +for f in "${live_files[@]}"; do |
| 113 | + mod=$(module_for "$f") |
| 114 | + pkg_dir=$(dirname "$f") |
| 115 | + |
| 116 | + if [[ -n "$mod" ]]; then |
| 117 | + # Strip the module prefix (plus the separating slash). |
| 118 | + rel="${pkg_dir:$((${#mod}+1))}" |
| 119 | + [[ -z "$rel" ]] && rel="." # file sits directly in the module root |
| 120 | + else |
| 121 | + rel="$pkg_dir" # root module; pkg_dir is already relative |
| 122 | + fi |
| 123 | + |
| 124 | + # Normalise to go-tool notation. |
| 125 | + if [[ "$rel" == "." ]]; then |
| 126 | + pkg="." |
| 127 | + else |
| 128 | + pkg="./$rel" |
| 129 | + fi |
| 130 | + |
| 131 | + pairs_raw+=("${mod}|${pkg}") |
| 132 | +done |
| 133 | + |
| 134 | +# Deduplicate. |
| 135 | +pairs_sorted=$(printf '%s\n' "${pairs_raw[@]}" | sort -u) |
| 136 | + |
| 137 | +# Enumerate unique module roots. |
| 138 | +mods=$(printf '%s\n' "$pairs_sorted" | cut -d'|' -f1 | sort -u) |
| 139 | + |
| 140 | +fail=0 |
| 141 | +while IFS= read -r mod; do |
| 142 | + pkgs=$(printf '%s\n' "$pairs_sorted" \ |
| 143 | + | awk -F'|' -v m="$mod" '$1==m{print $2}' \ |
| 144 | + | tr '\n' ' ') |
| 145 | + mod_dir="${REPO_ROOT}${mod:+/${mod}}" |
| 146 | + |
| 147 | + echo "gosec [${mod:-.}]: scanning package(s): $pkgs" >&2 |
| 148 | + |
| 149 | + # pkgs intentionally unquoted: space-separated package paths, no glob chars. |
| 150 | + # shellcheck disable=SC2086 |
| 151 | + if ! (cd "$mod_dir" && "$GOSEC_BIN" \ |
| 152 | + -quiet \ |
| 153 | + -exclude-dir=.legacy \ |
| 154 | + -exclude-dir=.dev-notes \ |
| 155 | + -exclude-dir=vendor \ |
| 156 | + "-exclude=${GOSEC_EXCLUDE}" \ |
| 157 | + $pkgs); then |
| 158 | + fail=1 |
| 159 | + fi |
| 160 | +done <<< "$mods" |
| 161 | + |
| 162 | +exit $fail |
0 commit comments