A secret that has been committed has been shared. Detection helps, but it works best as one layer in a delivery pipeline that is built to keep credentials out of source control in the first place.
Why a committed secret is hard to take back
Git keeps history, and history gets copied. A commit reaches every clone, may be fetched by CI runners, and can end up in forks and cached views on a hosting service. Deleting the file in the next commit removes it from the current tree. It does not remove it from the commits before.
The safe working assumption is that any secret that reached a shared remote is exposed and has to be replaced. That makes timing the main design question. The cheapest place to stop a secret is before it is committed, the next best is before it is merged, and the most expensive is after it has been pushed.
What Gitleaks is, and what it is not
Gitleaks is an open-source command-line scanner that looks for secrets such as passwords, API keys and tokens in Git repositories, in files, and in anything piped to it on standard input. It works from rules. Each rule has a regular expression, and many add keywords and a minimum entropy value to cut down noise. A built-in rule set covers common credential formats, and you can extend it with rules of your own.
It detects. It does not store, rotate or revoke secrets, and it does not stop anyone from reading one that is already exposed. I would treat it as a smoke alarm and not as a safe.
How secrets reach a repository
- Config files. A
.envfile or settings file committed with real values, often because it was never added to.gitignore. - Fixtures and sample data. Test data copied from a real environment and never cleaned.
- Scripts and notebooks. A token pasted in to get something working and left there.
- Connection strings. Credentials embedded in a string that looks like ordinary configuration.
- Pipeline definitions. A value written into a CI file instead of the platform’s secret store.
Most of these are shortcuts and not carelessness. They happen when the secure route is slower than the insecure one.
Layers, not a single control
Each control has a way around it. The aim is to place checks where they are cheap, and to make sure the later ones do not depend on the earlier ones working.
Controls get stronger and harder to bypass further along the path. The response row applies whenever a layer misses.
| Layer | Where it runs | Strength | Weakness |
|---|---|---|---|
| Pre-commit hook | Developer machine | Fast feedback, before the secret enters history | Local, optional and easy to skip |
| CI scan | Shared pipeline | Same check for everyone, can block a merge | The secret is already pushed to a branch |
| Platform push protection | Hosting service | Blocks supported patterns before they land | Limited to patterns the platform knows; availability depends on your plan |
| Scheduled scan | Shared pipeline | Re-checks history when rules change | Finds problems after the fact |
Push protection belongs to the hosting platform and not to Gitleaks. GitHub describes it as limited to patterns its secret scanning recognises, unless custom patterns are defined. It complements a scanner you control and does not replace one.
Detection and prevention are different jobs
Everything above detects secrets that have already been written into a file. Prevention means they are never in a file at all: values supplied at runtime from a secret manager or from the platform’s own workload identity, short-lived credentials in place of long-lived ones, and local-only config files listed in .gitignore. Prevention reduces how often the scanner has anything to find. The scanner covers the cases where the habit fails.
Running Gitleaks
Installation options in the project’s README include Homebrew (brew install gitleaks) and a Docker image, and you can also build from source. The commands below use the current command set. Version 8.19.0 deprecated detect and protect in favour of git, dir and stdin. The old commands still work but are hidden from help, so many older guides still show them.
# Scan the Git history of the repository in the current directory
gitleaks git -v .
# Scan files on disk, without looking at Git history
gitleaks dir -v .
# Scan only staged changes, as a pre-commit hook does
gitleaks git --pre-commit --redact --staged --verbose
# Write a SARIF report; --redact masks the secret in output
gitleaks git --redact --report-format sarif --report-path gitleaks.sarif .
The exit code is what makes it usable in automation: 0 means nothing was found, and 1 means leaks were found or an error occurred. The --exit-code flag changes the code returned when leaks are found.
Output is a finding for each match. Reports can be written as JSON, CSV, JUnit or SARIF. Redaction matters more than it looks, because a report that prints the secret it found becomes a second copy of it. Use --redact for anything that goes into shared logs or stored artifacts.
| Finding field | What it tells you |
|---|---|
| RuleID, Description | Which rule matched, and what kind of credential it believes it is |
| File, StartLine, EndLine | Where in the file the match sits |
| Commit, Author, Date, Message | For Git scans, which commit introduced it, and so who can say whether it is real |
| Entropy | How random the matched text is, which helps separate real keys from placeholders |
| Fingerprint | A stable identifier for the finding, used when ignoring one specific result |
The pre-commit hook
The project ships a hook for the pre-commit framework. It runs the staged-changes scan shown above before each commit.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1 # pin a release tag; "pre-commit autoupdate" moves it forward
hooks:
- id: gitleaks
pre-commit install
# Skipping the hook is possible, and should stay a visible, deliberate act
SKIP=gitleaks git commit -m "message"
If you do not use the framework, a plain Git hook does the same job.
#!/bin/sh
# .git/hooks/pre-commit (make it executable: chmod +x .git/hooks/pre-commit)
gitleaks git --pre-commit --redact --staged --verbose
The limitation is structural. A hook runs on the developer’s machine, so each person has to install it. Files in .git/hooks are not part of the repository and are not cloned, and the framework’s configuration is shared but still needs pre-commit install on every machine. It can also be bypassed deliberately or by accident, and the hook only looks at staged changes. That makes it a convenience and not an enforcement point. Its real value is that it catches the mistake before the secret enters history, where nothing else is as cheap.
Scanning in CI/CD with GitHub Actions
CI is where the check becomes shared and enforceable. This is the workflow from the action’s README, trimmed to the parts that matter here.
name: gitleaks
on:
pull_request:
push:
workflow_dispatch:
jobs:
scan:
name: gitleaks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history, not a shallow clone
- uses: gitleaks/gitleaks-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} # organisation accounts only
Three details are worth calling out:
- Full history.
fetch-depth: 0fetches every commit. A shallow checkout contains only recent commits, so a history scan would see only those. - Licensing. According to the action’s README, a
GITLEAKS_LICENSEsecret is required for organisation accounts and not for personal accounts, andGITHUB_TOKENis supplied automatically. That requirement is described for the action. Check the README for current terms before adopting it. - Making it count. A scan that can fail but is not a required check on the protected branch is advisory. Mark it as required so a failing scan blocks the merge.
The action is one way to run the scan. In any other CI system you can install the binary and call gitleaks git, since a non-zero exit code fails the job.
Configuring rules
Gitleaks looks for configuration in a fixed order: the --config flag, the GITLEAKS_CONFIG environment variable (a file path), GITLEAKS_CONFIG_TOML (the content itself), a .gitleaks.toml in the scanned path, and finally the built-in default.
# .gitleaks.toml
title = "Repository secret rules"
[extend]
useDefault = true # keep the built-in rules and add to them
[[rules]]
id = "acme-internal-token"
description = "Internal service token (synthetic example format)"
regex = '''acme_tok_[A-Za-z0-9]{32}'''
keywords = ["acme_tok_"] # cheap pre-filter before the regex runs
[[rules.allowlists]]
description = "Obviously synthetic values in test fixtures"
paths = ['''tests/fixtures/.*''']
The [extend] block is what pulls the built-in rules into your file. After any configuration change, test it against a deliberately synthetic value in a scratch directory and confirm the scan still reports it. A config that quietly stops detecting things is worse than none, because it looks like protection.
One compatibility note. Rule-level allowlists use [[rules.allowlists]] from v8.21.0, and earlier versions used [rules.allowlist]. Global [[allowlists]] blocks need v8.25.0 or later. Check the version your pipeline installs before copying configuration from an older post.
False positives, allowlists and baselines
Any scanner that matches patterns will flag things that are not secrets, and a team that is not given a way to say so will start ignoring the tool. The answer is a short, disciplined triage.
Triage order for a finding. The first question is always whether the credential is real.
| Mechanism | Scope | Good for | Risk |
|---|---|---|---|
Inline gitleaks:allow comment | One line | One known synthetic value | Easy to add without anyone reviewing it |
.gitleaksignore fingerprint | One finding | One specific, reviewed result | Tied to a particular finding, so it needs upkeep as code moves |
| Rule or global allowlist | A pattern: paths, regexes, stopwords, commits | Recurring fixtures and generated files | A broad regex or path can hide real secrets |
| Baseline | Everything in a report | Adopting the tool on an existing repository | Hides every finding it contains, real ones included |
Four habits keep these under control:
- Narrowest first. Allow one finding before a file, and a file before a directory. Never allowlist a source directory as a whole.
- Write down why. Use the allowlist’s
descriptionfield or the pull request, so a later reader can tell a reviewed decision from a silenced alarm. - Never allowlist a real credential. If it is real, it gets rotated. An allowlist entry is only for values that are not secrets.
- Review them. Allowlists only grow unless someone removes entries, and stale ones can cover things they were never meant to.
Baselines have a trade-off
Switching on a scanner in a repository with years of history can produce hundreds of findings. Blocking every build on them stops adoption, so a baseline lets you record today’s findings and fail only on new ones.
# Record today's findings once, after triage
gitleaks git --report-path gitleaks-baseline.json .
# From then on, report only findings that are not in the baseline
gitleaks git --baseline-path gitleaks-baseline.json --report-path findings.json .
The trade-off is that the baseline hides everything in it. Triage first, rotate whatever is real, and baseline only the remainder, with a note saying what the baseline contains and why.
What Gitleaks cannot guarantee
Because it is rule-based, Gitleaks is bounded by its rules and by what you point it at.
It can reasonably help with
- Credential formats that a rule describes, including rules you add.
- Catching a mistake at commit or review time.
- Scanning the history of repositories you point it at.
- A consistent check that does not depend on a reviewer noticing.
It cannot promise
- Formats no rule covers, until you add one.
- Values with no recognisable shape or context, such as a short human-chosen password with an unremarkable variable name.
- Encoded values, unless decoding is turned on. Decoding of percent, hex and base64 is controlled by
--max-decode-depth, which defaults to 0. - Content inside archives, unless
--max-archive-depthis set. - Anything you do not scan: chat, tickets, images, container layers, build logs and other repositories.
- Telling you whether a credential is live or has already been used.
A clean scan means no rule matched. It does not mean there are no secrets. The opposite also holds: a match is a candidate that someone has to judge, because a rule can flag a value that is not a secret.
If a real secret has already been committed
Write these steps down before you need them, because the order matters.
- Treat it as exposed, and revoke or rotate it first. GitHub’s guidance says that for a secret, the first step is to revoke or rotate it, and that doing so may be enough to solve the problem. Once the old value stops working, the exposure ends. Do this before anything involving Git.
- Work out the exposure. How long was it in the repository, who could read the repository, and did it reach forks, CI logs or stored artifacts? Then check the provider’s audit logs for use of that credential during the window.
- Replace it properly. Put the new value in a secret store or inject it at runtime, scope it to the least access it needs, and change the code so the value is no longer in a file.
- Decide whether to rewrite history. Rewriting is optional once the credential is dead, and it has limits. GitHub’s documentation warns that after a rewrite and force push, the old commits may still be reachable in clones and forks, directly by SHA through cached views, and through pull requests that reference them. It recommends
git-filter-repoand names a minimum version for its sensitive-data option, so follow its current guide and not a command copied from an older post. - Close the loop. Add a rule, a test or a hook so the same kind of value is caught next time, and record why the earlier layers missed it.
Deleting the secret in a later commit does not help. The latest commit looks clean, but every earlier commit still contains it, and a scan of full history will go on reporting it. It also shows why a baseline must never be used to hide a real credential.
Key takeaways
- Treat any committed secret as exposed. Revoke or rotate before cleaning history.
- Layer the controls. A hook gives fast feedback, CI gives enforcement, and neither stands alone.
- Detection finds secrets after they are written. Prevention keeps them out of files.
- Keep allowlists narrow and justified, and baseline only after triage.
- A clean scan means no rule matched, and it is not proof that nothing is there.
References
- Gitleaks, project README and releases (commands, configuration, hooks, baselines)
- gitleaks-action, GitHub Action README (workflow, variables, licensing)
- pre-commit, framework documentation
- githooks, Git documentation
- Removing sensitive data from a repository, GitHub Docs
- About push protection, GitHub Docs
Commands, flags and configuration syntax were checked against the project documentation when this was written. Gitleaks and its action change, so confirm against the current README before relying on any detail.