Skip to main content
DevSecOps (AppSec in the pipeline) · lesson 3 of 7

Secrets Scanning with Gitleaks: The Key You Deleted Is Still There

Commit an API key, delete it in the next commit, and watch Gitleaks find it in history anyway. Then rotate, record the decision, and block the next one.

By SkillByExample · Published

devsecops secrets gitleaks git pre-commit

A scanner that reads the current files will miss the most common leak: a key that was committed, noticed and deleted in the next commit. This lesson builds that exact repository, finds the key in history, handles it properly, and installs a hook that blocks the next one. Tested with Gitleaks 8.30.1 (zricethezav/gitleaks:v8.30.1), git 2.54.0 (alpine/git:v2.54.0) and Docker Engine 29.8.2. The key is a made-up random string, not a credential for any real service.

The repository

The script makes two commits: one adds a key, the next replaces it with an environment variable. Fixed author names and dates make the commit hashes identical on every machine.

# make-repo.sh
set -e
export GIT_AUTHOR_NAME=dev GIT_AUTHOR_EMAIL=dev@example.com
export GIT_COMMITTER_NAME=dev GIT_COMMITTER_EMAIL=dev@example.com
rm -rf shop && git init -q -b main shop && cd shop

echo 'PAYMENTS_API_KEY = "kq7Vx2Lm9Pz4Rt8Wb3Nc6Hy1"' > config.py
git add config.py
GIT_AUTHOR_DATE=2026-10-01T10:00:00Z GIT_COMMITTER_DATE=2026-10-01T10:00:00Z \
  git commit -q -m "Add payments config"

echo 'import os; PAYMENTS_API_KEY = os.environ["PAYMENTS_API_KEY"]' > config.py
git add config.py
GIT_AUTHOR_DATE=2026-10-01T10:05:00Z GIT_COMMITTER_DATE=2026-10-01T10:05:00Z \
  git commit -q -m "Read the key from the environment"
git log --oneline
$ docker run --rm -v "$PWD":/w -w /w --entrypoint sh alpine/git:v2.54.0 make-repo.sh
422fd14 Read the key from the environment
f1aa37d Add payments config

If you run the script with your own git instead, you get the same two hashes, because a commit’s hash depends only on its content, author, committer and dates.

Scan the files on disk

gitleaks dir scans files as they are now. In those files, the key has been removed.

$ docker run --rm -v "$PWD/shop":/repo zricethezav/gitleaks:v8.30.1 dir /repo --no-banner --no-color
12:36PM INF scanned ~61 bytes (61 bytes) in 16.3ms
12:36PM INF no leaks found
$ echo $?
0

Clean, exit 0. A review of the current code, by a person or a scanner, would pass this repository. The timestamps on the left are your local time; everything else is stable.

Scan the history

gitleaks git walks every commit. --redact=50 hides the second half of the secret so the report itself doesn’t leak it again, and -v prints each finding.

$ docker run --rm -v "$PWD/shop":/repo zricethezav/gitleaks:v8.30.1 git /repo --no-banner --no-color --redact=50 -v
Finding:     PAYMENTS_API_KEY = "kq7Vx2Lm9Pz4..."
Secret:      kq7Vx2Lm9Pz4...
RuleID:      generic-api-key
Entropy:     4.584962
File:        config.py
Line:        1
Commit:      f1aa37dcc28362ec7b839cf1049685fd80ad6583
Author:      dev
Email:       dev@example.com
Date:        2026-10-01T10:00:00Z
Fingerprint: f1aa37dcc28362ec7b839cf1049685fd80ad6583:config.py:generic-api-key:1

12:36PM INF 2 commits scanned.
12:36PM INF scanned ~107 bytes (107 bytes) in 295ms
12:36PM WRN leaks found: 1
$ echo $?
1

There it is, in commit f1aa37d, with exit code 1. The second commit deleted the line, but the first commit still holds the file with the key in it, and so does every clone made after it was pushed. generic-api-key is Gitleaks’ catch-all rule: a name like KEY, TOKEN or SECRET assigned a string whose entropy (4.58 here) is too high to be an ordinary word.

The fix is rotation

Rewriting history doesn’t un-leak a key, because every existing clone and CI cache keeps the old commits. The fix happens at the service that issued the key: revoke it and issue a new one, which goes into the environment, not into git. Once the old value is dead, the finding is history rather than risk, and you record that decision where the scanner reads it.

# .gitleaksignore
# Revoked 2026-10-01 in the payments dashboard; replaced by an env var.
f1aa37dcc28362ec7b839cf1049685fd80ad6583:config.py:generic-api-key:1

The line is the Fingerprint from the report: commit, file, rule and line number. Save it as .gitleaksignore in the repository root and scan again.

$ docker run --rm -v "$PWD/shop":/repo zricethezav/gitleaks:v8.30.1 git /repo --no-banner --no-color --redact=50 -v
12:38PM INF 2 commits scanned.
12:38PM INF scanned ~107 bytes (107 bytes) in 318ms
12:38PM INF no leaks found
$ echo $?
0

Exit 0. Because the fingerprint includes the commit hash, this ignores only that one finding. If the same key were committed again anywhere else, it would get a new fingerprint and fail the scan.

Block the next one before it’s committed

