GitHub push protection and secret scanning are free on public repositories. Private repositories need a paid GitHub Secret Protection license. This skill gives private and shared repositories a deterministic local replacement: a pinned Gitleaks binary, three scan scopes, and git hooks that stop a leak before it leaves the machine.
Everything runs through one script that ships with this skill: scripts/secret-scan.sh. It needs only Bash (3.2 or later, so the macOS default works), git, curl, and python3.
Quick start
SCAN=path/to/private-secret-scanning/scripts/secret-scan.sh
"$SCAN" install # pinned Gitleaks 8.30.1, SHA-256 checked before install
"$SCAN" self-test # proves leaks fail and clean repos pass on this machine
cd your-repo
"$SCAN" history --report /tmp/secret-report.json # one-time audit of every commit
"$SCAN" install-hooks # pre-commit and pre-push protection from now on
Scan scopes
| Command | Scans | Use it |
|---|---|---|
staged |
the staged diff only | pre-commit hook; fastest |
push [RANGE] |
commits in RANGE, or the commits a git push is about to send (read from pre-push stdin). A new branch or tag is scanned through its full history |
pre-push hook; catches commits made with --no-verify |
history |
every commit reachable from any ref, including files deleted later | first adoption, and before a private repo goes public |
A secret that was committed and then deleted is still in history. Only history finds it, and only rotating the credential fixes it. Rewriting history does not undo a clone that already happened.
Exit codes and failure behavior
| Exit | Meaning |
|---|---|
| 0 | no leaks |
| 1 | leaks found; each is listed on stderr |
| 2 | scanner or configuration error |
The script fails closed. It exits 2, never 0, when:
- Gitleaks is missing, cannot run, or is not the pinned version.
SECRET_SCAN_CONFIGnames a file that does not exist.- Gitleaks exits with anything other than 0 or the script's own leak code. Gitleaks also exits 1 on fatal errors, such as a malformed
.gitleaks.toml, so 1 from Gitleaks is treated as an error. - The Gitleaks exit code and its report disagree, or the report is not a list of findings.
- A
.gitleaks.tomlneither extends the built-in rules nor defines its own (see below). - A hook scan finds
.gitleaks.tomlor.gitleaksignoreuntracked or changed but not committed (staged is enough forstaged). - A push sends a ref that points at something other than a commit, such as a tag on a blob. Such a ref cannot be scanned as history.
- The report cannot be read.
A hook that exits non-zero blocks the commit or push, so a broken scanner blocks work. It does not let a leak through.
What reports contain
Terminal output and --report files hold five fields per finding: RuleID, File, StartLine, Commit, and Fingerprint. They never hold the secret, the match, or the source line. Gitleaks runs with --redact, its raw report goes to a private temp file that is deleted on exit, and the script copies only those five fields out. The report is written with mode 0600.
The fingerprint (commit:file:rule:line) is enough to find and fix the leak. It is also the line you add to .gitleaksignore to accept a finding.
Baselines and allowlists
Adopting scanning on an old repo usually turns up findings you have already rotated. Record them instead of turning the scanner off:
-
Rotate every live credential first. Accepting a finding does not make it safe.
-
Run
history --report /tmp/secret-report.json, then write its fingerprints to.gitleaksignoreat the repo root:python3 -c 'import json,sys; print("\n".join(f["Fingerprint"] for f in json.load(open(sys.argv[1]))))' \ /tmp/secret-report.json >> .gitleaksignore -
Commit
.gitleaksignore. Later scans report only new findings. Each line names one commit, file, rule, and line, so a new leak of the same secret in another commit still fails.
A .gitleaks.toml replaces the built-in rules unless it extends them. An allowlist-only file therefore turns every detector off, and the script refuses one. Start every config from this:
[extend]
useDefault = true
[allowlist]
description = "synthetic keys in test fixtures"
paths = ['''^tests/fixtures/''']
Keep allowlists narrow. Allow a specific path and rule, such as a test fixture, instead of a whole directory or a broad regex. Never allowlist a rule wholesale because it is noisy. A config that silences a rule also silences the next real leak of that type. Review a .gitleaksignore or allowlist change the way you would review an access-control change.
The script picks up .gitleaks.toml and .gitleaksignore from the repo root automatically. Hooks use them only once they are committed (or staged, for staged). A local edit cannot quietly change what gets through. Set SECRET_SCAN_CONFIG to use a shared config from elsewhere; a relative path is resolved from where you run the script.
Hooks
install-hooks writes pre-commit (runs staged) and pre-push (runs push) into the repository's hooks directory. It refuses to overwrite a hook it did not write; add a call to the script inside that hook instead. git commit --no-verify skips pre-commit, which is why pre-push scans the outgoing range again. A new branch is scanned through its full history, even if a remote-tracking ref says a remote already has those commits. Tracking refs can be stale: git remote set-url points a remote at a new URL, and the old refs stay. This matters when a branch moves from a private remote to a public one.
Keep this public, keep your data private
This skill is generic on purpose. Do not add repository inventories, fleet or host paths, credentials, or real findings to a public copy of it. Keep those in a private repository or local config. Reports belong outside the repository you scanned, or in a gitignored path.
CI
Run the same script in CI for a second check that local --no-verify cannot skip:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # history scans need every commit
- run: path/to/secret-scan.sh install
- run: path/to/secret-scan.sh history
Run this only on hosted runners, or on push events from trusted branches. Never run untrusted pull-request code on a self-hosted runner. A fork's PR can rewrite the scan script itself and then read whatever that runner can reach.
Pinned version
The script pins Gitleaks 8.30.1 and the SHA-256 of each platform build from the release's checksums.txt. To upgrade, change GITLEAKS_VERSION and all four hashes together, then run self-test. Set GITLEAKS_BIN to use a binary you installed another way. It must still report the pinned version.