Pre-Commit Hooks for Security Enforcement in Development Workflows
Problem
A secret committed to a repository is harder to contain than a secret that never leaves a developer’s terminal. Even if a repository is private, secrets committed to git history persist across clones, forks, CI caches, and backup snapshots. By the time a CI scanner raises an alert, the secret has already traversed the network, touched a remote, and been visible to anyone with repository access.
The same applies to infrastructure misconfigurations and known vulnerable code patterns. A Terraform module with overly permissive IAM, a Dockerfile running as root, or a Python function calling eval() on untrusted input: these are all cheaper to fix before they enter the commit graph than after they have been reviewed, merged, and deployed.
Pre-commit hooks move security checks to the earliest possible point in the development loop — the moment a developer runs git commit. The feedback is immediate, the fix cost is low, and the developer context (what they were just working on) is still fresh. This is the practical meaning of shifting security left.
Target systems: git 2.x; the pre-commit framework (Python); detect-secrets, gitleaks, git-secrets, checkov, hadolint, bandit, shellcheck; husky (Node.js); server-side git hooks.
Threat Model
- Adversary 1 — Accidental secret commit: A developer hard-codes a database password or API key while debugging, intends to remove it before committing, and forgets. The secret reaches the remote repository, where CI logs, pull request previews, and other consumers see it.
- Adversary 2 — Misconfigured IaC committed and merged: A Terraform change opens an S3 bucket to
"*"or a Kubernetes manifest drops all security contexts. No hook checks the IaC before it is committed; the change goes undetected until a reviewer notices — or it does not. - Adversary 3 — Vulnerable code pattern introduced: A developer introduces a shell injection via unsanitised
subprocess.call(user_input, shell=True). Bandit would flag it; without a pre-commit hook, the pattern reaches code review where it may or may not be caught by a reviewer unfamiliar with Python security. - Access level: These are insider-risk or unintentional-error scenarios. The developer has full repository write access. No external attacker involvement is needed.
- Objective: Detect and block the commit locally, before it is pushed to any remote.
- Blast radius: Without hooks, secrets in private repositories have a containment window of seconds (time to push). In public repositories, secret scanners operated by credential providers (GitHub, AWS, etc.) detect and invalidate known formats, but not all credentials have automatic revocation.
Configuration
Step 1: Install the pre-commit Framework
The pre-commit framework (https://pre-commit.com) manages hook installation, virtualenv isolation, and versioning for a collection of hooks defined in a single YAML file. It is the standard approach for polyglot repositories.
# Install system-wide or into a project virtual environment.
pip install pre-commit
# Verify.
pre-commit --version
Step 2: Create .pre-commit-config.yaml
Place this file at the repository root. It is checked into version control so every developer runs the same hook versions.
# .pre-commit-config.yaml
# Pin hook versions to avoid unexpected behaviour on updates.
# Run `pre-commit autoupdate` periodically to advance pinned revs.
repos:
# ── Secret detection ───────────────────────────────────────────────────────
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ["--baseline", ".secrets.baseline"]
# Exclude generated files and known-safe test fixtures.
exclude: >
(?x)^(
tests/fixtures/.*|
\.secrets\.baseline$
)$
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks
# ── Infrastructure as Code ─────────────────────────────────────────────────
- repo: https://github.com/bridgecrewio/checkov
rev: 3.2.400
hooks:
- id: checkov
args:
- "--quiet"
- "--compact"
# Fail on HIGH and CRITICAL only during development.
# Tighten to MEDIUM in CI.
- "--check"
- "HIGH,CRITICAL"
files: \.(tf|tfvars|json|yaml|yml)$
# ── Dockerfile linting ─────────────────────────────────────────────────────
- repo: https://github.com/hadolint/hadolint
rev: v2.13.1-beta
hooks:
- id: hadolint-docker
args:
# Deny specific rules that have security implications.
- "--failure-threshold"
- "warning"
- "--deny"
- "DL3002" # Last USER should not be root.
- "--deny"
- "DL3008" # Pin package versions in apt-get.
# ── Python security ────────────────────────────────────────────────────────
- repo: https://github.com/PyCQA/bandit
rev: 1.8.3
hooks:
- id: bandit
args: ["-c", "pyproject.toml"]
files: \.py$
exclude: tests/
# ── Shell scripts ──────────────────────────────────────────────────────────
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.10.0
hooks:
- id: shellcheck
args: ["--severity=warning"]
# ── General hygiene ────────────────────────────────────────────────────────
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-added-large-files
args: ["--maxkb=500"]
- id: check-merge-conflict
- id: check-yaml
- id: check-json
- id: detect-private-key
- id: end-of-file-fixer
- id: mixed-line-ending
- id: no-commit-to-branch
args: ["--branch", "main", "--branch", "master"]
Install the hooks into the local git repository:
pre-commit install
pre-commit install --hook-type commit-msg
pre-commit install --hook-type pre-push
Step 3: Initialise a detect-secrets Baseline
detect-secrets works by comparing staged content against a baseline of known-safe patterns. Initialise the baseline on first setup to avoid false positives from existing content.
# Scan the repo and create an initial baseline.
# Review the output before committing — it lists every potential secret found.
detect-secrets scan > .secrets.baseline
# Audit the baseline: mark each finding as a true or false positive.
detect-secrets audit .secrets.baseline
# Commit the baseline.
git add .secrets.baseline
git commit -m "chore: add detect-secrets baseline"
On subsequent commits, detect-secrets flags only new potential secrets not in the baseline.
Step 4: Configure Bandit via pyproject.toml
Bandit severity levels map to security impact. Configure skip lists carefully — skipping a test globally is the wrong response to a false positive.
# pyproject.toml
[tool.bandit]
exclude_dirs = ["tests", "venv", ".venv"]
# Skips: none by default. Add only after documented review.
# skips = ["B101"] # B101 = assert statements — only skip in test code.
severity = "medium"
confidence = "medium"
For test directories, assert statements are expected. Use inline suppression rather than a global skip:
# In test code only — never in production code paths.
assert response.status_code == 200 # noqa: S101
Step 5: Enforce Installation Across the Team
The pre-commit framework only runs hooks in repositories where pre-commit install has been executed. A developer who clones the repository and never runs the install command gets no hooks. Enforcement requires making the install step unavoidable.
Makefile target:
# Makefile
.PHONY: setup
setup: ## Set up the development environment.
@command -v pre-commit >/dev/null 2>&1 || pip install pre-commit
pre-commit install
pre-commit install --hook-type commit-msg
pre-commit install --hook-type pre-push
@echo "pre-commit hooks installed."
# Make setup a prerequisite for common targets.
.PHONY: test
test: setup
pytest
.PHONY: lint
lint: setup
ruff check .
Onboarding script (run once after cloning):
#!/usr/bin/env bash
# scripts/dev-setup.sh
# Run this after cloning the repository.
set -euo pipefail
echo "Installing development dependencies..."
# Python tooling.
pip install -r requirements-dev.txt
# pre-commit hooks.
pre-commit install
pre-commit install --hook-type commit-msg
pre-commit install --hook-type pre-push
# Verify hooks are active.
if ! grep -q "pre-commit" .git/hooks/pre-commit 2>/dev/null; then
echo "ERROR: pre-commit hook installation failed."
exit 1
fi
echo "Setup complete. Hooks are active."
CI verification that hooks ran:
In CI, run all hooks against the diff between the PR branch and the base branch. This catches cases where a developer bypassed hooks locally.
# .github/workflows/pre-commit-ci.yml
name: pre-commit checks
on:
pull_request:
push:
branches: [main]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install pre-commit
run: pip install pre-commit
- name: Run hooks against changed files
# Uses the official pre-commit CI action which caches hook environments.
uses: pre-commit/action@v3.0.1
- name: Run hooks against all files (weekly full scan)
if: github.event_name == 'schedule'
run: pre-commit run --all-files
The pre-commit/action action caches virtualenvs by hook repo and revision, keeping CI fast. The weekly full-file run catches issues in files that were never touched in recent PRs.
Step 6: Commit-Msg Hooks
A commit-msg hook validates the commit message itself before the commit is recorded. Use it to enforce conventional commit format and ticket references — both have security relevance (ticket references are required for change management traceability; conventional commit types like fix: and feat: feed automated changelogs and release notes).
#!/usr/bin/env bash
# .git/hooks/commit-msg
# Or manage via pre-commit with a commit-msg hook.
set -euo pipefail
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Conventional commits pattern.
CONVENTIONAL_PATTERN="^(feat|fix|docs|style|refactor|test|chore|ci|revert|perf|security)(\(.+\))?: .{1,72}"
if ! echo "$COMMIT_MSG" | grep -qP "$CONVENTIONAL_PATTERN"; then
echo "ERROR: Commit message does not follow Conventional Commits format."
echo " Expected: <type>(<scope>): <description>"
echo " Example: fix(auth): prevent token reuse after logout"
echo " Types: feat|fix|docs|style|refactor|test|chore|ci|revert|perf|security"
exit 1
fi
# Ticket reference check (optional — enable if your team uses a tracker).
# TICKET_PATTERN="(JIRA-[0-9]+|GH-[0-9]+|#[0-9]+)"
# if ! echo "$COMMIT_MSG" | grep -qP "$TICKET_PATTERN"; then
# echo "ERROR: Commit message must reference a ticket (e.g. JIRA-1234)."
# exit 1
# fi
exit 0
Manage the commit-msg hook through pre-commit by adding a local hook:
# In .pre-commit-config.yaml, add:
- repo: local
hooks:
- id: conventional-commit-msg
name: Conventional commit message format
language: script
entry: scripts/check-commit-msg.sh
stages: [commit-msg]
pass_filenames: false
Step 7: Pre-Push Hooks for Slower Checks
Pre-commit hooks run on every commit and must be fast (under a few seconds) to avoid disrupting developer flow. Slower checks belong in pre-push hooks, which run once when the developer runs git push.
# In .pre-commit-config.yaml, add stages to slower hooks:
- repo: https://github.com/bridgecrewio/checkov
rev: 3.2.400
hooks:
- id: checkov
stages: [pre-push] # Run at push, not every commit.
args: ["--quiet", "--compact"]
files: \.(tf|tfvars)$
# Install the pre-push hook.
pre-commit install --hook-type pre-push
Pre-push hooks are appropriate for:
- Full Checkov or tfsec scans of all IaC (slow for large repos)
- Dependency vulnerability scans (
pip-audit,npm audit) - Integration test runs
- Container image builds and scans
Step 8: Husky for Node.js Projects
For Node.js projects where Python tooling is undesirable, husky provides the same lifecycle hook management using npm scripts.
npm install --save-dev husky lint-staged
npx husky init
// package.json
{
"scripts": {
"prepare": "husky"
},
"lint-staged": {
"*.{js,ts,jsx,tsx}": [
"eslint --max-warnings=0",
"prettier --check"
],
"*.{json,yaml,yml}": [
"prettier --check"
],
"Dockerfile*": [
"hadolint"
]
}
}
# .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Run lint-staged (only checks staged files — fast).
npx lint-staged
# Run gitleaks on staged content.
gitleaks protect --staged --redact
# .husky/commit-msg
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx commitlint --edit "$1"
commitlint enforces conventional commits in Node.js projects, equivalent to the bash commit-msg hook above.
The prepare script in package.json means npm install automatically installs husky hooks — solving the same team-adoption problem that the Makefile setup target solves for Python projects.
The Architectural Limit: Pre-Commit is Not a Security Boundary
This is the most important section in this article.
Any developer can bypass pre-commit hooks with a single flag:
git commit --no-verify -m "push anyway"
git push --no-verify
The --no-verify flag skips all client-side hooks entirely. No detection. No log entry. No audit trail. Client-side hooks are developer tooling, not a security control.
This has several implications for your security architecture:
Pre-commit hooks are not a replacement for CI checks. Every security check that matters must also run in CI, on infrastructure the developer does not control. The pre-commit hook provides fast, local feedback. CI provides the enforcing layer.
Secrets committed with --no-verify will be caught by CI secret scanning. Configure your CI pipeline to run gitleaks or trufflehog on every push, with a non-zero exit code that blocks the build and prevents the pull request from merging.
# .github/workflows/secret-scan.yml
name: Secret scanning
on: [push, pull_request]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history — scan all commits in the push.
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Server-side hooks are the enforcing layer. For self-hosted git servers (Gitea, GitLab self-managed, Bitbucket Data Center, Gerrit), server-side pre-receive hooks run on the server when a push is received and can reject the push before it is accepted into the repository. Unlike client-side hooks, these cannot be bypassed by --no-verify.
#!/usr/bin/env bash
# Server-side pre-receive hook (place in repository's hooks/pre-receive).
# Rejects pushes containing secrets detected by gitleaks.
while IFS=' ' read -r old_rev new_rev ref_name; do
if [[ "$old_rev" == "0000000000000000000000000000000000000000" ]]; then
# New branch — scan all commits being pushed.
range="$new_rev"
else
range="${old_rev}..${new_rev}"
fi
if ! gitleaks detect \
--source . \
--log-opts "$range" \
--redact \
--exit-code 1 \
--quiet; then
echo "REJECTED: gitleaks detected secrets in this push."
echo "Rotate the affected credentials immediately."
exit 1
fi
done
exit 0
GitHub, GitLab, and Bitbucket Cloud operate their own server-side secret scanning on every push and will notify (or block, with the right settings) on detected credentials.
Telemetry
Track hook bypass and CI finding correlation to understand how often developers are skipping local checks:
pre_commit_bypass_total{repo, hook_id} counter # from CI: finding present but hook should have caught it
ci_secret_scan_finding_total{repo, severity} counter
ci_iac_scan_finding_total{repo, check_id} counter
pre_receive_rejection_total{repo, reason} counter
Alert on:
pre_receive_rejection_total— a push was rejected server-side; the developer bypassed local hooks and tried to push a secret. Treat as a security event, not a developer mistake.- Rising
ci_secret_scan_finding_totalwith no corresponding increase inpre_commit_bypass_total— hooks may not be installed across the team; audit.
Expected Behaviour
| Action | No hooks configured | Hooks installed and running |
|---|---|---|
| Developer commits a hardcoded API key | Succeeds silently; reaches remote | detect-secrets and gitleaks block the commit; developer rotates before pushing |
Terraform opens S3 bucket to "*" |
Committed and pushed | checkov fails at pre-commit; developer fixes IAM policy |
Dockerfile sets USER root at end |
Committed and pushed | hadolint fails; developer adds non-root USER instruction |
Developer runs git commit --no-verify |
N/A | Commit succeeds locally; CI secret scan catches it and blocks the PR |
| New developer clones and commits without running setup | No hooks active | Makefile setup target installs hooks; prepare script in npm installs husky |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Fast local feedback | Developer fixes issues immediately with full context | Hook failures interrupt commit flow | Keep hooks fast (< 5s); move slow checks to pre-push |
Shared .pre-commit-config.yaml |
Consistent check versions across all developers | Updating hook versions requires a PR and review | Use pre-commit autoupdate on a schedule; review diffs in the update PR |
detect-secrets baseline |
Avoids false positives on existing content | Baseline can become stale and mask real secrets | Audit the baseline quarterly; regenerate after large refactors |
| Bandit for Python | Catches common vulnerability patterns at commit time | High false-positive rate on some rules (B101, B404) | Tune via pyproject.toml; use inline # noqa: S<id> with documented rationale |
| husky for Node.js projects | No Python dependency for JS teams | prepare only runs on npm install; not all environments run it |
Document in README; add CI verification step |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
Developer bypasses with --no-verify |
Secret or misconfiguration reaches remote | CI secret scan finds it; pre_receive_rejection_total rises |
Revoke the secret; retrain the developer; verify CI blocking is active |
| Hook virtualenv becomes corrupted | pre-commit errors on commit; developers disable hooks |
Developer reports hook errors | Run pre-commit clean then pre-commit install; document in onboarding |
detect-secrets baseline out of date |
Hook fails on pre-existing content; developers skip | Developers report false positives on unchanged files | Run detect-secrets scan --update .secrets.baseline; audit and commit |
| Hook version pinned too old | Known bypass for old hook version; new secret patterns undetected | Manual review of hook changelogs; dependabot alerts on hook repos | Run pre-commit autoupdate; test against baseline before merging |
| Pre-commit not installed on CI runner | CI does not re-check for hook bypasses | PR merges with issues CI should have caught | Add pre-commit run --all-files as a required CI check |