Gitleaks is an MIT-licensed Go tool that finds hard-coded passwords, API keys and tokens in Git repositories, and as of October 28, 2026 it remains an actively maintained project (gitleaks.io). You can stack three defenses with it in about an hour: a scan of your full commit history, a pre-commit hook that stops a secret before it enters history, and a GitHub Actions gate that fails any pull request carrying one.
Volume is the problem. GitHub processed 7.4 billion commits in September, according to The New Stack, and every one of them is a chance for a token to slip through. Worse, a leaked key stays reachable in history after a follow-up commit deletes the file. A scan of the working tree misses it. A scan of history finds it.
The pattern also fits agent-driven pipelines. If you already run a headless coding agent through Cursor CLI in GitHub Actions, the same runner hygiene applies: a secret gate on every pull request means an agent mistake cannot ship a credential. The finished project holds a tuned .gitleaks.toml, a pre-commit config, a CI workflow, a baseline strategy for legacy code and a self-test script that proves the whole stack still works after every change.
Don't miss new tech stories on Google
Add Shattered once in the Google app and our stories show up in your news suggestions.
What Gitleaks Catches and Where It Fits in 2026 #
Gitleaks matches text against a library of rules. Each rule pairs a regular expression with optional keywords, an entropy floor and allowlists. The keyword acts as a cheap prefilter, so the engine only runs the expensive regex on lines that contain a hint such as ghp_ or AKIA. The entropy floor then discards low-randomness strings like changeme123, which look like secrets but are not. That combination keeps the tool fast on large histories and keeps noise tolerable.
The open-source project covers the common cloud, SaaS and source-host token formats out of the box. You can extend the default rule set with your own patterns, which matters because the secrets most likely to hurt you are internal ones that no public rule library knows about. An internal service token, a legacy signing key or a vendor credential with a custom prefix all need a custom rule.
Three scan modes
Gitleaks offers three entry points. gitleaks git walks commit history (or only staged changes when you add --staged). gitleaks dir scans files on disk and ignores Git entirely, which suits build artifacts and unpacked archives. gitleaks stdin reads piped text. Older tutorials use detect and protect. The project deprecated both in v8.19.0, and while they still run, new work should use git and dir.
Gitleaks, TruffleHog and GitHub native scanning
Gitleaks is not the only option, and it does not replace GitHub features. The table below compares the approaches by design, not by benchmark, because rule coverage shifts with every release and no public head-to-head number is stable enough to quote.
| Approach | Where it runs | Main strength | Main limit |
|---|---|---|---|
| Gitleaks | Laptop, CI runner, any Git host | Fast, single binary, TOML rules you own, works offline | Pattern matching only, so it cannot tell you whether a key is still live |
| TruffleHog | Laptop, CI runner, many data sources | Commonly used when checking whether a found credential is still active matters | Heavier setup, more network calls, different rule format |
| GitHub secret scanning | Inside GitHub only | Hosted, no pipeline to maintain, GitHub keeps shipping fixes to it (October 2026 updates included) | Tied to GitHub, and advanced security features sit behind a paid license |
| Pre-commit hook plus Gitleaks | Developer machine | Stops a secret before it exists in history | Developers can skip it with --no-verify |
The practical answer is to layer them. Run Gitleaks locally and in CI because it works on every host and you control the rules. Keep GitHub native scanning on as a second net if your plan includes it. Consider a verification-focused tool when you need to triage hundreds of historical findings and want to know which ones still open a door.
Prerequisites and Versions #
Everything below runs on Linux or macOS. Windows users should work inside WSL, because the hook scripts are Bash. Keep the versions in the table in mind when you copy commands, since flag names and config syntax have moved across Gitleaks releases.
| Tool | Version | Why you need it |
|---|---|---|
| Git | A recent release recommended | Hooks, --staged scans and history walking. Git 2.54 adds config-based hooks, which pre-commit 4.6.0 and later handle better |
| Gitleaks | Latest stable release (issue reports in October 2026 reference v8.30.1) | The scanner. Config syntax below needs v8.25.0 or newer |
| pre-commit | v4.7.0 (newest release) | Manages the hook. It fixed language: node hooks forgit commit -a and submodules after a v4.6.1 regression |
| Python | A currently supported Python 3 release | Runs pre-commit and the report summarizer |
| Bash and OpenSSL | Any current version | The self-test script generates random fake tokens |
| GitHub repository | Actions enabled | Hosts the CI gate. The pull request gate works on GitHub Free, Pro and Team plans |
| jq (optional) | 1.6 or newer | Quick JSON inspection of reports |
| Docker (optional) | Any current release | Alternative way to run Gitleaks without installing it |
pre-commit itself costs nothing, and so does Gitleaks. Budget about 60 minutes the first time. The longest single wait is the first pre-commit run, because the hook compiles Gitleaks from source and pre-commit bootstraps a Go toolchain if it cannot find one.
One more prerequisite is mental. Treat every token you plant in this exercise as radioactive. Generate fakes at runtime, never paste a real credential into a test, and never push the demo repository to a public remote. Secret scanners on the receiving end, including GitHub push protection, will flag test tokens that look real.
Steps 1 to 3: Install Gitleaks and Run the First History Scan #
Step 1: Install Gitleaks
Pick one install path. Homebrew is the fastest on macOS and Linux. Docker avoids a local install. Building from source suits locked-down machines, and the project build has moved to a recent Go toolchain (Go 1.24 at last check), so use a current Go release. For release binaries, take the newest tag from the project releases page rather than a version quoted in an old blog post.
brew install gitleaks
docker pull zricethezav/gitleaks:latest
git clone https://github.com/gitleaks/gitleaks.git
cd gitleaks
make build
sudo install -m 0755 gitleaks /usr/local/bin/gitleaks
gitleaks version
Watch out for look-alike forks. Search results in October 2026 surface a third-party fork that publishes pre-release tags mimicking the upstream numbering, such as 8.30.1-v.63, which its release page marks as a pre-release. Those are not upstream releases. Install from the official gitleaks/gitleaks repository only.
Step 2: Create a demo repository with a planted leak
The demo repo needs a secret that sits in history but not in the current files. That shape is the most common real-world failure: a developer commits a token, notices, deletes it in the next commit, and believes the problem is gone. The script below generates a fake GitHub-style token at runtime so no static string ever lands in your shell history or in this repo.
mkdir secret-guard-demo && cd secret-guard-demo
git init -b main
git config user.name "Demo User"
git config user.email "[email protected]"
mkdir -p config src tests/fixtures reports scripts
FAKE_TOKEN="ghp_$(openssl rand -hex 18)"
cat > config/settings.py <<EOF
GITHUB_TOKEN = "${FAKE_TOKEN}"
DEBUG = False
EOF
git add . && git commit -m "Add settings"
cat > config/settings.py <<EOF
import os
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
DEBUG = False
EOF
git add . && git commit -m "Read token from environment"
Two commits now exist. The second one looks clean, and a reviewer reading only the final diff would approve it. The first still holds the token, and anyone who clones the repository can run git show on it.
Step 3: Scan history, then scan the working tree
Run both modes against the same repository and compare. The history scan should flag the first commit. The directory scan should come back clean, because the current files no longer contain the token.
gitleaks git . --no-banner --redact --verbose
echo "exit code: $?"
gitleaks dir . --no-banner --redact
echo "exit code: $?"
Expect output shaped like the block below. Your commit hash, timestamps and fingerprint will differ, and depending on the rule set you may see a second finding from a generic API key rule. The values shown are illustrative placeholders, not captured output.
Finding: GITHUB_TOKEN = "REDACTED"
Secret: REDACTED
RuleID: github-pat
Entropy: 3.7
File: config/settings.py
Line: 1
Commit: <commit-sha>
Author: Demo User
Email: [email protected]
Date: 2026-10-28T14:02:11Z
Fingerprint: <commit-sha>:config/settings.py:github-pat:1
INF 2 commits scanned.
INF scanned ~120 bytes in 18ms
WRN leaks found: 1
exit code: 1
The directory scan prints no findings and exits with code 0. That gap is the whole argument for scanning history. If your CI only runs gitleaks dir, it would pass this repository while a live credential sits two commits back.
Note the --redact flag. It replaces the secret value in logs and standard output, and the default strength is 100 percent. You can pass a percentage from 0 to 100 to show part of the value, for example --redact=20. Make redaction your default everywhere a scan result might land in a CI log, a ticket or a chat message.
Steps 4 to 5: Read Reports and Lock Down Their Permissions #
Step 4: Export a JSON report and summarize it
Console output is fine for humans. Pipelines need structured data. Gitleaks writes JSON, CSV, JUnit, SARIF or template output through --report-format and --report-path. JSON is the easiest to post-process, and each finding carries fields such as RuleID, File, StartLine, Commit, Entropy and Fingerprint.
umask 077
gitleaks git . --no-banner --redact \
--report-format json --report-path reports/gitleaks.json
jq '.[] | {RuleID, File, StartLine, Commit, Fingerprint}' reports/gitleaks.json
Save the Python helper below as scripts/summarize_report.py. It groups findings by rule and file, prints a Markdown table that renders well in a GitHub step summary, and exits non-zero when anything is present. It never prints a secret value, even if someone forgot --redact, because it reads only metadata fields.
#!/usr/bin/env python3
import collections
import json
import pathlib
import sys
def main(path: str) -> int:
report = pathlib.Path(path)
if not report.exists():
print("No report found, nothing to summarize.")
return 0
findings = json.loads(report.read_text() or "[]")
if not findings:
print("No secrets found.")
return 0
by_rule = collections.Counter(f["RuleID"] for f in findings)
print(f"### Gitleaks found {len(findings)} potential secret(s)\n")
print("| Rule | Count |")
print("|---|---|")
for rule, count in by_rule.most_common():
print(f"| `{rule}` | {count} |")
print("\n| File | Line | Commit | Rule |")
print("|---|---|---|---|")
for f in findings:
commit = (f.get("Commit") or "working-tree")[:10]
print(f"| `{f['File']}` | {f['StartLine']} | `{commit}` | `{f['RuleID']}` |")
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "reports/gitleaks.json"))
Step 5: Keep reports out of Git and out of reach
A report is a map to your secrets. Even a redacted one lists file paths, commit hashes and rule names that tell an attacker where to look. An issue opened on the Gitleaks tracker in October 2026 reports that, in versions through v8.30.1, report files containing discovered secrets are created with permissive default permissions. Treat that as a reason to set umask 077 before every scan, as shown above, and to add the reports directory to .gitignore.
cat > .gitignore <<'EOF'
reports/
*.sarif
gitleaks-report*.json
.env
.env.*
EOF
git add .gitignore && git commit -m "Ignore scan reports and env files"
One more habit: if you must generate an unredacted report to triage a real incident, write it to a temporary directory, read it, then shred it. Do not upload it as a CI artifact, and do not paste it into a ticket.
Steps 6 to 8: Tune Gitleaks With a Config File #
The default rules catch public token formats. They cannot know your internal ones, and they will occasionally flag harmless strings in test data. A .gitleaks.toml file fixes both problems. Gitleaks resolves its config in a fixed order: the --config (or -c) flag first, then the GITLEAKS_CONFIG environment variable, then GITLEAKS_CONFIG_TOML, and finally a .gitleaks.toml in the target path. Committing the file at the repository root is the simplest setup, since every hook and CI job picks it up with no extra flags.
Step 6: Extend the defaults and add a custom rule
Start with [extend] and useDefault = true. Without it, your config replaces the built-in rules entirely, and a repo that suddenly stops catching GitHub tokens is a nasty surprise. Then add one rule for a fictional internal token format, acme_live_ or acme_test_ followed by 32 alphanumeric characters.
title = "secret-guard-demo"
[extend]
useDefault = true
[[rules]]
id = "acme-internal-token"
description = "ACME internal service token"
regex = '''acme_(live|test)_[a-zA-Z0-9]{32}'''
keywords = ["acme_"]
entropy = 3.0
tags = ["internal", "acme"]
[[rules.allowlists]]
description = "ACME test tokens are harmless"
regexes = ['''acme_test_[a-zA-Z0-9]{32}''']
The keywords array makes the rule cheap, because Gitleaks skips the regex on any line that lacks acme_. The entropy floor of 3.0 drops placeholders such as a run of repeated zeros. Keep the regex as tight as the real format allows. A loose pattern like [a-z0-9]{32} would flag every MD5 hash in your lockfiles.
Test the rule before you trust it. Generate one fake live token and one fake test token, then scan the directory with only your rule enabled.
mkdir -p /tmp/rule-check
printf 'LIVE=acme_live_%s\n' "$(openssl rand -hex 16)" > /tmp/rule-check/live.env
printf 'TEST=acme_test_%s\n' "$(openssl rand -hex 16)" > /tmp/rule-check/test.env
gitleaks dir /tmp/rule-check --config .gitleaks.toml \
--enable-rule acme-internal-token --no-banner --redact --verbose
rm -rf /tmp/rule-check
You should see exactly one finding, for live.env. The test token passes because of the rule-level allowlist. If both files flag, check that you used [[rules.allowlists]] (double brackets) and that the block sits directly under the rule it belongs to.
Step 7: Add narrow allowlists for fixtures and lockfiles
Global allowlists suppress findings across every rule. Use them sparingly and scope them tightly. A good global allowlist names one directory or one file pattern, and it explains itself in the description field so the next reviewer knows why it exists.
[[allowlists]]
description = "Synthetic fixtures used by unit tests"
paths = ['''(^|/)tests/fixtures/''']
[[allowlists]]
description = "Lockfiles hold integrity hashes, not credentials"
paths = [
'''(^|/)package-lock\.json$''',
'''(^|/)poetry\.lock$''',
'''(^|/)go\.sum$''',
]
Since v8.25.0, global allowlists support a targetRules field, which restricts an allowlist to specific rule IDs. That is the safest option for a noisy rule. Instead of silencing the whole tests directory for every rule, you silence only generic-api-key there. A real GitHub token pasted into a test file still trips the github-pat rule.
[[allowlists]]
description = "Generic key rule is noisy in snapshot tests"
targetRules = ["generic-api-key"]
paths = ['''(^|/)tests/snapshots/''']
You can also combine conditions. Setting condition = "AND" inside an allowlist requires every listed matcher (a path and a regex, for example) to match before the finding is suppressed. The default is OR, which is why a careless allowlist with a broad path and a broad regex hides far more than intended.
Step 8: Handle one-off exceptions without touching the config
Not every exception deserves a config change. Gitleaks gives you two lightweight escape hatches. An inline comment containing gitleaks:allow on the offending line suppresses that single finding. A .gitleaksignore file at the repo root lists fingerprints, one per line, with # comments allowed. The fingerprint comes straight from the scan output (commit, file, rule and line for history scans).
<commit-sha>:config/settings.py:github-pat:1
EXAMPLE_KEY = "acme_test_00000000000000000000000000000000" # gitleaks:allow
Use fingerprints for secrets you have already revoked, since the history cannot change without a rewrite. Use inline allows for documented examples. Require a ticket number or a date in the comment, and review the ignore file in every security audit. An ignore list with no owner grows quietly until it hides a live key.
Know the syntax changes that break old configs
Config syntax has changed several times in the 8.x line, so a snippet copied from an older post may fail to parse or may silently do nothing. The table lists the breakpoints that matter most.
| Gitleaks version | Change | What breaks |
|---|---|---|
| v8.8.0 | Stopwords introduced for allowlists | Configs using stopwords need this version or newer |
| v8.19.0 | detect andprotect deprecated in favor ofgit anddir |
Old commands still run, but docs, hooks and CI snippets should migrate |
| v8.21.0 | [rules.allowlist] replaced by[[rules.allowlists]] |
Single-bracket rule allowlists from older posts stop applying as intended |
| v8.25.0 | [allowlist] replaced by[[allowlists]] , newtargetRules field |
Global allowlists written the old way need rewriting |
When an upgrade changes behavior, diff your findings before and after. Run the old and new versions against the same repo with JSON reports, then compare fingerprints. Any finding that appears or disappears points at a syntax or rule change you need to understand.
Steps 9 and 10: Block Secrets Before They Enter History #
Every secret you stop at commit time is a secret you never have to rotate, rewrite out of history or explain to an auditor. A local hook is the cheapest control in this stack. Its weakness is that the developer controls it, so treat the hook as a speed bump with good manners and let CI act as the wall.
Step 9: Wire Gitleaks into the pre-commit framework
The pre-commit framework manages multi-language Git hooks from one YAML file, and the project is free and open source. The newest release at the time of writing is v4.7.0, published October 13, 2026. Pin your tooling and test it, rather than assuming behavior carried over from an older release. Install it with pip or pipx, then create the config.
pipx install pre-commit
pre-commit --version
cat > .pre-commit-config.yaml <<'EOF'
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
EOF
pre-commit autoupdate --repo https://github.com/gitleaks/gitleaks
pre-commit install
The rev above is the tag that Gitleaks issue reports reference in October 2026. Always run autoupdate once and commit whatever tag it lands on. Add --freeze if you want pre-commit to record the full commit SHA with the tag in a comment, which is the safer pin against a moved tag.
The stock hook runs Gitleaks against staged changes only. That means pre-commit run --all-files does not walk your history, so it is not a substitute for the gitleaks git scan from Step 3. Use the hook for prevention and the CLI for audits.
Now prove the hook works. Stage a file holding a fresh fake token and try to commit.
printf 'API=acme_live_%s\n' "$(openssl rand -hex 16)" > src/client.env
git add src/client.env
git commit -m "Add client config"
gitleaks..................................................................Failed
- hook id: gitleaks
- exit code: 1
Finding: API=REDACTED
Secret: REDACTED
RuleID: acme-internal-token
Entropy: 3.8
File: src/client.env
Line: 1
WRN leaks found: 1
The commit is rejected and nothing reaches history. Unstage the file with git restore --staged src/client.env and delete it. Expect the very first hook run to take noticeably longer than later ones, because pre-commit builds the hook environment, and for a Go hook it may bootstrap a Go toolchain. Subsequent runs reuse the cache and finish quickly on a normal change set. The output above shows the typical shape, not captured text, so your wording and entropy value will differ.
Step 10: Add a plain Git hook as a fallback
Some teams cannot use pre-commit. A repository may already set core.hooksPath, a corporate image may block pipx, or a developer may want zero Python on the machine. A native hook script covers those cases. Keep it in the repository under .githooks/ so it is versioned and reviewable, then point Git at the directory.
mkdir -p .githooks
cat > .githooks/pre-commit <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if ! command -v gitleaks >/dev/null 2>&1; then
echo "gitleaks is not installed. Install it, then retry the commit." >&2
echo "macOS/Linux: brew install gitleaks" >&2
exit 1
fi
exec gitleaks git --pre-commit --staged --redact --verbose --no-banner
EOF
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
The script fails closed. If Gitleaks is missing, the commit stops with install instructions. Some teams prefer failing open so that a missing tool never blocks urgent work. That is a policy choice, but make it deliberately, because a hook that silently skips on half the laptops in the company protects nothing.
Do not run both hooks at once. pre-commit refuses to install while core.hooksPath is set, which is a deliberate safety check. Pick one mechanism per repository. Teams on Git 2.54 or newer can look at config-based hooks, which pre-commit has supported more completely since v4.6.0.
Remember the bypass. git commit --no-verify skips every client-side hook. Developers use it for good reasons, such as a broken hook on a Friday evening, and for bad ones. Neither case should be able to push a secret into the main branch, and that is the job of the CI gate in the next section.
Steps 11 and 12: Gate Pull Requests in GitHub Actions and Baseline Legacy Code #
Step 11: Create the CI workflow
CI is the control a developer cannot skip with a flag. The workflow below checks out the full history, installs a pinned Gitleaks release with a checksum check, scans only the commit range the event introduced, and writes a summary to the job page. Scanning only the new range keeps pull request jobs fast on large repositories, while a scheduled full-history scan (see the advanced tips) catches anything the range logic misses.
The workflow installs the release binary directly instead of using a third-party action or the Docker image. That choice gives you one explicit version to bump, a verified download and no dependency on a moving latest tag. Set GITLEAKS_VERSION to the newest upstream release before you commit the file. The value below is the tag that issue reports reference in October 2026.
name: secret-scan
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: secret-scan-${{ github.ref }}
cancel-in-progress: true
env:
GITLEAKS_VERSION: "8.30.1"
jobs:
gitleaks:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Install Gitleaks
run: |
set -euo pipefail
base="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}"
tarball="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
curl -sSfL -o "${tarball}" "${base}/${tarball}"
curl -sSfL -o checksums.txt "${base}/gitleaks_${GITLEAKS_VERSION}_checksums.txt"
grep " ${tarball}$" checksums.txt | sha256sum -c -
tar -xzf "${tarball}" gitleaks
sudo install -m 0755 gitleaks /usr/local/bin/gitleaks
gitleaks version
- name: Scan new commits
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BEFORE_SHA: ${{ github.event.before }}
PUSH_SHA: ${{ github.sha }}
run: |
set -euo pipefail
umask 077
mkdir -p reports
args=(--config .gitleaks.toml --redact --no-banner
--report-format json --report-path reports/gitleaks.json)
if [ -f .gitleaks-baseline.json ]; then
args+=(--baseline-path .gitleaks-baseline.json)
fi
zeros="0000000000000000000000000000000000000000"
if [ "${EVENT_NAME}" = "pull_request" ]; then
args+=("--log-opts=${BASE_SHA}..${HEAD_SHA}")
elif [ "${BEFORE_SHA}" != "${zeros}" ]; then
args+=("--log-opts=${BEFORE_SHA}..${PUSH_SHA}")
fi
set +e
gitleaks git . "${args[@]}"
status=$?
set -e
python3 scripts/summarize_report.py reports/gitleaks.json >> "${GITHUB_STEP_SUMMARY}" || true
exit "${status}"
- name: Upload redacted report
if: failure()
uses: actions/upload-artifact@v4
with:
name: gitleaks-report
path: reports/gitleaks.json
retention-days: 7
Several details earn their place. The permissions: contents: read block gives the job the least privilege it needs. Passing event values through env rather than interpolating them into the script body avoids shell injection from attacker-controlled fields. The zero-SHA check handles the first push to a new branch, where there is no previous commit to diff against. And fetch-depth: 0 is non-negotiable, because the default shallow clone fetches one commit and the scan would inspect almost nothing.
Open a pull request that adds a fake acme_live_ token and watch the job fail. The step summary lists the rule, file, line and short commit hash, and the job exits with code 1. Then make the check required: in the repository settings, add a branch protection rule or ruleset that requires the gitleaks job to pass before merge. GitHub added bulk conversion of branch protection rules into rulesets on October 20, 2026, so teams with many repositories can migrate the requirement in one pass.
What changed on GitHub Actions in late October 2026
Three recent platform changes touch this workflow. First, on October 28, 2026 GitHub began scheduling eligible Linux jobs on its standard 2-core and 4-core hosted runners across both Microsoft Azure and Amazon Web Services. Workflow syntax and runner labels stay the same, but organizations that restrict egress by IP must allow all published Actions IP ranges. If your firewall blocks the download in the install step, that is the likely cause. GitHub lists the change in its October 2026 changelog.
Second, GitHub is retiring the macos-14 runner images on November 2, 2026. This workflow uses ubuntu-latest, so it is unaffected. If you copy the job into a matrix that includes macOS, move to macos-15 or macos-latest first. Third, workflow dependency lockfiles entered public preview on October 14, 2026 for the Free, Team and Enterprise Cloud plans. They target exactly the supply chain risk this workflow shares with every other one: actions referenced by a mutable tag.
Step 12: Baseline the findings you cannot fix today
Point Gitleaks at a ten-year-old repository and the first scan may return hundreds of findings, most of them long since revoked. Failing every pull request on that backlog teaches developers to ignore the check. A baseline solves it. Gitleaks accepts an earlier report through --baseline-path and suppresses every finding already in it, so the gate fires only on new leaks.
umask 077
gitleaks git . --no-banner --redact \
--report-format json --report-path .gitleaks-baseline.json
python3 scripts/summarize_report.py .gitleaks-baseline.json
git add .gitleaks-baseline.json
git commit -m "Add Gitleaks baseline for pre-existing findings"
gitleaks git . --no-banner --redact --baseline-path .gitleaks-baseline.json
Do two things before you commit that file. First, always generate it with --redact. An unredacted baseline stores every historical secret in plain text inside the repository, which turns your safety net into the leak. Second, treat the baseline as a debt ledger, not a pardon. Revoke each credential it lists, and shrink the file over time. A baseline that never gets smaller means nobody owns the cleanup.
Baselines and .gitleaksignore overlap, so pick a division of labor. Use the baseline for bulk legacy findings and the ignore file for individual, explained exceptions. Both match on fingerprints, so a rewritten history invalidates both and forces a regeneration.
The Complete Working Project: Self-Test, Makefile and File Tree #
A secret scanner that nobody tests rots. Rules get edited, allowlists widen, and one day a real token sails through because a regex lost a character. The self-test below plants fake tokens in throwaway repositories, runs Gitleaks with the project config, and fails loudly if detection breaks or if an allowlist suppresses too much. Run it locally before every change to .gitleaks.toml and in CI on a schedule.
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "${WORK}"' EXIT
make_repo() {
local dir="$1"
mkdir -p "${dir}"
git -C "${dir}" init -q -b main
git -C "${dir}" config user.name "selftest"
git -C "${dir}" config user.email "[email protected]"
}
scan() {
gitleaks git "$1" --config "${ROOT}/.gitleaks.toml" \
--redact --no-banner --log-level error >/dev/null 2>&1
}
make_repo "${WORK}/live"
printf 'TOKEN=acme_live_%s\n' "$(openssl rand -hex 16)" > "${WORK}/live/app.env"
git -C "${WORK}/live" add . && git -C "${WORK}/live" commit -q -m "plant live token"
git -C "${WORK}/live" rm -q app.env && git -C "${WORK}/live" commit -q -m "remove it"
if scan "${WORK}/live"; then
echo "FAIL: live token buried in history was not detected" >&2
exit 1
fi
echo "PASS: live token detected in history"
make_repo "${WORK}/pat"
printf 'GH=ghp_%s\n' "$(openssl rand -hex 18)" > "${WORK}/pat/ci.env"
git -C "${WORK}/pat" add . && git -C "${WORK}/pat" commit -q -m "plant pat"
if scan "${WORK}/pat"; then
echo "FAIL: default rules did not fire, check [extend] useDefault" >&2
exit 1
fi
echo "PASS: default rules still active"
make_repo "${WORK}/test"
printf 'TOKEN=acme_test_%s\n' "$(openssl rand -hex 16)" > "${WORK}/test/app.env"
git -C "${WORK}/test" add . && git -C "${WORK}/test" commit -q -m "plant test token"
if ! scan "${WORK}/test"; then
echo "FAIL: test-tier token should be allowlisted" >&2
exit 1
fi
echo "PASS: test-tier token allowlisted"
echo "All self-tests passed."
The three cases cover the three ways a config usually fails: a custom rule that stops matching, default rules that disappear, and an allowlist that is either too narrow or too wide. If you add a global allowlist later, add a fourth case that proves a real-looking token inside the allowlisted path is handled the way you intend.
A Makefile ties the commands together so a new teammate runs two targets instead of reading this walkthrough.
.PHONY: setup scan history selftest baseline
setup:
pipx install pre-commit
pre-commit install
scan:
umask 077 && mkdir -p reports && \
gitleaks dir . --no-banner --redact --config .gitleaks.toml
history:
umask 077 && mkdir -p reports && \
gitleaks git . --no-banner --redact --config .gitleaks.toml \
--report-format json --report-path reports/gitleaks.json; \
python3 scripts/summarize_report.py reports/gitleaks.json
selftest:
bash scripts/selftest.sh
baseline:
umask 077 && gitleaks git . --no-banner --redact --config .gitleaks.toml \
--report-format json --report-path .gitleaks-baseline.json
Your repository should now match this layout. Every file came from a step above.
secret-guard-demo/
|-- .githooks/
| `-- pre-commit # Step 10 (fallback hook)
|-- .github/
| `-- workflows/
| `-- secret-scan.yml # Step 11
|-- config/
| `-- settings.py # Step 2 (history holds the planted token)
|-- reports/ # Step 4 (git-ignored)
|-- scripts/
| |-- selftest.sh # self-test above
| `-- summarize_report.py # Step 4
|-- tests/fixtures/ # Step 7 (allowlisted path)
|-- .gitignore # Step 5
|-- .gitleaks-baseline.json # Step 12 (optional, redacted)
|-- .gitleaks.toml # Steps 6 and 7
|-- .gitleaksignore # Step 8
|-- .pre-commit-config.yaml # Step 9
`-- Makefile
Finish with a full verification pass. Run the self-test, scan history, then try to commit a fresh fake token to prove the hook still blocks it.
make selftest
make history
printf 'X=acme_live_%s\n' "$(openssl rand -hex 16)" > leak-test.env
git add leak-test.env && git commit -m "should be blocked"
git restore --staged leak-test.env && rm leak-test.env
Expect the self-test to print three PASS lines followed by the closing message, the history scan to report the Step 2 token (unless you baselined it), and the commit to fail. If all three behave that way, the stack works end to end.
When a Secret Gets Through: A Five-Step Response #
Scanners reduce risk and never remove it. Plan the response before you need it, because the first hour decides whether a leak becomes an incident. The ordering matters. Rotation comes first, cleanup comes last, and nothing about cleanup undoes exposure. For a broader view of detection and response practice, see the security coverage on this site.
- Revoke and rotate immediately. Assume anyone who cloned, forked or cached the repository has the credential. Issue a new one and kill the old one at the provider, before you touch Git.
- Check usage. Pull the provider audit logs for the exposure window. Look for requests from unfamiliar IP addresses, new resources and permission changes. Record what you find even if it is nothing.
- Remove it from history if policy requires. Use
git filter-repo --replace-texton a fresh mirror clone to scrub the string from every commit, then force-push and ask collaborators to re-clone. This step tidies the record. It does not make the secret safe again. - Record the fingerprint. Once the credential is dead, add its fingerprint to
.gitleaksignorewith the ticket number, so the baseline stays quiet and the audit trail stays complete. - Add a regression test. If the leaked format had no rule, write one, add a case to
selftest.shand ship it. Every incident should leave the scanner stronger.
git clone --mirror [email protected]:org/repo.git repo-mirror.git
cd repo-mirror.git
printf '%s==>REMOVED-SECRET\n' "THE-LEAKED-STRING" > ../replacements.txt
git filter-repo --replace-text ../replacements.txt
git push --force --mirror
shred -u ../replacements.txt 2>/dev/null || rm -f ../replacements.txt
Pull requests, forks and any cached views on a Git host may keep the old commits reachable after a force push, and your host may need to purge them on request. This is one more reason to put rotation first.
Rolling Gitleaks Out to a Team Without Starting a Revolt #
The technical setup takes an hour. The social setup takes a few weeks, and it decides whether the tool survives. Developers accept a gate that rarely cries wolf and explains itself. They resent one that blocks a release over a string in a test file. Plan the rollout in stages and give each stage an owner.
Week one: observe without blocking
Run the CI job in report-only mode first. Let it post the step summary but exit with code 0, either by adding continue-on-error: true to the scan step or by ignoring the exit status in the script. Collect every finding from a full-history scan across your repositories. Sort them into three buckets: live credentials that need rotation today, revoked credentials that belong in the baseline, and false positives that need a rule or allowlist change. The first bucket is usually the smallest and always the most valuable.
Week two: fix the rules, then turn on the gate
Spend this week on tuning. Every false positive you fix now saves a hundred annoyed pull requests later. Tighten regexes, add scoped allowlists with a reason, and generate the redacted baseline for each repository. Only then flip the job to blocking and mark it as a required check. Announce the change in the engineering channel with a short note on how to read a finding, how to request an exception and who to ask for help.
Week three and beyond: add the hook and measure
Roll out the pre-commit hook once the CI gate is stable. A hook that fires before the team trusts the rules feels like surveillance, while a hook that mirrors a check they already know feels like a convenience. Track a few simple signals without inventing targets: how many findings the hook blocks per month, how many pull requests the CI gate fails, how long exceptions stay in the ignore file, and how many baselined findings remain. A falling baseline count is the best single sign that the program works.
Assign ownership for two recurring chores. Someone reviews the ignore file and baseline every quarter and removes entries for credentials that no longer exist. Someone else owns version bumps for Gitleaks, pre-commit and the workflow, and reads the upstream release notes for syntax changes before merging them. Small, boring ownership beats a heroic cleanup every time, because secret scanning is a habit rather than a project.
Finally, write down the exception policy. Decide who can approve an allowlist entry, whether an exception needs an expiry date and what evidence proves a credential is dead. A one-page policy removes most arguments, and it gives auditors something concrete to read when they ask how the organization handles leaked credentials.
Common Pitfalls That Break Secret Scanning #
Most failed rollouts trace back to the same handful of mistakes. Each of the eight below has burned a real team, and each takes minutes to avoid once you know it exists.
- Using deprecated commands. Tutorials from before v8.19.0 teach
gitleaks detectandgitleaks protect. They still run, but the project deprecated them, and mixed old and new syntax across hooks and CI makes upgrades painful. Standardize ongitanddir. - Scanning shallow clones. GitHub Actions checks out a single commit by default. A history scan on that checkout inspects almost nothing and still reports success. Set
fetch-depth: 0and confirm the commit count in the log line. - Trusting the working tree. As Step 3 showed,
gitleaks dircannot see a token that a later commit removed. Always include at least one history scan in the pipeline. - Leaking through the report. An unredacted JSON report, a debug log or an uploaded artifact can hold every secret you just found. Use
--redacteverywhere, setumask 077, and git-ignore the reports directory. - Allowlisting too much. A pattern like
\.md$or a bare.*regex hides real leaks in documentation, where people love pasting credentials. Scope by directory, by rule throughtargetRules, and by a documented reason. - Believing a delete fixes a leak. Removing the file, rewriting history or making the repository private does not recall a credential someone already copied. Rotate first.
- Relying on the hook alone. Hooks run on the developer machine, and
--no-verifyskips them. Without a required CI check, the hook is advice rather than policy. - Floating versions. A
latestDocker tag or an unpinned download changes behavior without a commit in your repo. Pin the version, verify the checksum and bump on purpose.
A ninth mistake deserves a mention: ignoring the findings. A scanner that fails every build on noise gets disabled within a week. Spend the first day tuning rules and baselining, so the first red check a developer sees is a real one.
Troubleshooting: 11 Problems and Their Fixes #
Work through the table top to bottom when something misbehaves. Most symptoms map to a single cause.
| Symptom | Likely cause | Fix |
|---|---|---|
| Config fails to parse after an upgrade, or allowlists seem ignored | Old [allowlist] or[rules.allowlist] syntax |
Rewrite to [[allowlists]] and[[rules.allowlists]] , which v8.25.0 and v8.21.0 introduced |
Deprecation warnings mentioning detect orprotect |
Hooks or scripts written before v8.19.0 | Switch to gitleaks git andgitleaks dir |
| CI passes but scans one commit | Shallow checkout | Add fetch-depth: 0 toactions/checkout |
fatal: detected dubious ownership in a container |
Repository owned by a different user than the one running Git | Run git config --global --add safe.directory /path before the scan |
| First pre-commit run takes minutes or fails to build | pre-commit compiles the Go hook and may bootstrap Go, which needs network access | Allow the network on first run, then rely on the cache. Use the native hook from Step 10 on locked-down machines |
Cowardly refusing to install hooks with core.hooksPath set |
Both pre-commit and a custom hooks path are active | Run git config --unset core.hooksPath or use only the native hook |
| A custom hook fails before scanning when several files are staged | pre-commit passes filenames to a system-level hook that does not expect them | Prefer the stock gitleaks hook. A proposed upstream fix setspass_filenames: false , so add that to any custom local hook |
| Every legacy finding fails the build | No baseline | Create a redacted baseline (Step 12) and pass --baseline-path |
Job fails with fatal: bad object on a push event |
A force-push removed the commit named by github.event.before |
Catch the failed range lookup in the script and fall back to a full-history scan for that run |
| Release download fails on runners behind a firewall | GitHub began spreading eligible Linux jobs across Azure and AWS on October 28, 2026 | Allow all published GitHub Actions IP ranges in your egress rules |
| Scan flags MD5 hashes or lockfile checksums | Loose custom regex or the generic key rule | Tighten the regex, raise entropy , or add a path allowlist scoped withtargetRules |
If none of these match, rerun with --log-level debug and read the first few lines. Gitleaks prints which config file it loaded, and a surprising path there explains many mysteries. Check whether an environment variable such as GITLEAKS_CONFIG is overriding your file.
Advanced Tips for Larger Teams #
Scan encoded content and archives
Attackers and careless developers both hide secrets in Base64 blobs. Recursive decoding is off by default, and you enable it with --max-decode-depth followed by a positive number. Raise it cautiously. Each level multiplies scan time and false positives, and a depth of two or three covers most real cases such as a Base64-wrapped JSON key file.
Add a scheduled full-history scan
The pull request job scans only new commits, so it cannot notice a rule you add next month that would have caught an old leak. A weekly scheduled workflow that runs a full history scan with your baseline closes that gap. Add schedule with a cron expression to the workflow triggers, drop the --log-opts argument for that event, and alert the security channel on failure.
Sweep every repository in an organization
#!/usr/bin/env bash
set -euo pipefail
ORG="your-org"
mkdir -p org-scan && cd org-scan
umask 077
gh repo list "${ORG}" --limit 1000 --json nameWithOwner --jq '.[].nameWithOwner' |
while read -r repo; do
name="${repo//\//_}"
git clone --mirror --quiet "https://github.com/${repo}.git" "${name}.git"
gitleaks git "${name}.git" --no-banner --redact \
--report-format json --report-path "${name}.json" || echo "LEAKS: ${repo}"
rm -rf "${name}.git"
done
Enforce on the server side
Client hooks and CI both run after code leaves the laptop. On a self-hosted Git server, a pre-receive hook that runs gitleaks git --log-opts against the pushed range rejects the push itself, so the secret never lands in the central repository. Hosted platforms offer a similar control through push protection, and you can run both.
Other practical upgrades
- Emit SARIF with
--report-format sarifwhen you want findings to appear in a code scanning interface instead of a step summary. - Pin CI dependencies. GitHub opened workflow dependency lockfiles in public preview on October 14, 2026, which is worth testing on a non-critical repository.
- Share one org-wide config by pointing
[extend]at a path, or by settingGITLEAKS_CONFIGin CI. Keep repo-specific allowlists in each repo. - Use
init.templateDiror a bootstrap script so new clones receive the hook automatically. - Run the same binary on GitLab CI, Jenkins or CircleCI. Gitleaks is a CLI, so the commands in Step 11 translate directly.
- Review the tracker before upgrading. Open issues in October 2026 include a request to set
--redactthrough the config file, which does not exist yet, so keep the flag on the command line.
Frequently Asked Questions #
Is Gitleaks free for commercial use?
Yes. The project uses the MIT license, which permits commercial use, and the pre-commit framework is also free. Your only costs are CI minutes and the time you spend tuning rules.
Does Gitleaks scan the full Git history?
It does when you run gitleaks git without range options. Add --log-opts to limit the scan to a commit range, or --staged to inspect only staged changes. The dir command ignores history entirely.
How do I stop false positives without hiding real leaks?
Use the narrowest tool that works. Tighten the rule regex first, then raise the entropy floor, then add a path allowlist with targetRules so the suppression applies to one rule only. Reserve gitleaks:allow comments and fingerprints for individual, documented cases.
Should I use Gitleaks or TruffleHog?
They overlap, and many teams run one as a fast gate and consider the other for triage. Gitleaks favors a simple binary and rules you control. TruffleHog is commonly chosen when checking whether a found credential is still active matters. Test both on a copy of your own repositories, because rule coverage varies by credential type.
Does GitHub secret scanning make Gitleaks unnecessary?
Not for most teams. GitHub scanning works only on GitHub, and some of its security features need a paid license. Gitleaks runs on any host, on a laptop and in any CI system, and it understands the custom token formats you define. Running both is a reasonable belt-and-braces setup.
What should I do after a secret reaches the main branch?
Rotate the credential first, then review provider logs for misuse, then clean history if your policy requires it. Deleting the commit alone does not protect you, because clones and forks keep the old data.
How often should I update the Gitleaks version?
Check the releases page monthly and update the pinned version in both .pre-commit-config.yaml and the workflow. After each bump, run the self-test and compare findings against the previous version so a rule change does not surprise you.