# ADR 054: Gitleaks (Secret Scanning)

- HTML version: https://robbiepalmer.me/projects/personal-site/adrs/054-gitleaks
- Project: Personal Site (https://robbiepalmer.me/projects/personal-site.md)
- Status: Accepted
- Date: 2026-07-20

# Context

Secrets management is solved at the storage layer — Doppler holds runtime
secrets ([ADR 035](/projects/personal-site/adrs/035-doppler)), GitHub
environments hold CI credentials
([ADR 018](/projects/personal-site/adrs/018-github-secrets)). The unsolved
layer is *accidental commits*: a `.env` pasted into a script, a connection
string in a test fixture, an API key in a debugging commit.

The existing net for that is **GitHub secret scanning**, and it has two
structural limits:

1. **It fires after the push.** On a public repo that is too late by
   definition: the secret is in clone caches, mirrors, and scrapers the moment
   it lands. History rewrites un-publish nothing; the only real remediation is
   rotation. Detection that arrives post-push is an incident report, not a
   guard.
2. **It matches known provider patterns.** Tokens with vendor-recognisable
   shapes are caught; a generic high-entropy string, a database URL with an
   inline password, or a private key in an unusual wrapper may not be.

On the determinism axis of [ADR 048](/projects/personal-site/adrs/048-sonarqube):
AI reviewers might notice a pasted credential, non-deterministically, at PR
time — also after the secret has already left the laptop.

# Decision

Adopt **gitleaks**, mise-pinned like every other scanner, at two points in the
pipeline:

* **Pre-commit** (the layer that actually prevents leaks): a
  `//:security:secrets` task runs `gitleaks git --pre-commit --staged` after
  lint-staged in the Husky hook, so a staged secret blocks the commit while it
  is still only on the laptop. `--redact` keeps the candidate value out of
  terminal scrollback.
* **CI, Security-tab posture** ([ADR 047](/projects/personal-site/adrs/047-trivy) /
  [ADR 049](/projects/personal-site/adrs/049-zizmor) pattern): a workflow scans
  the *full history* on PRs, `main` pushes, and a weekly Wednesday schedule,
  uploading SARIF with `--exit-code 0`. Findings gate via Code Scanning's
  new-alert behaviour rather than failing the job — a historical finding
  predating the scan cannot be fixed by whichever PR happens to trip over it,
  and the remediation for anything historical is rotation, not a red build.
  A genuine tool failure still fails the job, so the scan cannot silently
  skip itself.

GitHub secret scanning stays on; the two overlap on known provider patterns
(harmless redundancy, per the ADR 048/049 precedent) and differ everywhere
that matters: timing (pre-commit vs post-push) and breadth (generic entropy
and custom rules vs vendor patterns).

# Why It Adds Something New

Every other scanner in the stack examines code *after it is shared* — the
pre-commit hook is the only place in the entire pipeline where detection
precedes disclosure, and on a public repo that distinction is the whole game.
The CI scan adds the complementary posture view: the complete history audited
by a ruleset broader than vendor patterns, with findings in the same Security
tab as Trivy, zizmor, and Scorecard.

# Alternatives Considered

| Tool                                         | Role                      | Pros                                                                                                                 | Cons                                                                                                       | Verdict                                                                 |
| -------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| **gitleaks** *(accepted)*                    | Secret detection          | De-facto standard; single binary in mise; pre-commit + full-history modes; SARIF; TOML allowlist config when needed. | Pattern/entropy-based → occasional false positives to allowlist.                                           | **Accepted.**                                                           |
| **GitHub push protection**                   | Push-time block           | Native, free on public repos, zero config.                                                                           | Known provider patterns only; fires at push, later than pre-commit; not a substitute for history auditing. | **Complementary** — worth enabling in repo settings, not a replacement. |
| **TruffleHog**                               | Detection + verification  | Verifies candidates against live APIs (fewer false positives).                                                       | Verification means sending candidates to providers; heavier tool; OSS/paid split has churned.              | Rejected: verification isn't worth the weight here.                     |
| **detect-secrets** (Yelp)                    | Baseline-driven detection | Mature baseline workflow.                                                                                            | Python toolchain in a Node monorepo; slower-moving project.                                                | Rejected: gitleaks covers it in one binary.                             |
| **gitleaks-action**                          | Same engine, packaged     | Turnkey.                                                                                                             | Org licensing terms; less control than running the mise-pinned binary the hook already uses.               | Rejected: run the same binary both places instead.                      |
| **Status quo** (GitHub secret scanning only) | n/a                       | Zero effort.                                                                                                         | Post-push detection only, vendor patterns only.                                                            | Rejected: detection after publication is rotation, not prevention.      |

# Where It Sits On The Adoption Curve

**Late majority.** Gitleaks has been the default open-source secret scanner
for years — stable CLI, huge deployment base, deep LLM corpus
([LLM-Optimized](/projects?tab=philosophy#llm-optimized)). No Goldilocks
trade-off to accept this time.

# Relation To Building Philosophy

* [Short Feedback Loops](/projects?tab=philosophy#short-feedback-loops). The
  loop cannot get shorter: the finding lands before the commit exists.
* [Less Is More](/projects?tab=philosophy#less-is-more). One binary serving
  both the hook and CI; findings in the existing Security tab; no platform, no
  account.
* [LLM-Optimized](/projects?tab=philosophy#llm-optimized). Agents commit
  autonomously in this repo; a deterministic pre-commit gate on secrets is the
  control that scales with that, rather than hoping each agent notices.
* [Respect Goodhart's Law](/projects?tab=philosophy#respect-goodharts-and-conways-laws).
  Historical findings surface for triage instead of failing unrelated PRs —
  pressure lands on rotating the credential, not on silencing the scanner.

# Gaps & Risks

* **False positives** (test fixtures, example configs) will need
  `.gitleaks.toml` allowlist entries over time; the config is committed and
  reviewable, so exceptions are visible rather than silent.
* **Pattern-based detection is not proof of absence** — a secret in a shape no
  rule anticipates still gets through; pre-commit is a strong net, not a
  guarantee.
* **The hook can be bypassed** (`--no-verify`); the CI history scan is the
  backstop that still catches what skipped the hook.

# Consequences

## Positive

* Secret detection moves from post-push (irreversible on a public repo) to
  pre-commit (still private, still preventable).
* Full-history coverage with generic/entropy rules, surfaced in the Security
  tab alongside Trivy/zizmor/Scorecard.
* Free, one binary, mise/CI parity like every other scanner in the stack.

## Negative

* Pre-commit gains a scan step (sub-second on staged changes).
* Occasional false positives require allowlist maintenance in `.gitleaks.toml`.

---

Markdown index of this site: https://robbiepalmer.me/llms.txt
