gitleaks
gitleaks is a single Go binary that scans text for the shape of a secret — an AWS access key, a Stripe live key, a private key header, or just a string random enough to be a credential nobody named — and it does that scan in two very different places: on your laptop, against only the lines you're about to commit, and in CI, against every diff in the repository's entire history. That second mode is the one people underestimate. A secret you deleted last Tuesday is still sitting in a commit object from three months ago, readable by anyone who runs git log -p, and gitleaks' whole reason to exist is to find it there before an attacker does.
Imagine your class turns in homework by dropping pages into a big folder, and once a page is in the folder nobody's allowed to take it back out — they can only add a note on top saying "ignore the old page." If you ever wrote your house key's location on a page by mistake, crossing it out later on a new page doesn't help: the old page with the real answer is still in the folder, forever. gitleaks is a librarian who reads every single page in the folder, past and present, looking for anything that looks like a secret — a key, a password, a code — and flags it, even if it's buried under a hundred pages nobody's looked at in months.
What gitleaks is and the problem it solves
☺ Like you're 10: It's a fast, free program that reads your code changes and yells if something in them looks like a real password or key.
gitleaks is an open-source (MIT-licensed core CLI), single-binary secrets scanner written in Go, originally by Zachary Rice and now maintained under the gitleaks organization on GitHub. It ships with no runtime dependency beyond the binary itself — no database to sync, no account to create, no network call to make a decision — which is exactly what makes it cheap enough to run on every keystroke a pre-commit hook cares about and every commit a CI pipeline touches. Its whole job is one question, asked over and over against every line of every diff it's pointed at: does this string match the shape of a real credential?
Two subcommands do essentially all of the work, and confusing them is the single most common gitleaks mistake:
gitleaks detect— by default, walks the entire git history of a repository, commit by commit, diff by diff, looking for anything a rule matches. This is the tool's history-scanning mode, and it's what you run to answer "has a secret ever been committed to this repo, at any point, even if it was deleted the next day?"gitleaks protect— scans only the currently staged changes: whatgit diff --stagedwould show. This is the fast, narrow mode built specifically to sit inside a pre-commit hook, where it has to return an answer before the commit is even created, not minutes later.
The problem this solves is the one secrets management describes from the policy side: no amount of "please don't hardcode credentials" training gets a team to zero, because a paste-and-forget mistake takes one distracted second. gitleaks doesn't try to fix the human habit — it assumes the mistake will keep happening and puts a fast, automated check at both places a secret could still slip through: the moment before it's committed, and the moment before it's trusted as clean history.
How detection works: regex rules and entropy
☺ Like you're 10: It looks for two different things — text that matches a known password pattern, and text that's just weirdly random-looking for no good reason.
gitleaks ships with a default configuration — a TOML file embedded in the binary — containing well over a hundred rules, each one built to catch a specific credential shape: AWS access keys (AKIA…), GitHub personal access tokens (ghp_…), Slack tokens, Stripe live keys (sk_live_…), SendGrid keys, PEM-formatted private key headers, JWTs, and more. Every rule is one of two kinds, and most real configs use both:
- Pattern rules — a regular expression written to match one vendor's known token format exactly. These are precise: a match on the AWS access-key rule is a match because the string genuinely starts with
AKIAfollowed by sixteen more specific characters, not because it merely looks random. - Entropy rules — for the much larger set of secrets that don't have a recognizable prefix (an internal API key, a database password, a custom token format), gitleaks falls back to Shannon entropy: a measure of how random a string's characters are.
password123has low entropy — it's mostly predictable, dictionary-shaped text.xK9$mQ2vL8pR4tN7wZ1chas high entropy — every character looks independent of the last. A generic rule pairs a loose contextual pattern (a variable name likeapi_key,secret, ortokensitting near an assignment) with an entropy threshold, so it fires on "something random-looking was just assigned to a variable named like a credential" even when it has no idea what kind of credential it is.
Every rule also carries a keywords list — a set of plain substrings (like akia or secret) that gitleaks checks with a cheap literal search before it ever runs the rule's regex against a line. Only a line that contains one of a rule's keywords pays the cost of that rule's regex match. Across a hundred-plus rules and a diff with thousands of lines, that pre-filter is the difference between a scan that finishes in under a second and one that doesn't.
The .gitleaks.toml config: rules and allowlists
☺ Like you're 10: The rulebook lives in one file, and you're allowed to add your own rules and cross out the false alarms.
The default embedded ruleset covers the common vendor formats well, but every real repository accumulates its own false positives — a test fixture with a fake key, a vendored file with a long base64 blob, a lockfile hash that happens to look entropy-rich. A repo-local .gitleaks.toml is where you extend the rule set and, just as importantly, tell gitleaks what to ignore.
# .gitleaks.toml — illustrative shape; check the current default config
# on the gitleaks GitHub repo before assuming exact regexes or field names.
[[rules]]
id = "acme-internal-service-token"
description = "Acme internal service-to-service token"
regex = '''acme_(?:prod|stage)_[A-Za-z0-9]{32}'''
tags = ["acme", "internal", "high-confidence"]
[[rules]]
id = "generic-high-entropy-secret"
description = "Generic assignment to a credential-shaped variable name"
regex = '''(?i)(secret|token|api[_-]?key|password)\s*[:=]\s*['"][0-9a-zA-Z\-_/+=]{16,64}['"]'''
secretGroup = 0 # which capture group holds the actual secret, for entropy + redaction
entropy = 3.75 # tune this per repo — too low and everything trips it
[allowlist]
description = "Known-safe paths and fixtures"
paths = [
'''(.*?)(jpg|png|gif|pdf|woff2?)$''',
'''test/fixtures/.*''',
'''\.gitleaks\.toml'''
]
regexes = [
'''EXAMPLE_KEY_[A-Z0-9]{16}''' # placeholder values used in docs, not real secrets
]
commits = [
# a commit SHA already triaged, rotated, and accepted as a known historical leak
"3f9a1c2b7e4d5f6a8b9c0d1e2f3a4b5c6d7e8f90"
]Two fields do the quiet work here. secretGroup tells gitleaks which regex capture group actually contains the secret value — the rest of the match (the variable name, the quotes, the equals sign) is just context, and only the captured group gets entropy-checked and redacted. allowlist.commits is how you close the loop on a historical finding: once a leaked credential in an old commit has been triaged and rotated, you allowlist that specific commit SHA so it stops showing up as a fresh alert on every subsequent scan, without silencing the rule for the rest of the repository.
gitleaks never confirms a secret is real — it only confirms a string matches a shape that's usually a secret. That's a deliberate trade: matching is fast and needs no network call, which is exactly what makes it cheap enough to run on every commit. The cost is false positives that need allowlist tuning, and — the more important asymmetry — false negatives on any credential format the rules don't know and that isn't random enough to trip the entropy check. See TruffleHog for the tool built around the other half of that trade: live verification against the issuing provider, at the cost of needing network access to do it.
Git-history scanning: catching a secret buried in old commits
☺ Like you're 10: Deleting a secret in a new commit doesn't erase it — the old commit still has it, and gitleaks checks every old commit, not just the newest one.
This is the mode the content brief for this page is really about, and it's worth being precise about why it matters. Git is append-only by design: a commit object, once created, is immutable, and git log can always walk back to it. If a credential is committed and then "removed" in the very next commit, the removal only changes what the latest tree looks like — the earlier commit object, with the real secret sitting in its diff, is still there, still reachable by anyone with clone access, and still sitting in every fork or local clone made before the deletion. A plain grep -r across a checked-out working directory will never see it, because the working directory only ever shows the current state.
gitleaks detect is built specifically to close that gap. By default — with no extra flags — it doesn't scan the working tree at all; it walks the entire commit history via git log -p under the hood, running the rule engine against every commit's diff, one at a time, from the very first commit to the tip of the current branch. A secret committed two years ago and deleted a day later is exactly as visible to detect as one committed an hour ago.
# full history walk against the default embedded ruleset — the "has this repo ever leaked anything" scan $ gitleaks detect --source . -v # same, but only the current working tree — ignores git entirely (rare; mostly for non-git directories) $ gitleaks detect --source . --no-git # use a repo-local rule/allowlist file instead of the embedded defaults $ gitleaks detect --config .gitleaks.toml --source . # machine-readable report, with secret VALUES redacted in the output $ gitleaks detect --report-format json --report-path gitleaks-report.json --redact # scope the history walk with ordinary git log flags — useful on a very large, very old repo $ gitleaks detect --log-opts="--since=2024-01-01" $ gitleaks detect --log-opts="--all" # every branch and tag, not just the current HEAD # only report NEW findings against a saved baseline — for adopting gitleaks into a repo with existing debt $ gitleaks detect --baseline-path gitleaks-report.json # exit code: 0 = clean, 1 = at least one leak found — this is what a CI step checks $ echo $?
Two flags matter enough to call out on their own. --redact replaces the actual secret value with a redacted placeholder in whatever report gitleaks writes — without it, the JSON report itself becomes a second copy of the leaked credential, which is exactly the kind of artifact that ends up sitting in a CI log or an S3 bucket with its own access-control problem. --baseline-path is the practical answer to "we just turned gitleaks on and it found four hundred historical findings we can't triage today" — it lets a team gate on new leaks immediately while working through the historical backlog on its own schedule, the same discipline described in vulnerability management & triage for any scanner's findings backlog.
Finding a secret in history is not the same as fixing it. Secrets management covers this in depth, but the short version bears repeating here: purging history with git filter-repo or the BFG Repo-Cleaner rewrites commit SHAs going forward, but it does nothing about clones, forks, or CI caches that already exist with the old history intact. The only response that actually closes the exposure is rotating the credential — treating it as compromised the moment gitleaks flags it, regardless of how old the commit is or whether you've since rewritten history to remove it.
Wiring it in: the pre-commit hook, then CI as the real gate
☺ Like you're 10: First a friendly check right before you save your work, then a strict check nobody's allowed to skip before it joins everyone else's code.
The pre-commit hook is gitleaks protect's job specifically — it has to be fast, because it runs synchronously every time a developer types git commit, and scanning only the staged diff (not the whole repo, not the whole history) is what keeps it fast enough not to be annoying. The most common way to wire it in uses the pre-commit framework, which gitleaks ships a hook definition for:
# .pre-commit-config.yaml — pin a real released tag, not a moving branch
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaksRunning pre-commit install once per clone writes that hook into .git/hooks/pre-commit, so it fires automatically on every future commit in that checkout. If a team doesn't want the pre-commit framework as a dependency, the same idea works as a plain shell hook:
# .git/hooks/pre-commit — must be executable (chmod +x) #!/bin/sh gitleaks protect --staged --redact -v exit $?
Neither of these is the real control, and this course keeps coming back to why: a git hook lives inside .git/, which isn't cloned or version-controlled by default, so it has to be installed separately in every developer's checkout — and even installed, it's trivially bypassed with git commit --no-verify. Treat the pre-commit hook as a courtesy that catches most mistakes for free, cheaply, before they cost anyone else anything — and treat the CI-level scan as the actual gate, because it's the one a developer can't opt out of from their own machine.
# .github/workflows/gitleaks.yml — illustrative; pin real action/image versions before using
name: gitleaks
on: [pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # see the callout below — this line is load-bearing
- run: |
docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest \
detect --source="/repo" --redact --exit-code 1 -vRunning the official image directly, as above, sidesteps any question about the GitHub Marketplace action's current licensing terms — the gitleaks-action project has changed its terms for organization and private-repo use more than once, so check its repository for the current terms before adopting it rather than assuming it's unconditionally free at scale.
Most CI checkout actions default to a shallow clone — actions/checkout without fetch-depth: 0 fetches only the single most recent commit. gitleaks detect walking git history in that job doesn't error out; it just walks a history that's one commit long, finds nothing buried further back because there's nothing further back to find, and reports clean. The pipeline goes green, and everyone reasonably assumes the whole repository has been checked — when in fact only the tip commit ever was. Always fetch full history (fetch-depth: 0) in a job that runs gitleaks detect against history, or scope it deliberately with --log-opts and know exactly what range you're actually covering.
Gotchas and failure modes
☺ Like you're 10: Most surprises come from the same two facts — it only knows the patterns it's been told about, and a "yes" from it means "looks like a secret," not "is definitely a secret."
- Entropy rules produce real false positives. Lockfile hashes, minified JavaScript, base64-encoded test fixtures, and UUIDs are all high-entropy strings that aren't secrets. A repo that turns on the generic entropy rule without tuning its threshold or building out an allowlist will get noisy fast enough that people start ignoring the tool entirely — which is worse than not running it, because now a real finding gets ignored along with the noise.
- Detection is probabilistic, not exhaustive. A credential in a format no rule knows, or one that's below the entropy threshold, or one that's built at runtime by concatenating several short strings, can pass through cleanly. gitleaks answers "does this look like a known secret shape," not "is this file free of secrets" — a scan finding nothing is not proof of absence.
- An unredacted report is a second leak. Skipping
--redactmeans the report file — JSON, SARIF, whatever format — contains the raw secret value in plain text. If that report gets uploaded as a CI artifact, posted to a Slack channel, or committed anywhere, you've just created a second copy of the exact thing you were trying to prevent. --no-verifybypasses the local hook completely, silently. There's no warning, no log entry a reviewer would see — the commit just goes through unchecked. This is exactly why the CI-level scan has to be a required, merge-blocking check rather than a courtesy: it's the only layer an individual developer's local shortcuts can't route around.- A full history walk is a one-time-shaped cost, not a per-PR one. Running
gitleaks detect's full history scan on every single pull request re-walks the entire repository's history every time, which gets slower as the repo ages and is mostly redundant work — the history behind the PR's base branch hasn't changed since the last scan. A common pattern is a full history scan as a one-time onboarding step (and maybe a periodic scheduled job), with routine PR-triggered scans scoped to just the PR's own commit range via--log-opts. - Finding it isn't fixing it. Repeated from the history-scanning section because it's the mistake teams actually make: deleting the offending line, or even rewriting history to remove the commit, does not un-leak a credential that may already be sitting in a clone, a fork, or a CI cache somewhere outside your control. Rotate first, clean up history second.
gitleaks vs. TruffleHog vs. detect-secrets
☺ Like you're 10: All three spot the same kinds of things — the difference is whether one of them also checks if the key still actually works.
All three tools answer the same core question — "does this diff contain something credential-shaped?" — and any one of them run consistently is real secrets-detection coverage. The differences that actually change how you'd deploy them:
| Tool | Detection approach | Live verification | History scanning | Config |
|---|---|---|---|---|
| gitleaks | Regex pattern rules + Shannon entropy, TOML config, ~100+ built-in rules | None in the open-source core — a match means "shaped like a secret," not "confirmed live" | detect walks full git history by default; protect covers staged changes for pre-commit | TOML, single embedded default + optional repo-local file |
| TruffleHog | Regex + entropy, plus purpose-built detectors per credential type | Built-in — actively calls the issuing provider's API for many token types and flags a result as verified live or not, cutting through false-positive noise | trufflehog git scans full repository history, similar in spirit to detect | YAML custom detectors; check current licensing before assuming free use at scale |
| detect-secrets (Yelp) | Pluggable Python detectors — regex, entropy, and several credential-specific plugins | None built in | Primarily working-tree and diff focused; a committed .secrets.baseline file tracks accepted findings rather than re-walking full history by default | Plugin list configured via the CLI, tracked in the baseline JSON file |
The practical decision isn't usually "pick exactly one." gitleaks' appeal is that it's fast, dependency-free, and needs nothing beyond the binary — a strong default for the pre-commit hook everyone runs locally, and a solid CI gate on its own. TruffleHog's live-verification step earns its keep specifically for triage: when a scan turns up forty findings and only three are confirmed-live credentials, that's the difference between an afternoon of manual checking and an afternoon of just rotating the three that matter — see the leaked-credential triage drill for exactly that scenario worked end to end. A defensible pattern: gitleaks as the fast pre-commit and every-PR gate, with a scheduled or on-adoption full-history scan from either tool, and TruffleHog (or a paid gitleaks tier, where verification features exist) layered in specifically where triage volume justifies the extra network calls. See static analysis & secrets detection for how this sits inside the broader scanning strategy, and SAST, DAST & SCA for where a secrets-scanning stage sits relative to the pipeline's other gates.
Benny: The hook yelled at me for a "leaked secret" so I just committed with --no-verify. It's a demo key, it doesn't even work.
Ellie: gitleaks can't tell a demo key from a real one, Benny — it only knows the shape. That's why it flags both. Skipping the hook doesn't make it safe, it just makes it unchecked.
Timmy: Which is fine, because CI is the actual gate. It should've caught the same thing on your pull request and blocked the merge.
Foxy: It didn't, though. The PR went green. That's the part I want explained.
Rocky: Because your checkout step never fetched the history — just the tip commit. gitleaks detect walked exactly one commit and called it clean. It wasn't lying, it just never saw the other four hundred.
Timmy: fetch-depth: 0. Every time. A green check on a shallow clone isn't a clean repo, it's an unasked question.
Ellie: And Benny — real key or not, once it's committed I want it rotated on principle. I don't keep score on which secrets were "probably fine."
1. What two kinds of rules does gitleaks combine to decide a string is a secret, and what is each one for? 2. What's the difference between gitleaks detect and gitleaks protect, and where does each belong in a team's workflow? 3. Why does a shallow CI checkout silently defeat gitleaks' history-scanning mode, and what fixes it? 4. A developer bypasses the pre-commit hook with --no-verify and a secret reaches a shared branch. What's the actual fix, and why isn't deleting the file in a later commit enough?
Check your answers
- Pattern rules — regexes matching a specific vendor's known token format exactly (like AWS's
AKIAprefix) — and entropy rules, which use Shannon entropy to flag strings that are simply too random-looking to be normal text, for the much larger set of secrets with no recognizable format. detectwalks the entire git history by default, diff by diff, and is meant for CI, onboarding scans, and answering "has this repo ever leaked anything."protectscans only the currently staged changes and is built for speed inside a pre-commit hook, where it has to return an answer before the commit is even created.- Most CI checkout actions default to a shallow clone that fetches only the latest commit.
gitleaks detectstill runs and still reports "clean," but it only ever walked that one commit — it never saw anything further back, because a shallow clone doesn't contain it. The fix is fetching full history in the checkout step (fetch-depth: 0onactions/checkout), or deliberately scoping the scan with--log-optsand knowing exactly what range that covers. - The fix is rotating the credential immediately, treating it as compromised. Deleting the file in a later commit isn't enough because git history is immutable — the earlier commit object still contains the real value, and it's still reachable via
git log, in any existing clone or fork, and in CI caches, regardless of what the latest commit looks like.