From 773632562e72d12074ec002114c56631fe2beeae Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 26 May 2026 09:21:27 +0200 Subject: [PATCH] feat(dev-env): wire shared pre-commit secret scanner via core.hooksPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/secrets-management.md | 38 +++++- modules/dev-env/config.nix | 24 +++- modules/dev-env/options.nix | 12 ++ modules/dev-env/scripts/git-hooks/pre-commit | 135 +++++++++++++++++++ 4 files changed, 202 insertions(+), 7 deletions(-) create mode 100755 modules/dev-env/scripts/git-hooks/pre-commit diff --git a/docs/secrets-management.md b/docs/secrets-management.md index b94509e..bffe712 100644 --- a/docs/secrets-management.md +++ b/docs/secrets-management.md @@ -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/.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/.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. diff --git a/modules/dev-env/config.nix b/modules/dev-env/config.nix index 5342d27..50b05b8 100644 --- a/modules/dev-env/config.nix +++ b/modules/dev-env/config.nix @@ -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"; + }; + }) + ]); } diff --git a/modules/dev-env/options.nix b/modules/dev-env/options.nix index 03425cb..e8aa95b 100644 --- a/modules/dev-env/options.nix +++ b/modules/dev-env/options.nix @@ -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 diff --git a/modules/dev-env/scripts/git-hooks/pre-commit b/modules/dev-env/scripts/git-hooks/pre-commit new file mode 100755 index 0000000..b6dc0c4 --- /dev/null +++ b/modules/dev-env/scripts/git-hooks/pre-commit @@ -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 " start" ... " 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 " 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