DevSecOps (AppSec in the pipeline) · lesson 3 of 7
Beginner
3Intermediate
3Also part of DevSecOps (AppSec in the pipeline)
10- + Bash Security Best Practices from Bash
- + Secrets: Real Log Masking, and the Base64 Trick That Defeats It from GitHub Actions
- + Docker Security from Docker
- + Multi-Stage Builds from Docker
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Variable Precedence: host_vars Beats group_vars, and extra-vars Beats Everything from Ansible
- + Scanning Terraform for Misconfiguration with Checkov from Cloud Security
- + Policy as Code: Testing Terraform Plans with OPA and Conftest from Cloud Security
- + MCP Authorization: OAuth 2.1, CIMD and a Real Identity Provider from MCP (Model Context Protocol)
- + Running MCP in Production: Scaling, Caching and Operations from MCP (Model Context Protocol)
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
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
-
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.pyprints 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. -
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:allowA
gitleaks dirscan reports nothing for that line. The comment is visible in code review, so the exception is reviewed like any other change.--ignore-gitleaks-allowturns these comments off for an audit. -
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.tomlin the scanned folder. A file containingTOKEN = "sbe_live_4f9a2c7e1b8d3a6f0c5e9b2d"is then reported asRuleID: sbe-live-token. Without the file, the generic rule catches the same line asgeneric-api-key, but a named rule tells the person on call exactly which system to rotate. -
Run the history scan with
--redactand no number. What changes in the report?Solution
--redacton its own means 100%, so theFindingandSecretlines both readREDACTED. 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.