
Docker image layers aren't the only place a secret sneaks into a repository — git history is a bigger and quieter problem. A key that was committed, then "removed" in the next commit, is still sitting in history forever unless someone rewrites it. git log -p will show it to anyone who clones the repo, and so will every CI runner, every fork, and every backup of that repository. Gitleaks scans for exactly this — both the working tree and the full commit history — and it's fast enough to run on every commit, not just occasionally.
Step 1: Scan what's already there
Install Gitleaks (a prebuilt binary, Homebrew, or the Docker image all work — this uses the binary) and run a baseline scan:
gitleaks detect --source . --verbose
This scans the current working tree and the full git history by default, and exits non-zero if it finds anything. On a repo with any real age, expect findings — old API keys in a "quick fix" commit from two years ago are extremely common. For a detailed report instead of just console output:
gitleaks detect --source . --report-path gitleaks-report.json --report-format json
Step 2: Tell it what to ignore
Not every finding is real — test fixtures with fake-looking keys, example values in documentation, and placeholder strings all trigger regex-based detection. Gitleaks has two ways to suppress these, and they serve different purposes.
A custom config with an allowlist, for patterns and paths you want to exclude everywhere:
# .gitleaks.toml
title = "gitleaks config"
[extend]
useDefault = true
[[rules]]
id = "internal-api-key"
description = "Internal service API key pattern"
regex = '''internal_api_key\s*=\s*['"][a-zA-Z0-9]{32}['"]'''
tags = ["key", "internal"]
[rules.allowlist]
paths = [
'''(.*)?\.test\.(js|ts|py)$''',
'''fixtures/.*'''
]
[allowlist]
description = "Global allowlist"
paths = [
'''(.*)?_test\.go$''',
'''docs/examples/.*'''
]
[extend] useDefault = true keeps Gitleaks' built-in rule set (AWS keys, GitHub tokens, Slack webhooks, private keys, and dozens more) and adds your own rule on top, rather than replacing it. Run with the config explicitly:
gitleaks detect --source . --config .gitleaks.toml
A .gitleaksignore file, for specific findings you've reviewed and decided are not real secrets (a fingerprint-based ignore, not a pattern-based one):
# .gitleaksignore
# One fingerprint per line — gitleaks prints the fingerprint for each finding;
# paste it here once you've confirmed it's a false positive.
3f29a1c4e8b2d9f0a1c4e8b2d9f0a1c4e8b2d9f0:test/fixtures/fake_token.py:generic-api-key:12
The difference matters: the TOML allowlist suppresses a whole category or path going forward, while .gitleaksignore suppresses one specific already-reviewed finding without hiding future ones at the same path.
Step 3: Block it before it's committed
Scanning history catches what's already there. A pre-commit hook catches it before it happens, using gitleaks protect — a different command from detect, purpose-built to scan the staged diff instead of the whole repo:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks
Run pre-commit install once per clone. From then on, every git commit runs Gitleaks against just the staged changes — fast, because it's not rescanning the whole repository's history every time.
Step 4: Block it in CI too
A pre-commit hook is opt-in — someone can skip it with --no-verify, or simply not have it installed locally. CI is the backstop that can't be skipped:
name: gitleaks
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history — gitleaks needs it to scan commits, not just the working tree
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 matters more than it looks — the default shallow checkout (depth 1) only gives Gitleaks the latest commit to look at, silently skipping the exact history-scanning that's the point of running it in the first place.
What a finding actually looks like
A real finding reports the rule that matched, the file and line, the commit, and a redacted preview of the secret itself:
Finding: AKIA************WXYZ
Secret: AKIA************WXYZ
RuleID: aws-access-token
Entropy: 3.912651
File: config/legacy_settings.py
Line: 14
Commit: a3f29c1
Author: ...
Date: 2024-03-11T09:22:04Z
Fingerprint: a3f29c1:config/legacy_settings.py:aws-access-token:14
The fingerprint is what goes into .gitleaksignore if this turns out to be a false positive — and it's also exactly what you'd search for to confirm whether the real key needs to be rotated, since it's sitting in a specific commit, not an abstraction.
A real secret was found in history — now what?
Removing the file in a new commit does nothing; the secret is still in every clone's history. Two things need to happen, and the order matters:
- Rotate the credential first. Assume it's already compromised — anyone with repo access (including CI logs, forks, and old clones) has had access to it since the commit that introduced it. Rewriting history doesn't undo that.
- Then rewrite history if you also want it gone from the repository itself, with
git-filter-repo(the actively maintained replacement for the oldgit filter-branch/ BFG workflow). This rewrites every commit's hash downstream of the change, so it needs coordinating with anyone else who has the repo cloned — not something to do silently on a shared branch.
Common pitfalls
- Shallow checkouts in CI. As above — without full history, the CI scan silently becomes a no-op for anything beyond the latest commit.
- Treating history rewrite as the fix. It's cleanup, not remediation. The credential itself still needs rotating.
- An allowlist that's too broad. A path pattern like
test/.*on a repo where test fixtures sometimes contain real staging credentials will hide exactly the findings you need to see. - Only running it in CI, not locally. The pre-commit hook is what stops the secret from ever being pushed in the first place — CI alone just tells you after the fact.
Where to go next
Gitleaks' built-in rules cover the well-known providers well, but a growing codebase accumulates its own internal secret formats — service tokens, signed URLs, internal API keys — exactly like the custom rule in Step 2. Revisit .gitleaks.toml whenever a new kind of credential gets introduced, the same way you'd review dependencies for a new library.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


