diff --git a/.githooks/pre-push b/.githooks/pre-push index 5a8e702..47a97b3 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,52 +1,13 @@ #!/usr/bin/env bash -# Secret scan over the commits about to be pushed. Opt in per clone with: +# Trigger only. The checks live in the Makefile, where they can be read, run by +# hand (`make pre-push`), and changed under review. # -# git config core.hooksPath .githooks +# This file is identical in every repo in this workspace, deliberately: the call +# surface is the same everywhere even though what each gate runs is not, so +# nobody has to read a repo to find out how to check it (D-27). # -# Self-contained on purpose: this repo has no Makefile, and a hook that -# depends on one in a sibling repo breaks the moment the repo is cloned -# anywhere else. +# Enable per clone with: git config core.hooksPath .githooks +# Never bypass with --no-verify. Suppress a specific finding deliberately +# instead, with a reason — see `make pre-push`. set -euo pipefail - -cd "$(git rev-parse --show-toplevel)" - -# A non-login shell — which is what git gives a hook — skips /etc/profile.d -# and never sees ~/.local/bin, where the gitleaks release tarball lands. -# Without this the scan reports "not installed" on every push. -[ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH" - -if ! command -v gitleaks >/dev/null 2>&1; then - echo "FAIL secrets — gitleaks not installed, so this check would be a no-op pretending to pass." >&2 - echo " https://github.com/gitleaks/gitleaks/releases → ~/.local/bin/gitleaks" >&2 - exit 1 -fi - -# Scan the outgoing range, not full history. History here carries findings -# that are settled — test fixtures and vendored third-party code — and a gate -# that fails on something unfixable gets bypassed within a week. What matters -# is what is about to leave this machine. -if upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null); then - range="$upstream..HEAD" -elif git rev-parse --verify --quiet origin/main >/dev/null; then - range="origin/main..HEAD" -else - range="" -fi - -if [ -z "$range" ]; then - gitleaks dir . --redact --no-banner --exit-code 1 || { - echo "FAIL secrets — gitleaks found a credential in the working tree." >&2; exit 1; } - exit 0 -fi - -[ -n "$(git log --oneline "$range" 2>/dev/null)" ] || exit 0 - -gitleaks git . --log-opts="$range" --redact --no-banner --exit-code 1 >/dev/null 2>&1 || { - echo "FAIL secrets — gitleaks found a credential in the commits being pushed." >&2 - echo " inspect (values redacted): gitleaks git . --log-opts=\"$range\" --redact" >&2 - echo " then remove and rotate it, or suppress deliberately:" >&2 - echo " inline '# gitleaks:allow '" >&2 - echo " or add the fingerprint to .gitleaksignore WITH a reason" >&2 - exit 1 -} -echo " ok secrets" +exec make -C "$(git rev-parse --show-toplevel)" pre-push diff --git a/Makefile b/Makefile index bc88787..f39df76 100644 --- a/Makefile +++ b/Makefile @@ -28,3 +28,26 @@ test: ## Run the test suite # flake8 config, and neither in requirements. Per D-27 the name is reserved for # repos that lint; an empty target here would report clean for something never # run. Add the target when a linter is added, not before. + +# git hands a hook a non-login shell, which never sees ~/.local/bin — where +# gitleaks lands. Without this the scan reports "not installed" on every push, +# which is a check that fails open (D-24). +export PATH := $(HOME)/.local/bin:/usr/local/bin:$(PATH) + +.PHONY: secrets +secrets: ## Scan the commits about to be pushed for credentials + @ci/secrets.sh + +# The call surface is identical in every repo; what it runs is not. +# +# `secrets` runs first, deliberately: it is the only failure here that cannot be +# undone by fixing it afterwards. A failed lint costs another commit; a pushed +# credential is cached and indexed whether or not it is later deleted. +# +# Some of these fail today, and are left wired anyway. The state was measured +# once and written down in T-56 rather than being worked around here — a gate +# quietly narrowed to what already passes is a gate that reports success for +# doing nothing, which is the failure this workspace keeps rediscovering. +.PHONY: pre-push +pre-push: secrets ## Everything the pre-push hook runs + @echo " -- not gated here yet: lint (no linter configured) and test (T-56)" diff --git a/ci/secrets.sh b/ci/secrets.sh new file mode 100755 index 0000000..3f94338 --- /dev/null +++ b/ci/secrets.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Secret scan over the commits about to be pushed. +# +# Lives here rather than inside .githooks/pre-push so it can be read, run by +# hand (`make secrets`), and changed under review. A hook is a trigger; it is +# not a home for logic. Identical in every repo in this workspace (D-27). +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +# A non-login shell — which is what git gives a hook — skips /etc/profile.d +# and never sees ~/.local/bin, where the gitleaks release tarball lands. +# Without this the scan reports "not installed" on every push. +[ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH" + +if ! command -v gitleaks >/dev/null 2>&1; then + echo "FAIL secrets — gitleaks not installed, so this check would be a no-op pretending to pass." >&2 + echo " https://github.com/gitleaks/gitleaks/releases → ~/.local/bin/gitleaks" >&2 + exit 1 +fi + +# Scan the outgoing range, not full history. History here carries findings +# that are settled — test fixtures and vendored third-party code — and a gate +# that fails on something unfixable gets bypassed within a week. What matters +# is what is about to leave this machine. +if upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null); then + range="$upstream..HEAD" +elif git rev-parse --verify --quiet origin/main >/dev/null; then + range="origin/main..HEAD" +else + range="" +fi + +if [ -z "$range" ]; then + gitleaks dir . --redact --no-banner --exit-code 1 || { + echo "FAIL secrets — gitleaks found a credential in the working tree." >&2; exit 1; } + exit 0 +fi + +[ -n "$(git log --oneline "$range" 2>/dev/null)" ] || exit 0 + +gitleaks git . --log-opts="$range" --redact --no-banner --exit-code 1 >/dev/null 2>&1 || { + echo "FAIL secrets — gitleaks found a credential in the commits being pushed." >&2 + echo " inspect (values redacted): gitleaks git . --log-opts=\"$range\" --redact" >&2 + echo " then remove and rotate it, or suppress deliberately:" >&2 + echo " inline '# gitleaks:allow '" >&2 + echo " or add the fingerprint to .gitleaksignore WITH a reason" >&2 + exit 1 +} +echo " ok secrets"