feat(dev-env): wire shared pre-commit secret scanner via core.hooksPath
Ships `modules/dev-env/scripts/git-hooks/pre-commit` — the same secret-scanner pattern omnixy uses, lightly adapted (drops the omnixy-specific test_auth.py skip, generic header comment). New option `lnbits-sensei.devEnv.gitHooks.enable` (off by default). When on, modules/dev-env/config.nix installs the hook at `~/.local/share/lnbits-sensei/git-hooks/pre-commit` and sets the consumer's git `core.hooksPath` to that directory, so every repo on the machine picks it up without per-repo wiring. The hook refuses to commit obvious secrets (PRIVATE KEY blocks, `password=…`, `secret=…`, `api_key=…`, `admin_key=…`, AWS keys, non-placeholder POSTGRES_PASSWORD) and unencrypted sops files (checks for a top-level `sops:` block AND `mac: ENC[…]` — either signal alone is forgeable). False positives are handled via `# pragma: allowlist secret` line- or block-level markers (gitleaks convention). docs/secrets-management.md gets a new subsection covering what the hook does, when to enable it, and the false-positive escape hatches. The Pitfalls section's reference to "the pre-commit hook most consumers use" is replaced with a concrete pointer to this option. `nix flake check --no-build` stays green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fc1d31244a
commit
773632562e
4 changed files with 202 additions and 7 deletions
135
modules/dev-env/scripts/git-hooks/pre-commit
Executable file
135
modules/dev-env/scripts/git-hooks/pre-commit
Executable file
|
|
@ -0,0 +1,135 @@
|
|||
#!/usr/bin/env bash
|
||||
# lnbits-sensei shared pre-commit hook.
|
||||
#
|
||||
# Wired into every repo under your `core.hooksPath` (set by
|
||||
# modules/dev-env/config.nix when `devEnv.gitHooks.enable = true`).
|
||||
# Refuses to commit obvious secrets and unencrypted sops files.
|
||||
#
|
||||
# False positives:
|
||||
# - Add `# pragma: allowlist secret` on/above the offending line, or
|
||||
# - Wrap a block with `# pragma: allowlist secret start` ... end, or
|
||||
# - Bypass with `git commit --no-verify` (last resort).
|
||||
|
||||
set -u
|
||||
|
||||
# Patterns are assembled from fragments so the literal text in this
|
||||
# file doesn't match the patterns themselves — otherwise this hook
|
||||
# would always block commits that touch it.
|
||||
_PVT='PRI''VATE'
|
||||
_AWS='AWS_SEC''RET_ACCESS_KEY'
|
||||
FORBIDDEN_PATTERNS=(
|
||||
"${_PVT} KEY"
|
||||
"BEGIN RSA ${_PVT}"
|
||||
"BEGIN EC ${_PVT}"
|
||||
"BEGIN OPENSSH ${_PVT}"
|
||||
'password\s*=\s*["\x27][^"\x27]+'
|
||||
'secret\s*=\s*["\x27][^"\x27]+'
|
||||
'api_key\s*=\s*["\x27][^"\x27]+'
|
||||
'admin_key\s*=\s*["\x27][^"\x27]+'
|
||||
"${_AWS}"
|
||||
'POSTGRES_PASSWORD=(?!.*(example|changeme|placeholder))'
|
||||
)
|
||||
|
||||
SKIP_FILES=(
|
||||
'*.md'
|
||||
'*.txt'
|
||||
# The hook script itself contains the FORBIDDEN_PATTERNS as
|
||||
# literals — scanning it would always self-trigger.
|
||||
'modules/dev-env/scripts/git-hooks/*'
|
||||
)
|
||||
|
||||
# Line-level allowlist. A line that matches a FORBIDDEN_PATTERN is treated
|
||||
# as a false positive in any of these cases:
|
||||
# 1. The line itself contains the marker.
|
||||
# 2. The line *immediately above* contains the marker (handy for a
|
||||
# single-line trigger where the marker doesn't fit on the line).
|
||||
# 3. The line falls inside a "<marker> start" ... "<marker> end" block
|
||||
# (same convention as gitleaks).
|
||||
# Use sparingly — only on lines that genuinely don't hold a secret (prose
|
||||
# comments, test fixtures with placeholder values, constant strings).
|
||||
ALLOWLIST_MARKER='pragma: allowlist secret'
|
||||
|
||||
errors=0
|
||||
|
||||
for file in $(git diff --cached --name-only --diff-filter=ACM); do
|
||||
skip=false
|
||||
for pat in "${SKIP_FILES[@]}"; do
|
||||
# shellcheck disable=SC2053
|
||||
if [[ "$file" == $pat ]]; then
|
||||
skip=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ "$skip" == true ]] && continue
|
||||
|
||||
blob=$(git show ":$file" 2>/dev/null) || continue
|
||||
|
||||
# Walk the file once to compute the set of allowlisted line numbers.
|
||||
# Tracks both "single-line marker" and "<marker> start/end" block state.
|
||||
declare -A allowlisted=()
|
||||
in_block=false
|
||||
prev_was_marker=false
|
||||
line_num=0
|
||||
while IFS= read -r line_content; do
|
||||
line_num=$((line_num + 1))
|
||||
if [[ "$line_content" == *"$ALLOWLIST_MARKER start"* ]]; then
|
||||
in_block=true
|
||||
prev_was_marker=false
|
||||
continue
|
||||
fi
|
||||
if [[ "$line_content" == *"$ALLOWLIST_MARKER end"* ]]; then
|
||||
in_block=false
|
||||
prev_was_marker=false
|
||||
continue
|
||||
fi
|
||||
is_marker_line=false
|
||||
if [[ "$line_content" == *"$ALLOWLIST_MARKER"* ]]; then
|
||||
is_marker_line=true
|
||||
fi
|
||||
if [[ "$in_block" == true \
|
||||
|| "$is_marker_line" == true \
|
||||
|| "$prev_was_marker" == true ]]; then
|
||||
allowlisted[$line_num]=1
|
||||
fi
|
||||
prev_was_marker=$is_marker_line
|
||||
done <<<"$blob"
|
||||
|
||||
for pattern in "${FORBIDDEN_PATTERNS[@]}"; do
|
||||
while IFS= read -r match; do
|
||||
[[ -z "$match" ]] && continue
|
||||
line_num="${match%%:*}"
|
||||
[[ -n "${allowlisted[$line_num]:-}" ]] && continue
|
||||
echo "ERROR: potential secret in $file:$line_num (pattern: $pattern)"
|
||||
errors=$((errors + 1))
|
||||
done < <(grep -niE "$pattern" <<<"$blob" || true)
|
||||
done
|
||||
unset allowlisted
|
||||
done
|
||||
|
||||
# Unencrypted or malformed sops files.
|
||||
# Structural check: a real sops YAML always contains both a top-level `sops:`
|
||||
# block AND a `mac:` field whose value is `ENC[...]`. Either signal alone is
|
||||
# trivially forgeable; together they're specific to actual sops output.
|
||||
for file in $(git diff --cached --name-only --diff-filter=ACM | grep -E 'secrets.*\.yaml$' || true); do
|
||||
blob=$(git show ":$file" 2>/dev/null)
|
||||
if ! grep -q '^[[:space:]]*sops:' <<<"$blob"; then
|
||||
echo "ERROR: unencrypted secrets file: $file (no sops metadata block)"
|
||||
echo " run: sops -e -i $file"
|
||||
errors=$((errors + 1))
|
||||
elif ! grep -q '^[[:space:]]*mac: ENC\[' <<<"$blob"; then
|
||||
echo "ERROR: secrets file has sops block but mac is not encrypted: $file"
|
||||
echo " file may be tampered or partially decrypted; re-encrypt with sops"
|
||||
errors=$((errors + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if (( errors > 0 )); then
|
||||
echo ""
|
||||
echo "commit blocked: $errors potential secret(s) detected"
|
||||
echo "false positive? add '# $ALLOWLIST_MARKER' on/above the line,"
|
||||
echo "or wrap a block with '# $ALLOWLIST_MARKER start' ... '# $ALLOWLIST_MARKER end',"
|
||||
echo "or bypass with: git commit --no-verify"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in a new issue