DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Building a Secrets Detection Pre-Commit Hook in 50 Lines

Secrets leaking into git repositories cause a disproportionate share of real-world breaches. AWS keys committed to a public GitHub repo get scraped by bots within minutes — sometimes seconds. The fix is to catch them before they ever reach the commit, not after.

Why a pre-commit hook?

CI/CD scanners catch leaks after the fact. The commit is already in history at that point, and you need to rotate the secret, scrub the history with git filter-branch or BFG, and hope no bot grabbed it in the window between push and detection. A pre-commit hook runs locally before the commit lands — zero-latency interception.

There are heavy tools like truffleHog, gitleaks, and detect-secrets. They're excellent for large organizations. But if you want something lightweight, auditable, and easy to customize for your team's specific patterns, 50 lines of Python gets you further than you'd think — and it's code your whole team can read and modify.

What we're detecting

The hook targets the most common categories of accidentally committed secrets:

  • AWS access key IDs (AKIA... followed by 16 alphanumeric characters)
  • Generic API keys and tokens assigned to common variable names
  • PEM-encoded private keys
  • Passwords assigned in .env or config files
  • Platform-specific tokens (Slack, GitHub)

A regex-based approach covers these reliably without needing a trained model or external API call. It runs in milliseconds.

The hook script

Create .git/hooks/pre-commit with the following content:

#!/usr/bin/env python3
import re
import subprocess
import sys

PATTERNS = {
    "AWS Access Key":  r"AKIA[0-9A-Z]{16}",
    "AWS Secret Key":  r"(?i)(aws_secret_access_key|aws_secret)\s*[=:]\s*[A-Za-z0-9/+=]{40}",
    "Generic API Key": r"(?i)(api_key|api_secret|secret_key|access_token|auth_token)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{20,}",
    "Private Key":     r"-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----",
    "Password in env": r"(?i)(password|passwd|pwd)\s*[=:]\s*['\"]?.{8,}",
    "Slack Token":     r"xox[baprs]-[0-9A-Za-z]{10,48}",
    "GitHub Token":    r"gh[pousr]_[A-Za-z0-9]{36,255}",
    "Generic Bearer":  r"(?i)bearer\s+[A-Za-z0-9\-._~+/]+=*",
}

IGNORED_EXTENSIONS = {
    ".lock", ".sum", ".mod", ".png", ".jpg",
    ".jpeg", ".gif", ".ico", ".woff", ".woff2",
}


def get_staged_diff():
    result = subprocess.run(
        ["git", "diff", "--cached", "--unified=0"],
        capture_output=True, text=True,
    )
    return result.stdout


def parse_added_lines(diff):
    # Returns (filename, line_number, content) for added lines only
    hits = []
    current_file = None
    line_number = 0
    for line in diff.splitlines():
        if line.startswith("+++ b/"):
            current_file = line[6:]
            ext = "." + current_file.rsplit(".", 1)[-1] if "." in current_file else ""
            if ext in IGNORED_EXTENSIONS:
                current_file = None
        elif line.startswith("@@ "):
            match = re.search(r"\+(\d+)", line)
            line_number = int(match.group(1)) - 1 if match else 0
        elif current_file and line.startswith("+") and not line.startswith("+++"):
            line_number += 1
            hits.append((current_file, line_number, line[1:]))
    return hits


def scan_lines(lines):
    findings = []
    for filename, lineno, content in lines:
        for pattern_name, pattern in PATTERNS.items():
            if re.search(pattern, content):
                findings.append({
                    "file":    filename,
                    "line":    lineno,
                    "pattern": pattern_name,
                    "snippet": content.strip()[:80],
                })
    return findings


def main():
    diff = get_staged_diff()
    if not diff:
        sys.exit(0)

    lines = parse_added_lines(diff)
    findings = scan_lines(lines)

    if findings:
        print("\n[BLOCKED] Possible secrets detected:\n")
        for f in findings:
            print(f"  [{f['pattern']}] {f['file']}:{f['line']}")
            print(f"    -> {f['snippet']}\n")
        print("Fix the issue or use `git commit --no-verify` to bypass (with risk).")
        sys.exit(1)

    sys.exit(0)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Make it executable:

chmod +x .git/hooks/pre-commit
Enter fullscreen mode Exit fullscreen mode

That's the whole hook — 50 lines of actual logic, zero dependencies beyond the standard library.

Testing it

Create a test file with a fake secret and try to commit it:

echo 'AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' > .env.local
git add .env.local
git commit -m "test"
# => [BLOCKED] Possible secrets detected:
#     [AWS Secret Key] .env.local:1
#     -> AWS_SECRET_ACCESS_KEY=wJalrXUtnFEM...
Enter fullscreen mode Exit fullscreen mode

The hook only scans added lines in staged changes, not the full file. This is intentional: it won't trigger on secrets already in history (that's a separate audit problem), and it won't fire on deletions. You're intercepting new leaks, not relitigating old ones.

Handling false positives

The Password in env and Generic API Key patterns are intentionally broad, which means they'll occasionally flag:

  • Example credentials in README files (password=changeme123)
  • Test fixtures with realistic-looking but fake tokens
  • SDK documentation embedded in source

The cleanest solution for legitimate false positives is a # nosecret inline comment convention. Extend the scanner to skip lines containing that marker:

elif current_file and line.startswith("+") and not line.startswith("+++"):
    line_number += 1
    if "nosecret" not in line.lower():
        hits.append((current_file, line_number, line[1:]))
Enter fullscreen mode Exit fullscreen mode

This gives developers a documented escape hatch that doesn't require bypassing the entire hook.

Distributing the hook to your team

The .git/hooks/ directory is not tracked by git. To distribute the hook:

  1. Store it in a committed directory like .githooks/pre-commit
  2. Add a setup step in your Makefile:
setup-hooks:
    cp .githooks/pre-commit .git/hooks/pre-commit
    chmod +x .git/hooks/pre-commit
Enter fullscreen mode Exit fullscreen mode

Or configure git to use your hooks directory for the entire repo, which is cleaner:

git config core.hooksPath .githooks
Enter fullscreen mode Exit fullscreen mode

This last approach requires no file copying and works automatically once developers run the one-time configuration command. Put it in your onboarding docs.

Extending the patterns

Some teams add patterns for their internal token formats. If your platform issues tokens like MYAPP_[a-z0-9]{32}, add it to PATTERNS. Keep the list small and precise — broad patterns generate false positives, and false positives teach developers to habitually --no-verify. That defeats the entire purpose.

For a broader defense-in-depth posture, this hook pairs well with a CI-level scanner (truffleHog, gitleaks) as a backstop. The pre-commit hook catches accidental leaks locally; the CI scanner catches anything that slips through on branches, forks, or rebases. Both layers together give you meaningful coverage without relying entirely on developers' vigilance.

You can find a checklist covering pre-commit hooks, CI scanning, secret rotation policies, and vault integration at our free security hardening checklists.

The takeaway

A pre-commit hook doesn't replace a full secrets management strategy — Vault, AWS Secrets Manager, or environment-based injection handle the architectural side. But this hook stops the most common failure mode: accidentally staging a .env file or hardcoding a key during late-night debugging and forgetting to remove it before committing.

Fifty lines, no external dependencies, no CI changes required, deployable in under thirty minutes. It's the lowest-effort security control with one of the highest payoff-to-effort ratios you'll find. Add it to your team's standard repo template and stop thinking about it.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)