Python Pre-commit Setup (security gate)
Wire up a Python repo with pre-commit hooks that block a commit on file-hygiene issues, leaked secrets, vulnerable dependencies, misconfigs, and unsafe Python patterns. Defaults to a uv project; a pip/poetry note is at the bottom.
This is a single-file skill — the config files are inlined below as code blocks you write to disk. The value isn't the YAML (pre-commit is easy); it's the four things that silently break a first run: the Trivy binary isn't a pip package, Trivy's --template needs a real html.tpl on disk, Bandit can't read pyproject.toml without the [toml] extra, and the generated report must be gitignored or it re-triggers the hook. Encode all four so the user runs pre-commit install once and it just works.
What gets installed
- pre-commit-hooks — trailing whitespace, YAML/TOML syntax, large files, merge conflicts, leftover
pdb/breakpoint, private keys, missing final newline, case conflicts. - gitleaks — secret scanning; fetches its own binary via the remote repo, no manual install.
- Trivy (local hook) — filesystem scan for dependency CVEs (
uv.lock), secrets, IaC/Dockerfile misconfigs. Blocks on CRITICAL/HIGH/MEDIUM; LOW ignored. Writes an HTML report. - Bandit (local hook) — Python SAST for hardcoded creds, weak crypto, unsafe
subprocess/eval, etc. Blocks on MEDIUM/HIGH.
Before writing anything — resolve two variables
- Package/source dir — the importable code directory (has
__init__.py, e.g.school_pathways_ai,app,src/<pkg>). Ask if ambiguous. This replaces__PACKAGE_DIR__below. - Package manager — confirm uv (
uv.lock+pyproject.toml). If pip/poetry, adapt commands (see bottom).
Setup steps (run in order from repo root)
1. Dev dependencies
uv add --dev pre-commit "bandit[toml]"
The [toml] extra matters: plain bandit cannot read -c pyproject.toml and errors out — the single most common Bandit failure.
2. Trivy binary (NOT pip — it's a Go binary, installed system-wide)
# macOS
brew install trivy
# Debian/Ubuntu
sudo apt-get install -y wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install -y trivy
# Any OS
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
Verify with trivy --version. Every machine running the hooks needs Trivy — note this in the README.
3. Report template + data dir
Trivy's --format template needs the template file to exist on disk first, or the run dies. Create data/html.tpl with this content:
mkdir -p data
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Trivy Scan Report</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 24px; color: #1a1a1a; }
h1 { font-size: 22px; }
h2 { font-size: 16px; margin-top: 28px; border-bottom: 1px solid #e5e5e5; padding-bottom: 6px; }
table { border-collapse: collapse; width: 100%; margin: 8px 0 20px; font-size: 13px; }
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; vertical-align: top; }
th { background: #f5f5f5; }
.sev { font-weight: 700; padding: 2px 8px; border-radius: 4px; color: #fff; white-space: nowrap; }
.CRITICAL { background: #7c0a02; }
.HIGH { background: #d13438; }
.MEDIUM { background: #e8a33d; }
.LOW { background: #6c757d; }
.UNKNOWN { background: #9aa0a6; }
.none { color: #2e7d32; }
a { color: #0b57d0; text-decoration: none; }
code { background: #f2f2f2; padding: 1px 4px; border-radius: 3px; }
</style>
</head>
<body>
<h1>Trivy Scan Report</h1>
{{- $found := false }}
{{- range . }}
{{- if or .Vulnerabilities .Misconfigurations .Secrets }}
{{- $found = true }}
<h2>{{ .Target }} <small>({{ .Type }})</small></h2>
{{- if .Vulnerabilities }}
<table>
<thead><tr><th>Severity</th><th>ID</th><th>Package</th><th>Installed</th><th>Fixed</th><th>Title</th></tr></thead>
<tbody>
{{- range .Vulnerabilities }}
<tr>
<td><span class="sev {{ .Severity }}">{{ .Severity }}</span></td>
<td>{{ if .PrimaryURL }}<a href="{{ .PrimaryURL }}">{{ .VulnerabilityID }}</a>{{ else }}{{ .VulnerabilityID }}{{ end }}</td>
<td>{{ .PkgName }}</td>
<td><code>{{ .InstalledVersion }}</code></td>
<td>{{ if .FixedVersion }}<code>{{ .FixedVersion }}</code>{{ else }}—{{ end }}</td>
<td>{{ .Title }}</td>
</tr>
{{- end }}
</tbody>
</table>
{{- end }}
{{- if .Misconfigurations }}
<table>
<thead><tr><th>Severity</th><th>ID</th><th>Check</th><th>Message</th><th>Resolution</th></tr></thead>
<tbody>
{{- range .Misconfigurations }}
<tr>
<td><span class="sev {{ .Severity }}">{{ .Severity }}</span></td>
<td>{{ if .PrimaryURL }}<a href="{{ .PrimaryURL }}">{{ .ID }}</a>{{ else }}{{ .ID }}{{ end }}</td>
<td>{{ .Title }}</td>
<td>{{ .Message }}</td>
<td>{{ .Resolution }}</td>
</tr>
{{- end }}
</tbody>
</table>
{{- end }}
{{- if .Secrets }}
<table>
<thead><tr><th>Severity</th><th>Rule</th><th>Category</th><th>Title</th><th>Lines</th></tr></thead>
<tbody>
{{- range .Secrets }}
<tr>
<td><span class="sev {{ .Severity }}">{{ .Severity }}</span></td>
<td>{{ .RuleID }}</td>
<td>{{ .Category }}</td>
<td>{{ .Title }}</td>
<td>{{ .StartLine }}{{ if ne .StartLine .EndLine }}–{{ .EndLine }}{{ end }}</td>
</tr>
{{- end }}
</tbody>
</table>
{{- end }}
{{- end }}
{{- end }}
{{- if not $found }}
<p class="none">No CRITICAL/HIGH/MEDIUM findings. ✅</p>
{{- end }}
</body>
</html>
4. Bandit config — append to pyproject.toml
[tool.bandit]
exclude_dirs = ["tests", ".venv", "data", "logs", "build", "dist"]
[tool.bandit.assert_used]
skips = ["*/tests/*", "*_test.py", "test_*.py"]
5. .gitignore — the report regenerates every run; ignore it but keep the template tracked
data/report.html
Do not ignore all of data/ — data/html.tpl must stay committed.
6. Write .pre-commit-config.yaml (replace both __PACKAGE_DIR__)
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: check-yaml
- id: check-toml
- id: check-added-large-files
- id: check-merge-conflict
- id: debug-statements
- id: detect-private-key
- id: end-of-file-fixer
- id: check-case-conflict
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.0
hooks:
- id: gitleaks
- repo: local
hooks:
- id: trivy-scan
name: Trivy security scan (blocks on CRITICAL/HIGH/MEDIUM)
entry: bash -c 'trivy fs --scanners vuln,secret,misconfig --severity CRITICAL,HIGH,MEDIUM --skip-dirs logs --skip-dirs data --skip-dirs .venv --skip-files .env --exit-code 1 --format template --template "@data/html.tpl" -o data/report.html . || { echo "❌ Trivy found CRITICAL/HIGH/MEDIUM issue(s) — open data/report.html for details."; exit 1; }'
language: system
pass_filenames: false
always_run: true
- id: bandit
name: Bandit security scan (blocks on MEDIUM/HIGH)
entry: uv run bandit -c pyproject.toml -r __PACKAGE_DIR__ --severity-level medium
language: system
pass_filenames: false
always_run: true
Then pin fresh versions instead of trusting the template's: uv run pre-commit autoupdate.
7. Install and verify
uv run pre-commit install # registers the git hook
uv run pre-commit run --all-files # first run reformats files; fix findings; a clean second run = gate is live
Verify it actually gates (optional)
- gitleaks / detect-private-key — stage a file with a fake
AWS_SECRET_ACCESS_KEY=...; commit must be blocked. - Bandit — add
password = "hunter2"oreval(user_input)in the package; expect a MEDIUM+ block. - Trivy — a known-vulnerable pinned dep in
uv.lockshould surface indata/report.html.
Tuning knobs
- Too noisy? Raise Trivy
--severitytoCRITICAL,HIGHand Bandit to--severity-level high(keep both in sync). src/layout → point Bandit-ratsrc/<pkg>; Trivy scans.regardless.- CI → run
pre-commit run --all-filesas a CI job so it's enforced even if a dev skips the local hook. - False-positive secret → inline
# gitleaks:allowor a.gitleaks.tomlallowlist, not a lower gate.
pip / poetry
Drop the uv run prefix and uv add --dev:
- pip:
pip install pre-commit "bandit[toml]"; removeuv runfrom Bandit'sentry:. - poetry:
poetry add --group dev pre-commit "bandit[toml]"; Bandit entry becomespoetry run bandit ....
Trivy binary, template, and gitignore steps are identical.