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:
Padreug 2026-05-26 09:21:27 +02:00
commit 773632562e
4 changed files with 202 additions and 7 deletions

View file

@ -43,11 +43,38 @@ This repo already wires sops-nix:
- **`secrets/`** is gitignored except for `*.yaml` (which is
encrypted) and `README.md`.
- **`configuration.nix`** imports `modules/secrets.nix`.
- **`modules/dev-env/scripts/git-hooks/pre-commit`** is the shared
secret-scanner hook. Opt in by setting
`lnbits-sensei.devEnv.gitHooks.enable = true;` — see below.
All of this is **inert until you create your first encrypted file**.
`nix flake check` stays green meanwhile because the module gates on
`builtins.pathExists`.
### The pre-commit hook (recommended)
`lnbits-sensei.devEnv.gitHooks.enable = true` installs a single
secret-scanner pre-commit hook under
`~/.local/share/lnbits-sensei/git-hooks/` and sets git's
`core.hooksPath` so **every** repo on the machine picks it up
without per-repo wiring. The hook refuses to commit:
- Lines matching `PRIVATE KEY`, `BEGIN RSA/EC/OPENSSH PRIVATE`,
`password=…`, `secret=…`, `api_key=…`, `admin_key=…`,
`AWS_SECRET_ACCESS_KEY`, `POSTGRES_PASSWORD=…` (unless the value
is `example|changeme|placeholder`).
- Files under `secrets/` matching `*.yaml` that lack a `sops:`
metadata block (i.e. unencrypted secrets files staged by accident).
False positives (legitimate matches on placeholder values, prose
comments, test fixtures) are handled via line- or block-level
`# pragma: allowlist secret` markers — same convention as gitleaks.
See the hook source for the full marker semantics.
Last resort: `git commit --no-verify` bypasses the hook entirely.
Use sparingly, after confirming the diff doesn't actually contain
secret material.
## Step-by-step
### 1. Install the tools
@ -287,10 +314,13 @@ its own, and the operator can decrypt both.
## Pitfalls
- **Don't commit unencrypted YAML under `secrets/`.** The pre-commit
hook most consumers use catches obvious cases, but it's not
foolproof. Always verify with `cat secrets/<file>.yaml` that you
see `sops:` metadata + base64 blobs, not plaintext.
- **Don't commit unencrypted YAML under `secrets/`.** The shared
pre-commit hook (enable via `devEnv.gitHooks.enable = true`)
catches the obvious cases by checking for a `sops:` metadata
block + `mac: ENC[…]` line — both are required for a real
sops-encrypted file. Always verify with `cat secrets/<file>.yaml`
that you see metadata + base64 blobs, not plaintext, before
trusting the hook.
- **Don't lose the private key.** No recovery. Back up the file at
`~/.config/sops/age/keys.txt` (or `/var/lib/sops-nix/key.txt` on
servers) somewhere safe and offline.

View file

@ -17,13 +17,31 @@
}:
let
inherit (lib) mkIf;
inherit (lib) mkIf mkMerge;
cfg = config.lnbits-sensei.devEnv;
user = config.lnbits-sensei.user;
in
{
config = mkIf cfg.enable {
config = mkIf cfg.enable (mkMerge [
# TODO(skeleton): wire scripts, systemd units, and the
# /etc/dev-env/config.sh render here. See omnixy
# modules/dev-env/config.nix for the reference shape.
};
# Shared pre-commit hook via core.hooksPath. Installs the
# secret-scanner under ~/.local/share/lnbits-sensei/git-hooks/
# and points the consumer's git config at that directory, so
# every repo on the machine picks it up automatically.
(mkIf cfg.gitHooks.enable {
home-manager.users.${user} =
{ ... }:
{
home.file.".local/share/lnbits-sensei/git-hooks/pre-commit" = {
source = ./scripts/git-hooks/pre-commit;
executable = true;
};
programs.git.settings.core.hooksPath =
"/home/${user}/.local/share/lnbits-sensei/git-hooks";
};
})
]);
}

View file

@ -123,6 +123,18 @@ in
'';
};
gitHooks = {
enable = mkEnableOption ''
Shared pre-commit hook via `core.hooksPath`. Installs a single
secret-scanner hook under
`~/.local/share/lnbits-sensei/git-hooks/pre-commit` and points
the consumer's git config there so every repo on the machine
picks it up without per-repo wiring. Refuses to commit obvious
secrets and unencrypted sops files; false positives are handled
via `# pragma: allowlist secret` markers
'';
};
claude = {
enable = mkEnableOption ''
Seed `~/dev/lnbits/CLAUDE.md` from

View 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