A pre-commit hook runs before git creates the commit, and a non-zero exit aborts it. --pre-commit --staged scans only what’s staged.

#!/bin/sh
# .git/hooks/pre-commit
exec docker run --rm -v "$(git rev-parse --show-toplevel)":/repo \
  zricethezav/gitleaks:v8.30.1 git /repo --pre-commit --staged \
  --no-banner --no-color --redact=50 -v

Make it executable with chmod +x .git/hooks/pre-commit, then try to commit another secret.

$ echo 'WEBHOOK_SECRET = "Hj4kP9qXz2Lw7Vn3Bt8Rm5Yc"' > webhook.py
$ git add webhook.py
$ git commit -m "Add webhook handler"
Finding:     WEBHOOK_SECRET = "Hj4kP9qXz2Lw..."
Secret:      Hj4kP9qXz2Lw...
RuleID:      generic-api-key
Entropy:     4.584962
File:        webhook.py
Line:        1
Fingerprint: webhook.py:generic-api-key:1

12:36PM INF 0 commits scanned.
12:36PM INF scanned ~44 bytes (44 bytes) in 240ms
12:36PM WRN leaks found: 1
$ echo $?
1
$ git log --oneline
422fd14 Read the key from the environment
f1aa37d Add payments config

The commit never happened: git log still shows two commits. The fingerprint has no commit hash, because there’s no commit yet, and “0 commits scanned” is correct for the same reason. Read the value from the environment instead, stage it, and the same commit goes through.

$ echo 'import os; WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]' > webhook.py
$ git add webhook.py
$ git commit -q -m "Add webhook handler"
12:36PM INF 0 commits scanned.
12:36PM INF scanned ~57 bytes (57 bytes) in 214ms
12:36PM INF no leaks found
$ git log --oneline -1
760be61 Add webhook handler

This commit’s hash will differ on your machine, because it wasn’t made with fixed dates.

A hook only protects the machine it’s installed on, and git commit --no-verify skips it. That’s why the history scan from earlier also belongs in CI, which you’ll wire up in the CI security gate lesson.

Practice

  1. Without Gitleaks, prove the key is still in the repository by printing the first commit’s version of config.py.

    Solution

    git -C shop show f1aa37d:config.py prints the line with the full key. That’s all an attacker with read access needs, which is why deleting the line was never a fix.

  2. A test fixture needs a random-looking string that isn’t a secret. Write it so Gitleaks skips that line only.

    Solution
    TEST_FIXTURE_KEY = "Zx8Qw3Lp7Vn2Rk5Tj9Hb4Mc6"  # gitleaks:allow

    A gitleaks dir scan reports nothing for that line. The comment is visible in code review, so the exception is reviewed like any other change. --ignore-gitleaks-allow turns these comments off for an audit.

  3. Your company’s live tokens look like sbe_live_ followed by 24 hex characters. Add a rule that names them, while keeping all the default rules.

    Solution
    [extend]
    useDefault = true
    
    [[rules]]
    id = "sbe-live-token"
    description = "Internal live API token"
    regex = '''sbe_live_[0-9a-f]{24}'''
    keywords = ["sbe_live_"]

    Save it as .gitleaks.toml in the scanned folder. A file containing TOKEN = "sbe_live_4f9a2c7e1b8d3a6f0c5e9b2d" is then reported as RuleID: sbe-live-token. Without the file, the generic rule catches the same line as generic-api-key, but a named rule tells the person on call exactly which system to rotate.

  4. Run the history scan with --redact and no number. What changes in the report?

    Solution

    --redact on its own means 100%, so the Finding and Secret lines both read REDACTED. Use that in CI logs, which are often readable by more people than the repository itself.

Where this fits

Secrets scanning covers what you commit. The next lesson covers what you install: dependencies with published vulnerabilities, and the SBOM that lists them.

Frequently Asked Questions

If I delete a secret in a new commit, is it gone?
No. Git stores every commit, so the old version of the file, with the secret in it, is still in the repository's history and in every clone, fork and CI cache made since. Anyone with read access can see it with git show. Treat a committed secret as leaked from the moment it was pushed.
What should I do when a secret is found in git history?
Revoke or rotate it first, at the service that issued it, so the leaked value stops working. Rewriting history with git filter-repo is optional cleanup afterwards and does not undo the leak, because existing clones keep the old commits. Then record the old finding as handled, for example in a .gitleaksignore file, so scans stay green.
What is the difference between gitleaks dir and gitleaks git?
gitleaks dir scans the files as they are on disk right now. gitleaks git walks the repository's commit history and scans every change, which is how it finds secrets that were added and later removed. In Gitleaks 8.19 and later these subcommands replaced the older detect and protect commands.
How do I stop secrets being committed in the first place?
Run gitleaks git --pre-commit --staged from a pre-commit hook. It scans only what you have staged and exits 1 if it finds a secret, which makes git abort the commit. Hooks are local and can be skipped with --no-verify, so also run the full history scan in CI.
Why does Gitleaks flag random-looking strings that are not secrets?
Its generic-api-key rule matches an assignment to a name like key, token or secret whose value has high Shannon entropy, so random test fixtures look the same as real keys. Mark a known-safe line with a gitleaks:allow comment, or add the finding's fingerprint to .gitleaksignore with a note saying why.