Secret Scanning in CI: What Pre-Commit, Pull Request, and Main Branch Each Actually Catch
A teammate pastes an AWS access key into a PR comment to "debug quickly." Another commits .env.production because .gitignore was wrong on a new microservice. A third rotates nothing after a contractor laptop compromise because "we never committed secrets, probably fine."
Secret scanners are not interchangeable at every stage of the software development lifecycle (SDLC). Running only on main means the secret already lived in git history. Running only locally means it never ran on the machine that mattered. Layers matter, and each layer should have a different job.
In this article, you will implement a practical three-layer secret-scanning model with Gitleaks and GitHub Actions, then verify each layer and handle real incidents without creating scanner fatigue.
Who this is for: Engineers owning GitHub Actions security checks for application repos.
What you'll build: Pre-commit hook, PR scan, and post-merge history scan with shared allowlist discipline.
Prerequisites: Git, GitHub repo, optional Gitleaks binary locally.
TL;DR
- Use three layers with different goals: pre-commit for fast local feedback, pull request as a merge gate, and scheduled default-branch scans for hygiene.
- Block merges on PR findings; treat default-branch findings as incidents plus historical cleanup.
- Share one
.gitleaks.tomlacross local and CI runs, and allowlist only specific safe paths. - Rotation and revocation matter more than history rewrite; deleting git history without credential rotation does not contain compromise.
Why one scanner in one place fails
| Layer | Catches | Misses |
|---|---|---|
| Pre-commit | Keys before they enter any remote | Skipped hooks (--no-verify), new clones without hooks installed |
| Pull request | Keys introduced in the diff; blocks merge | Secrets only in comments, wiki, or release assets |
| Default branch/history | Deep scan, scheduled; finds old leaks | Still too late for keys already exfiltrated from an open PR |
Treat findings like production incidents at the PR layer; treat main-branch scans as hygiene and audit evidence.
Layer 1 — Pre-commit (fast feedback)
Start with local feedback so engineers catch leaks before pushing. Install Gitleaks and wire a hook:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks
pre-commit install
Developers see failures in under two seconds on staged files. Allowlist test fixtures explicitly, never disable rules globally:
# .gitleaks.toml (repo root)
[allowlist]
paths = [
'''^tests/fixtures/''',
'''^docs/examples/fake-credentials\.json$'''
]
Rule: If a path needs a real secret for tests, use a vault-injected env var in CI—not a committed file with "fake" in the name and a real-looking key format.
Layer 2 — Pull request scan (merge gate)
The PR layer is your enforcement point. Scan only the PR diff so developers are not punished for historical debt on day one:
name: Secret scan (PR)
on:
pull_request:
permissions:
contents: read
pull-requests: write
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Gitleaks on PR diff
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
config-path: .gitleaks.toml
Configure branch protection: this check is required before the merge.
Fork PRs: GITHUB_TOKEN from forks is read-only; gitleaks-action still scans the diff but cannot comment with elevated permissions, acceptable for public repos; for private repos, consider org-level secret scanning (GitHub Advanced Security) as a parallel signal.
Layer 3 — Main branch and history (hygiene)
The default-branch layer catches historical debt and supply-chain surprises. Run a weekly full-history scan:
name: Secret scan (history)
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
jobs:
gitleaks-history:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
with:
config-path: .gitleaks.toml
# no PR context: full repo scan
When this fires on main, assume rotation: revoke the credential, purge from history (git filter-repo or BFG) only after revocation—scrubbing git without rotating the secret fixes nothing.
How to verify this works
Use these checks to confirm each layer is doing the right job:
- Pre-commit check: add a fake test secret string in a staged file and confirm
pre-commitblocks the commit. - PR gate check: Open a test pull request with the same string and confirm the workflow fails before merge.
- History scan check: run
workflow_dispatchfor the history workflow and confirm it scans the full repository. - Allowlist check: Place the same test token in an allowlisted fixture path and confirm scanner behavior matches your policy.
Use clearly fake values in tests and examples. Never use production-like secrets for scanner testing.
Reducing false positives without going blind
- Allowlist paths, not regexes for "AWS" — Broad entropy exceptions hide real keys.
- Separate example keys: Use obviously invalid formats (
AKIAFAKE00000000000) in docs; scanners and humans both win. - Custom rules sparingly: Add org-specific patterns (internal API key prefix) via Gitleaks
[[rules]]; do not fork the entire ruleset unless you can maintain it. - Teach the escape hatch: If pre-commit blocks a docs change, fix the example, not
SKIP=gitleaks.
When a secret is found anyway
- Revoke in the provider (AWS, Stripe, Slack) immediately.
- Rotate dependent systems; assume compromise if the repo is public or the PR was open.
- Purge history if the secret touched git; coordinate with legal/comms if customer data access was possible.
- Post-incident: add a rule or allowlist fix so the same mistake is a one-liner next time.
Pair this scanning pipeline with short-lived cloud credentials (for example, OIDC-based CI federation) so leaked CI identity material has reduced blast radius.
When this breaks down
- Teams can bypass local hooks, so pre-commit cannot be your enforcement layer by itself.
- Diff-only PR scans do not catch existing secrets already in history, release assets, or external systems.
- Aggressive allowlists can silently reduce coverage if they are not reviewed during security change management.
- Scanner success can create false confidence if incident response, revocation, and rotation are weak.
Summary
- Use three layers: pre-commit (speed), PR (gate), scheduled main (history).
- Share one
.gitleaks.toml; allowlist paths, not categories of secrets. - Block merge on PR findings; treat main-branch hits as incident + hygiene work.
- Rotation beats history rewriting; scanning is detection, not prevention alone.
Further reading
- Gitleaks configuration
- GitHub secret scanning
- Removing sensitive data from a repository
Discussion in the ATmosphere