DEV Community

Pavel Espitia
Pavel Espitia

Posted on

A Local AI Pre-Commit Hook That Blocks Secrets Without Annoying You

My regex secret scanner once blocked a commit because a test file contained the string sk_test_EXAMPLE_KEY_DO_NOT_USE. The same week, a colleague on another project committed a real Etherscan API key inside a hardcoded URL, and no scanner caught it because it didn't match any known key format. That pair of failures sums up regex-based secret scanning: loud where it doesn't matter, quiet where it does.

The standard fix is an allowlist file that grows forever, plus developers who learn to type git commit --no-verify from muscle memory. Once people bypass the hook by habit, the scanner is decoration.

So I tried something different: keep the regex scanner, but add a small local LLM as a second opinion. The regex stage decides what's worth looking at. The model decides whether it's actually a secret. Only flagged files ever reach the model, so the hook stays fast, and because the model is Ollama running on my own machine, no staged diff ever leaves my laptop. That last part is non-negotiable for me, sending your possibly-secret-containing diffs to a cloud API to check for secrets is a joke that writes itself.

The architecture in one paragraph

Stage 1 is a deliberately paranoid regex pass over staged changes: high-entropy strings, known key prefixes, PRIVATE KEY blocks, suspicious variable names. Stage 2 sends each flagged hunk, with a few lines of surrounding context, to qwen2.5-coder via Ollama with a classification prompt. Verdict SECRET blocks the commit, FALSE_POSITIVE lets it through. Most commits never trigger stage 1 at all, so most commits pay zero latency.

The hook

.git/hooks/pre-commit (or wire it through your hook manager of choice):

#!/usr/bin/env bash
set -euo pipefail

MODEL="${SECRET_HOOK_MODEL:-qwen2.5-coder:7b}"

# Stage 1: cheap and paranoid. Wide patterns, we WANT false positives here.
PATTERNS=(
  'AKIA[0-9A-Z]{16}'                      # AWS access key
  '-----BEGIN( RSA| EC| OPENSSH)? PRIVATE KEY-----'
  '(api[_-]?key|secret|token|passwd|password)["'"'"']?\s*[:=]\s*["'"'"'][^"'"'"']{16,}'
  '0x[a-fA-F0-9]{64}'                     # possible EVM private key
  '[A-Za-z0-9+/]{40,}={0,2}'              # high-entropy base64-ish
)

flagged=()
while IFS= read -r file; do
  [[ -f "$file" ]] || continue
  for p in "${PATTERNS[@]}"; do
    if git show ":$file" | grep -qE "$p"; then
      flagged+=("$file")
      break
    fi
  done
done < <(git diff --cached --name-only --diff-filter=ACM)

[[ ${#flagged[@]} -eq 0 ]] && exit 0   # fast path: nothing suspicious

# Stage 2: ask the local model about each flagged file's staged content.
block=0
for file in "${flagged[@]}"; do
  verdict=$(git show ":$file" | ollama run "$MODEL" "$(cat <<'PROMPT'
You review a file staged for a git commit. Decide if it contains a REAL
credential that must not be committed.

REAL secrets: live API keys, private keys (including 0x-prefixed 64-hex
EVM keys), tokens, passwords, connection strings with embedded passwords.

NOT secrets: placeholders (YOUR_KEY_HERE, xxx, changeme), documented
example keys, test fixtures clearly labeled as fake, public addresses,
hashes of public data, template variables like ${API_KEY}, lockfile
integrity hashes.

First line of your answer must be exactly SECRET or FALSE_POSITIVE.
Second line: one short reason.
PROMPT
)")
  if [[ "$verdict" == SECRET* ]]; then
    echo "BLOCKED: $file"
    echo "$verdict" | sed -n '2p' | sed 's/^/  reason: /'
    block=1
  fi
done

if [[ $block -eq 1 ]]; then
  echo ""
  echo "Commit blocked. If this is wrong, re-run with SECRET_HOOK_MODEL"
  echo "set to a bigger model, or use --no-verify and accept the risk."
  exit 1
fi
exit 0
Enter fullscreen mode Exit fullscreen mode

Two details in there matter more than they look.

Scan the staged content, not the working tree. git show ":$file" reads the index. If you scan the file on disk, you'll block commits over unstaged scratch content, and you'll miss the case where the secret is staged but already deleted from the working copy.

The exit-code contract is the whole interface. Exit 0 commits, exit 1 blocks, and the model's freeform text never decides anything by itself. I parse only the first line and demand it be one of two tokens. Small local models will occasionally produce a paragraph of hedging, forcing a machine-readable first line is what makes them usable in a pipeline.

The prompt is mostly a list of non-secrets

Notice the prompt spends more words on what is NOT a secret than on what is. That's deliberate. The regex stage already guarantees everything the model sees looks secret-ish, so the model's real job is recognizing placeholders, fixtures, and templates. Framing it that way cut my false blocks dramatically. I learned this pattern building spectr-ai: small models do much better when you tell them what to exclude than when you ask them open-ended "is this dangerous?" questions.

The 0x + 64 hex chars pattern deserves a note for the Web3 crowd. A transaction hash, a storage slot, and a private key all look identical to a regex. The model can use the variable name and surrounding code to tell DEPLOYER_PRIVATE_KEY = 0x... apart from KNOWN_TX_HASH = 0x.... That single distinction is most of the value I get from this hook, because regex scanners either flag every 32-byte hex value in a blockchain codebase (unbearable) or none (useless).

Honest tradeoffs

Latency. On my machine (WSL2, mid-range GPU), a flagged file costs roughly two to four seconds with the 7b model once it's warm, more if Ollama has to load the model first. Clean commits pay nothing because stage 1 short-circuits. Commits that touch a .env.example or a test fixture pay a few seconds. I find that acceptable, you might not, and if your team commits forty times an hour you should keep the model stage async or advisory.

1.5b vs 7b. I tried qwen2.5-coder:1.5b first because it responds almost instantly. It was too eager to please: it labeled obviously fake fixtures as SECRET often enough that I would have started bypassing my own hook, which defeats the entire purpose. The 7b is noticeably better at reading context like "this is in tests/fixtures/ and the variable is called FAKE_KEY". For this job the model runs rarely, so I pay for the bigger one.

It can still be wrong both ways. A local 7b model is not a security boundary. A weird real key can slip through, and this hook is a complement to your platform-side scanning (GitHub push protection and friends), not a replacement. What it fixes is the human layer: the hook complains so rarely that when it does, I actually stop and look instead of reflexively reaching for --no-verify. A scanner people trust and obey beats a stricter one everybody bypasses.

Determinism. LLM verdicts can flip between runs on borderline inputs. For a blocking hook I accept that, borderline cases are exactly the ones I want a human to re-examine anyway.

I've run this for a couple of months now. The regex stage fires a few times a week, the model overrules it almost every time, and the one time it said SECRET it was right: a real RPC URL with an embedded API key inside an old test I was resurrecting.

What's your current secret-scanning setup, and be honest, how often do you bypass it?

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

Local secret blocking is strongest when it stays boring and fast. The tradeoff is avoiding alert fatigue: a hook that catches real credential-shaped mistakes and explains why it blocked will earn trust; a clever hook with too many noisy guesses gets bypassed.