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
.envor 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()
Make it executable:
chmod +x .git/hooks/pre-commit
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...
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:]))
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:
- Store it in a committed directory like
.githooks/pre-commit - Add a setup step in your Makefile:
setup-hooks:
cp .githooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
Or configure git to use your hooks directory for the entire repo, which is cleaner:
git config core.hooksPath .githooks
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)