A single leaked cloud key is not a bug you fix once; it is the visible tip of a condition called secret sprawl — the steady accumulation of live credentials across every surface your engineering org touches. A developer pastes a connection string into a commit to “test something,” removes it an hour later, and calls it clean. It is not clean. The value is now reachable through the old commit SHA, present in every clone and fork, cached on three CI runners, baked into a container layer the deploy pipeline built, and sitting in terraform.tfstate as plaintext JSON. The commit that “removed” it changed nothing that matters. The credential is exploitable from the second it reached a remote you do not fully control until the second you revoke it, and everything you do between those two moments is either shrinking that window or wasting time.
This article builds the program that shrinks it. Not a scanner you bolt on and forget — a left-to-right pipeline that blocks new leaks at the keyboard (pre-commit), rejects them at the server before they land (push protection), detects the ones that slip through (secret scanning across history and CI and images), and remediates with a fixed order of operations whose first and only urgent step is rotate the credential. We will wire GitHub Secret Protection (the product formerly bundled as GitHub Advanced Security secret scanning), Gitleaks, TruffleHog and GitGuardian, and we will treat git filter-repo and BFG as what they are: optional, last-in-line history hygiene that does nothing to a key an attacker already copied. Along the way, every control gets real config — gitleaks.toml, .pre-commit-config.yaml, gh api calls, GitHub Actions workflows, Trivy scans, Key Vault references, and OIDC federation — because a secrets program you cannot copy-paste into a repo is a slide deck, not a control.
By the end you will run this as an org-wide program with numbers on it: mean-time-to-rotate measured per alert, secret density trending toward zero, push-protection bypasses routed to your security team, and a false-positive rate low enough that people still read the channel. You will know precisely why the correct incident response to a leaked AWS key is aws iam update-access-key --status Inactive first and git filter-repo maybe-never — and you will be able to explain that ordering to an auditor, a panicked developer, and a CTO in the same sentence.
What problem this solves
The failure mode is quiet and cumulative. No single leak feels like an emergency: a token in a test fixture, a password in an old branch, a service-principal secret echoed into a build log during a debugging session at 11pm. Each one, in isolation, is “probably fine.” The sprawl is the sum. Left unmanaged, a mid-size org accumulates hundreds of live secrets scattered across repos, and the moment one of those repos goes public — an accidental visibility flip, a fork by a departing employee, a misconfigured mirror — the entire back-catalogue is exposed at once. The 2022 and 2023 wave of high-profile breaches that started with a token in a public repo were not exotic; they were secret sprawl meeting a visibility mistake.
What breaks without a program: your first sign of a leak is an AWS abuse notice about a crypto-miner running on a key nobody remembers creating, or a GitGuardian email about a token in a repo you did not know was public. By then the credential has been live for days or weeks. The reflexive responses make it worse — someone spends two hours running git filter-repo to “remove” the key (leaving it fully live at the provider the entire time), force-pushes a rewritten history that breaks every open pull request and every collaborator’s clone, and never rotates the actual credential. The history is now clean and the account is still compromised. That is the exact inversion this article exists to prevent.
Who hits this: every engineering org that uses git and CI, which is every engineering org. It bites hardest on teams with many repos and no org-level controls (each repo is a fresh chance to leak), teams that print environment in CI (env, set -x, a tool that dumps config on error), teams building custom containers (secrets in early layers survive later RUN rm), and teams managing infrastructure as code (Terraform state and .tfvars are secret magnets). The fix is never “tell people to be careful.” Careful people leak secrets constantly. The fix is a system that catches honest mistakes cheaply and makes the expensive mistakes impossible.
To frame the whole field before the deep dive, here is every surface a credential leaks into, why the leak persists after the obvious cleanup, and the control layer that actually addresses it:
| Leak surface | The credential lands here as | Why “removing it” doesn’t remove it | Control that addresses it |
|---|---|---|---|
| Commit history | A key added then deleted in a later commit | Still reachable via the old SHA and in every clone/fork | Push protection (block) + secret scanning (detect) |
| CI/CD logs | echo $TOKEN, set -x, a tool printing config on error |
Logs are retained, cached, and often org-readable | Runner masking + never-echo discipline + log scanning |
| Container image layers | ENV API_KEY=…, a copied .env, COPY . . |
Deleting the file in a later layer leaves earlier layers intact | Image layer scanning (Trivy) + BuildKit secret mounts |
| IaC + Terraform state | Provider creds in .tf; every secret in terraform.tfstate |
State is plaintext JSON, committed by accident constantly | fs scanning + remote encrypted backend + .gitignore |
| Build artifacts / caches | .npmrc, settings.xml, signing keys, .pypirc |
Attached to pipeline runs and pushed to package registries | Artifact scanning + secretless auth (OIDC) |
| Config files & dotenv | .env, appsettings.Production.json, .aws/credentials |
Copied into images and commits; often git-ignored too late | Pre-commit scanning + vault references |
| Wikis, issues, PRs, gists | A token pasted into a bug report or gist to reproduce | Not covered by repo-file scanning by default | Org secret scanning on non-code + policy |
| Chat / screen-shares | A key pasted into Slack or shown on a shared screen | You cannot scan it; you cannot prove it wasn’t copied | Rotation on any exposure (the universal fallback) |
The last row is the load-bearing one. The reason rotation is non-negotiable and non-substitutable is that you cannot prove a negative. Once a value has been on a shared remote, a CI runner, a pulled image, a Slack thread, or a screen a stranger saw, you have no mechanism to confirm it was never copied. The only deterministic control is to make the value worthless by revoking it. Every scanning and prevention control in this article exists to (a) stop new secrets reaching these surfaces and (b) collapse the time between exposure and rotation. None of them substitutes for the rotation itself.
Learning objectives
By the end of this article you can:
- Enumerate every surface a secret leaks into, explain why post-hoc cleanup does not remediate it, and articulate why revoke-and-rotate is the only deterministic fix.
- Deploy layered detection: pre-commit hooks (Gitleaks, detect-secrets), CI scanning (full-history and diff-scoped), and server-side push protection — and explain what each layer catches and what it cannot.
- Configure GitHub Secret Protection at organization scale: secret scanning, push protection, custom patterns with before/after context, dry-run validation, and bypass routing.
- Choose between and combine Gitleaks, TruffleHog, and GitGuardian, and know where partner-pattern validity checks (“is this key live right now?”) change your triage order.
- Execute the remediation runbook in the correct order — revoke → rotate → verify → resolve → (optional) purge history — and use
git filter-repo/ BFG correctly when history purge is actually required. - Scan the non-repo surfaces: CI logs, container image layers (Trivy), and IaC + Terraform state, and apply the structural fixes (BuildKit secret mounts, remote state backends).
- Prevent recurrence by migrating discovered secrets into a vault with managed-identity/OIDC references, so the leaked credential class is deleted, not just the instance.
- Roll the program out across an org and prove it works with metrics: mean-time-to-rotate, secret density, false-positive rate per rule, and coverage.
Prerequisites & where this fits
You should be comfortable with git internals at the level of “a commit is an immutable object addressed by its SHA, and deleting a file in a new commit does not delete the blob the old commit points to.” You should know your way around GitHub org administration (or GitLab/Bitbucket equivalents), the gh CLI, and a CI system (this article uses GitHub Actions in examples, but the layering applies to Azure DevOps, GitLab CI, and Jenkins identically). Basic familiarity with a secrets manager (Azure Key Vault, HashiCorp Vault, AWS Secrets Manager) and with OIDC/workload-identity federation helps for the prevention sections.
This sits in the DevSecOps track, at the seam between source control, CI/CD, and identity. It is downstream of the “why secretless” argument made in Workload Identity Federation: Secretless CI/CD and Securing Workload Identities with Conditional Access and Risk — those articles argue for eliminating the stored credential entirely; this one deals with the world where credentials still exist and leak. It pairs tightly with Pipeline Secrets Management (where the rotated secret should live) and with Azure Key Vault: Secrets, Keys & Certificates and Secret Management in Pipelines with Key Vault and Managed Identity for the vault-reference end state. When a leak becomes a genuine incident, it hands off to Security Incident Response Runbooks, Tabletops & Cloud Forensics. And the container-layer scanning here overlaps with the supply-chain controls in Software Supply Chain: SBOM, VEX & Admission Verification and Securing the Container Supply Chain with Private ACR.
A quick map of who owns what during a leak, so you page the right person and route the alert correctly:
| Layer | What lives here | Who usually owns it | What they decide during a leak |
|---|---|---|---|
| Developer workstation | Pre-commit hooks, local git config | Individual dev + platform team | Whether the hook fired and was bypassed |
| Source control (GitHub org) | Push protection, secret scanning, custom patterns | Security + platform admins | Pattern tuning, bypass approvals, alert SLA |
| CI/CD | Pipeline logs, artifacts, runner masking | DevOps / platform team | Whether the pipeline printed the secret |
| The credential’s provider | AWS/Azure/GitHub/Stripe/etc. issuing the key | Service owner (the app team) | The rotation — they own revoke and reissue |
| Secrets manager | Key Vault / Vault / Secrets Manager | Platform + app team | Where the rotated value goes |
| Security operations | Triage, metrics, audit trail | Security team | SLA enforcement, incident escalation |
The routing rule that makes this work: the team that owns the code owns the rotation; the security team owns the SLA and the audit trail. An alert with no code owner is an alert that ages forever, and an aging alert on a live credential is the whole problem in miniature.
Core concepts
Six mental models make every later decision obvious.
Once it leaves your control, it is compromised. This is the axiom the entire program rests on. “Your control” means a place where you can prove, with certainty, that no unauthorized party copied the value. A secret in an unpushed local commit is (arguably) still under your control. The moment it reaches a shared remote, a CI runner you do not exclusively own, a container registry, a log aggregator, or a human’s screen, that certainty evaporates. You cannot audit who cloned a repo in the last month. You cannot audit who read a CI log. Therefore any secret that touched those surfaces is, for remediation purposes, already in the attacker’s hands — and the only correct response is to rotate. This is not paranoia; it is the recognition that you are optimizing an exposure window you cannot otherwise close.
Rotation is the fix; history cleanup is hygiene. These are separate, non-substitutable steps, and confusing them is the single most common and most expensive mistake in this domain. Rotation invalidates the leaked value at the provider — after it, the leaked string is a dead password. History cleanup (git filter-repo, BFG) removes the string from git objects — after it, the string is gone from the repo but, if you skipped rotation, still live everywhere it was copied. A rotated secret still sitting in history is an annoyance a future scan will flag and you can note as “already rotated.” A live secret in a clean history is an active breach with no paper trail. Spend your urgency on rotation.
Defense in depth means three layers, each with a different job. Pre-commit hooks catch honest mistakes on the developer’s machine, before a commit object even exists — cheap, fast, and bypassable by design (--no-verify), so never an enforcement boundary. CI scanning catches what pre-commit missed (bypassed, or the dev never installed the hook), scanning the diff on PRs and full history on the default branch — an enforcement boundary you can require via branch protection, but it runs after the push. Push protection is the only layer that rejects the secret at the server, before it lands on the remote — it is the real prevention boundary, and it is the one a developer cannot bypass with a local flag. You want all three because they fail differently: pre-commit for speed, CI for coverage, push protection for the guarantee.
Detection has two questions, and they are not the same. “Does this string look like a secret?” (pattern and entropy matching) and “Is this credential live right now?” (validity checking). A regex that matches AKIA[0-9A-Z]{16} tells you something looks like an AWS access key ID. A validity check calls the provider (or does a cheap authenticated no-op) and tells you whether that key currently works. The second question reorders your entire triage: a valid, live key is an incident to rotate this hour; a look-alike that is already dead or is a test fixture is noise to suppress. GitHub secret scanning, TruffleHog, and GitGuardian all perform validity checks for many partner patterns — this is the feature that turns a firehose of matches into a prioritized queue.
Entropy and regex are complementary, not interchangeable. A regex anchored on a known prefix (kv_pat_, ghp_, xoxb-) is precise and low-noise but only catches shapes you have defined. Shannon entropy — a measure of randomness — catches high-randomness strings you did not anticipate (a base64 blob, a random hex secret) but is noisy, flagging UUIDs, hashes, and minified JavaScript. The right rule usually combines them: an anchored regex to find the candidate, an entropy floor to reject the low-randomness false positives that share the shape. “Anchored prefix beats loose entropy” is the tuning heuristic that keeps your false-positive rate survivable.
The exposure window is the only metric that matters operationally. Every control either shrinks the window before a leak (prevention: pre-commit, push protection) or shrinks the window after (detection speed + rotation speed). Mean-time-to-rotate (MTTR-secret) — from the alert firing to the credential being confirmed dead — is your real exposure-window proxy, and it is the number you drive down relentlessly. A program with beautiful scanning and a three-day rotation SLA is a program that leaves live keys exposed for three days. Push protection is powerful precisely because it drives the pre-leak window to zero for anything it catches; rotation speed is what you optimize for everything it doesn’t.
The vocabulary in one table
Pin down every moving part before the deep sections. The glossary at the end repeats these for lookup; this table is the mental model side by side:
| Term | One-line definition | Where it operates | Why it matters |
|---|---|---|---|
| Secret sprawl | Steady-state accumulation of live secrets across surfaces | Org-wide | The condition; not a single incident |
| Pre-commit hook | Local git hook that scans staged changes | Developer machine | Cheap first catch; bypassable |
| Push protection | Server-side rejection of a push containing a secret | GitHub remote | Only unbypassable pre-leak boundary |
| Secret scanning | History + push scanning for known/custom patterns | GitHub server | Detects what slipped through |
| Validity check | Provider call to test if a matched secret is live | Scanner ↔ provider | Reorders triage; live keys first |
| Partner pattern | Vendor-registered secret format GitHub recognizes | GitHub | High-precision, often validity-checkable |
| Custom pattern | Your own regex for internal token shapes | GitHub org / gitleaks | Catches internal secrets defaults miss |
| Shannon entropy | Randomness measure of a string | Scanner rule engine | Catches unanticipated high-entropy secrets |
| Rotation | Invalidate old credential, issue new one | Provider | The fix — ends exposure |
| History purge | Remove a string from git objects (filter-repo/BFG) |
Git repo | Hygiene; optional; last |
| MTTR-secret | Alert-to-revoked latency | Metrics | Real exposure-window proxy |
| Vault reference | App setting pointing to a secret, resolved at runtime | App config + identity | End state: no secret in source |
| OIDC federation | Short-lived token exchange, no stored CI secret | CI ↔ cloud | Deletes the most-leaked secret class |
Where secrets leak: the six surfaces in detail
Before the tooling, understand the terrain. Each surface leaks differently, persists differently, and demands a different scan. Getting this wrong — scanning only repo files and declaring victory — leaves the majority of your real exposure untouched.
Commit history is the canonical case and the most misunderstood. A commit is an immutable object; the file content is stored as a blob addressed by its own hash. When you rm secret.env && git commit, you create a new commit whose tree no longer references that blob — but the previous commit still references it, that commit’s SHA is still in the reflog and in every clone, and git show <old-sha>:secret.env still prints the secret. This is why “I deleted it in the next commit” is meaningless. It is reachable until the object is genuinely purged and the credential is rotated. Every fork and clone made while it was present carries its own copy.
CI/CD logs leak through three mechanisms: explicit echo $SECRET (usually debugging), shell tracing (set -x or bash -x, which prints every command with its expanded variables), and tools that dump their configuration on error (a database client printing its DSN, a deploy tool logging its full config). GitHub Actions and Azure DevOps mask registered secrets in logs, but they cannot mask a value you derived at runtime or one they never knew was secret. Logs are retained (often 90+ days), downloadable, and readable by anyone with repo read access — which in a large org can be thousands of people.
Container image layers are the sneakiest. A Dockerfile is a stack of layers; each RUN, COPY, and ENV produces a layer, and all layers persist in the final image even if a later layer deletes a file. COPY . . (copying a .env into the image), then RUN rm .env, produces an image where docker history and any layer extractor still surface the .env from the earlier layer. ENV API_KEY=... bakes the value into image metadata permanently. Squashing the final layer does nothing to the earlier ones. You must scan the built image’s every layer, not just lint the Dockerfile.
IaC and Terraform state. Provider blocks with hardcoded credentials in .tf are the obvious leak. The worse one is terraform.tfstate: Terraform stores the full resource graph including every attribute, and many resources record secrets (database admin passwords, generated keys, connection strings) as plaintext attributes. State is JSON, and it gets committed by accident constantly — a developer runs terraform apply locally without a remote backend configured, and git add . sweeps the freshly-written state file into a commit. .tfvars files, which hold the input variables, are the third leak.
Build artifacts and package-manager config. .npmrc (npm auth token), settings.xml (Maven repository credentials), .pypirc (PyPI token), and code-signing keys frequently get attached to pipeline runs or, worse, published inside a package to a registry. A .npmrc with a live token copied into a published npm tarball is a real and common leak.
Config files, dotenv, and the “git-ignored too late” trap. .env, appsettings.Production.json, .aws/credentials, and kubeconfig are secret-dense. The recurring failure: someone commits .env before adding it to .gitignore. Adding it to .gitignore afterward stops future commits but leaves the already-committed version in history, fully exposed. .gitignore is not a remediation; it is a prevention for files you have not committed yet.
The surfaces, mapped to the exact scan that finds them and the structural fix that prevents recurrence:
| Surface | What finds it | Command / control | Structural fix (prevent recurrence) |
|---|---|---|---|
| Commit history | History-scanning secret scanner | gitleaks detect, GitHub secret scanning |
Push protection blocks new ones |
| Live push | Push protection / pre-push scan | GitHub push protection | (Is the prevention) |
| CI logs | Log scanning + runner masking | Scan artifact logs; ::add-mask:: |
Never echo env; secretless auth |
| Image layers | Image filesystem scan | trivy image --scanners secret |
BuildKit --mount=type=secret |
| Terraform state | Filesystem scan | trivy fs --scanners secret,misconfig |
Remote encrypted backend + .gitignore |
| Package config | Filesystem + artifact scan | gitleaks detect, trivy fs |
OIDC / short-lived registry tokens |
| Dotenv / config | Pre-commit + fs scan | gitleaks protect --staged |
.gitignore from day one + vault refs |
Layer 1 — Pre-commit secret scanning with regex and entropy
The cheapest place to stop a leak is before it becomes a commit object at all. Two tools dominate the local layer: Gitleaks (Go, fast, an excellent rule engine, the de-facto standard) and detect-secrets (Python, baseline-driven, strong entropy heuristics, from Yelp). Run Gitleaks as the org default and reach for detect-secrets where a team wants an auditable baseline file that tracks known findings explicitly.
Wire both through the pre-commit framework so the hook is reproducible, version-pinned, and enforceable in CI (the same config runs locally and in the pipeline):
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ["--baseline", ".secrets.baseline"]
pip install pre-commit
pre-commit install # installs the git hook into .git/hooks/
pre-commit run --all-files # one-time sweep of the entire existing tree
The built-in rules catch cloud provider keys well, but your highest-value leaks are usually internal tokens with predictable shapes that no default rule knows about. Add custom rules with an anchored regex plus an entropy floor — the anchor for precision, the entropy floor to reject low-randomness look-alikes:
# .gitleaks.toml
title = "KloudVin custom rules"
[extend]
useDefault = true # keep ALL the built-in cloud/provider rules
[[rules]]
id = "kloudvin-internal-pat"
description = "KloudVin internal platform PAT"
# Anchored prefix beats a loose entropy match: far fewer false positives.
regex = '''kv_pat_[0-9a-zA-Z]{40}'''
keywords = ["kv_pat_"] # keyword pre-filter makes scanning much faster
entropy = 3.5
[[rules]]
id = "generic-high-entropy-secret-assignment"
description = "High-entropy value assigned to a secret-ish variable"
regex = '''(?i)(api[_-]?key|secret|token|passwd|password)\s*[:=]\s*['"]?([0-9a-zA-Z\/+=_-]{32,})['"]?'''
entropy = 4.3 # high floor: only genuinely random assignments trip it
[allowlist]
description = "Known-safe test fixtures, docs, and vendored code"
paths = [
'''(.*)?_test\.go$''',
'''testdata/''',
'''docs/examples/''',
'''(.*)?\.lock$''',
]
regexes = [
'''EXAMPLE[0-9A-Z]{12}''', # documented example keys
'''AKIAIOSFODNN7EXAMPLE''', # the canonical AWS docs example key
]
For detect-secrets, generate and commit a baseline so existing (audited) findings do not block every commit, while genuinely new ones still fail. The baseline is the auditable record of “we looked at these and they are false positives / already handled”:
detect-secrets scan --all-files > .secrets.baseline
detect-secrets audit .secrets.baseline # interactively mark each true/false positive
Gitleaks distinguishes two commands that matter here: gitleaks protect --staged scans only what is staged (the pre-commit use case — fast, pre-object) while gitleaks detect scans the full commit history (the CI and audit use case). The pre-commit hook uses protect; your CI job uses detect.
The two entropy failure modes and how to tune each:
| Symptom | Cause | Tuning move |
|---|---|---|
| Floods on UUIDs, hashes, minified JS | Entropy floor too low, no anchor | Raise entropy; add a keyword/prefix anchor |
| Misses a real random secret | No rule matches its shape; floor too high | Add an anchored rule; lower floor for that rule only |
| Flags documented example keys | Docs use real-looking placeholders | Allowlist the exact example strings / docs/ paths |
| Flags base64 config blobs (sealed secrets) | High-entropy ciphertext that is safe to commit | Path-scope allowlist to the known directory + prefix |
| Slow scans on a large repo | No keyword pre-filter | Add keywords to rules so most files skip regex |
The tool trade-off, so you pick deliberately rather than by default:
| Dimension | Gitleaks | detect-secrets | TruffleHog |
|---|---|---|---|
| Language / speed | Go, very fast | Python, moderate | Go, fast |
| Primary strength | Rule engine + history scan | Auditable baseline + entropy plugins | Verified (live) secret detection |
| Baseline model | Allowlist in TOML | Explicit .secrets.baseline file |
N/A (verifies instead) |
| Validity checking | No | No | Yes (700+ detectors) |
| Pre-commit fit | Excellent (protect --staged) |
Excellent (baseline) | Usable, heavier |
| Best role | Org default local + CI | Teams wanting audit trail | CI verification of live keys |
The discipline to internalize: a pre-commit hook is advisory by design. A developer can git commit --no-verify and sail straight past it, and that is fine — the hook exists to catch honest mistakes fast and cheaply, not to be a control you audit. Never treat a local hook as an enforcement boundary. The enforcement lives server-side, in push protection and CI, covered next. If your threat model requires that developers cannot bypass local scanning, you are looking for push protection, not a better hook.
Layer 2 — GitHub Secret Protection: push protection and secret scanning at org scale
GitHub Secret Protection (the product formerly licensed as GitHub Advanced Security secret scanning; the “Secret Protection” SKU makes it purchasable without the full code-scanning bundle) provides the two server-side controls a developer cannot bypass with --no-verify:
- Secret scanning — scans the full repository history and every ongoing push for known partner patterns (vendor-registered formats like AWS, Azure, Stripe, GitHub, Slack tokens) and your custom patterns, raising an alert per finding. For many partner patterns it performs a validity check and labels the alert
active,inactive, orunknown. - Push protection — rejects a push at the server the instant it detects a supported secret in the pushed content, so the credential never lands on the remote. This is the prevention boundary that matters: unlike a pre-commit hook, there is no local flag to skip it (a bypass is an audited, reason-required action).
Enable both as org-wide defaults for newly created repos rather than chasing them one repo at a time:
# Enable org-wide defaults so EVERY newly created repo gets protection automatically
gh api --method PATCH /orgs/KloudVin \
-F secret_scanning_enabled_for_new_repositories=true \
-F secret_scanning_push_protection_enabled_for_new_repositories=true \
-F secret_scanning_validity_checks_enabled=true
For existing repos, enable per repository via the security-and-analysis settings (script this across the org’s repo list — do not click through hundreds of settings pages):
# Enable on one existing repo
gh api --method PATCH /repos/KloudVin/platform-core \
-f 'security_and_analysis[secret_scanning][status]=enabled' \
-f 'security_and_analysis[secret_scanning_push_protection][status]=enabled'
# Roll across every repo in the org
gh repo list KloudVin --limit 2000 --json name --jq '.[].name' | while read -r r; do
gh api --method PATCH "/repos/KloudVin/$r" \
-f 'security_and_analysis[secret_scanning][status]=enabled' \
-f 'security_and_analysis[secret_scanning_push_protection][status]=enabled' \
&& echo "enabled: $r"
done
Add custom patterns for your internal token shapes at the org level so every repo inherits them. Define the secret format, optionally with before/after context to cut false positives, add a test string, and — critically — run the dry run before publishing so you can see the blast radius against real history:
# Org → Settings → Advanced Security → Custom patterns → New pattern
Secret format: kv_pat_[0-9a-zA-Z]{40}
Before secret: (?:authorization|token|pat)\s*[:=]\s*["']?
After secret: (?:["']|\s|$)
Must not match: AgB[0-9A-Za-z+/=]{100,} # exclude sealed-secrets ciphertext
Test string: token=kv_pat_AbC123def456GHI789jkl012MNO345pqr678 (40 chars)
The custom-pattern fields and how each earns its keep:
| Field | Purpose | Effect on precision | When you need it |
|---|---|---|---|
| Secret format | The core regex matching the token | Defines the candidate set | Always (required) |
| Before secret | Context required immediately before | Cuts matches lacking the assignment context | High-entropy tokens with common shapes |
| After secret | Context required immediately after | Rejects mid-string coincidental matches | Tokens embedded in larger blobs |
| Additional match / must match | Extra condition anywhere on the line | Narrows to genuine assignments | Reducing noise on generic patterns |
| Must not match | Exclusion condition | Suppresses a known-safe class | Sealed-secrets, example keys, vendored data |
| Dry run | Preview matches before publishing | Zero production impact until validated | Every new or edited pattern |
When push protection blocks a developer, GitHub gives them an inline choice: remove the secret, or bypass with a reason (it’s a test value, it’s a false positive, or “I’ll fix it later”). Route every bypass to your security team — the bypass reason is the single richest signal you have about where a pattern is too aggressive or where a real secret very nearly shipped:
# Pull push-protection bypasses across the org for review (dedicated audit endpoint)
gh api "/orgs/KloudVin/secret-scanning/push-protection-bypasses" \
--jq '.[] | {repo: .repository.full_name, actor: .actor.login, reason, created_at}'
The three bypass reasons and how to treat each:
| Bypass reason | What it usually means | Your response |
|---|---|---|
| It’s used in tests | A fixture value, possibly real-looking | Verify it is not a real credential; allowlist the fixture pattern/path |
| It’s a false positive | Pattern matched non-secret content | Refine the custom pattern’s before/after or must-not-match |
| I’ll fix it later | A real secret the dev pushed anyway | Treat as a leak: rotate, and coach the developer |
Run every new custom pattern in dry run first. A sloppy pattern published live can push-protect an entire monorepo into a standstill and flood the security tab with thousands of historical alerts overnight. Validate the match count and eyeball the matches, then publish. This one discipline prevents the most common self-inflicted outage in a secrets program.
The three GitHub controls, delineated so you know what each does and does not cover:
| Control | Scans | Blocks? | Bypassable? | Covers |
|---|---|---|---|---|
| Push protection | The push, in real time | Yes (rejects push) | Only via audited reason | New secrets before they land |
| Secret scanning (push) | Each push after it lands | No (alerts) | N/A | Anything push protection missed |
| Secret scanning (history) | Full existing history | No (alerts) | N/A | The back-catalogue of leaks |
| Custom patterns | Adds your shapes to both | (Inherits) | N/A | Internal tokens defaults miss |
| Validity checks | Tests matched secrets live | N/A | N/A | Prioritization (active first) |
Layer 3 — CI scanning: closing the diff and the full history
Push protection catches supported patterns; CI scanning is your backstop for everything else and your enforcement gate on pull requests. Add a scan step that fails the build, scoped to the diff on PRs so it stays fast, and full-history on the default branch so it catches anything that slipped in historically:
# .github/workflows/secret-scan.yml
name: secret-scan
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so the scan sees prior commits, not just HEAD
- name: Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_CONFIG: .gitleaks.toml
trufflehog-verified:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: TruffleHog (only fail on VERIFIED live secrets)
uses: trufflesecurity/trufflehog@main
with:
extra_args: --only-verified # verify against the provider; ignore dead look-alikes
The fetch-depth: 0 line is load-bearing: without it, actions/checkout fetches only the tip commit and the scan sees nothing historical. The most valuable CI pattern is running TruffleHog with --only-verified as a hard gate — it verifies each candidate against the provider and fails only on secrets that are actually live, which drives your CI false-positive rate to near zero (a dead or fake key does not break the build; a live one does).
The two most common CI own-goals are a pipeline that prints a secret. Two defenses: register secrets so the runner masks them, and never echo environment:
- name: Mask a secret you derive at runtime
run: |
DERIVED=$(compute-token)
echo "::add-mask::$DERIVED" # the runner now redacts it from all subsequent logs
# NEVER do: run: env
# NEVER do: run: set -x (prints every command with expanded variables)
Scope your CI scan sensibly by trigger so it is fast where it needs to be and thorough where it counts:
| Trigger | Scan scope | Fail build? | Rationale |
|---|---|---|---|
| Pull request | Diff (new commits in the PR) | Yes | Fast feedback; block the merge |
Push to main |
Full history (fetch-depth: 0) |
Yes | Catch anything historical; audit |
| Nightly scheduled | Full history + verify (--only-verified) |
Alert (not fail) | Catch newly-valid keys; low noise |
| Release / tag | Full + image + IaC scans | Yes | Gate the artifact, not just source |
Scanning the non-repo surfaces: CI logs, container layers, and IaC
Repository scanning misses three high-leak surfaces. Cover them explicitly or your “secrets program” is scanning a third of the actual attack surface.
Container image layers
A secret added in an early layer survives even if you delete the file later — docker history and any layer extractor will surface it. Scan the built image, not just the Dockerfile. Trivy walks every layer’s filesystem for secrets:
# Scan every layer of a built image; non-zero exit fails the pipeline
trivy image --scanners secret --exit-code 1 \
ghcr.io/kloudvin/api:${GIT_SHA}
The structural fix is to never let the secret enter a layer in the first place. Use BuildKit secret mounts so the value is available at build time but is not persisted in any layer (the mount exists only during that RUN and is never written to the image):
# syntax=docker/dockerfile:1.7
FROM node:20-slim
# The token is available ONLY during this RUN, mounted at /run/secrets, never layered.
RUN --mount=type=secret,id=npmtoken \
NPM_TOKEN="$(cat /run/secrets/npmtoken)" npm ci
DOCKER_BUILDKIT=1 docker build \
--secret id=npmtoken,env=NPM_TOKEN \
-t kloudvin/api:dev .
The anti-patterns that bake secrets into layers, and the fix for each:
| Anti-pattern | Why it leaks | Correct approach |
|---|---|---|
ENV API_KEY=xyz |
Value baked into image metadata forever | Inject at runtime (env/orchestrator/vault) |
COPY . . including .env |
.env layered in even if later rm’d |
.dockerignore the file; BuildKit mount |
RUN echo $TOKEN > /app/.npmrc |
Written to a persistent layer | --mount=type=secret for the RUN only |
ARG SECRET used in build |
ARG values visible in docker history |
BuildKit secret mount, not build ARG |
Copying ~/.aws/credentials |
Cloud creds embedded in image | OIDC/instance identity at runtime |
IaC and Terraform state
Scan .tf for hardcoded provider credentials, and treat terraform.tfstate as radioactive — it stores resource attributes, including secrets, in plaintext JSON. Filesystem scanning catches both hardcoded creds and committed state:
# Scan the working tree for secrets AND misconfigurations
trivy fs --scanners secret,misconfig .
Then make committing state impossible and move it to an encrypted, locked, identity-authenticated backend:
# backend.tf — remote state, never local, never committed
terraform {
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "stkloudvintfstate"
container_name = "state"
key = "platform-core.tfstate"
use_azuread_auth = true # identity-based; no storage account key in config
}
}
# .gitignore — belt and braces; add this on day ONE of any Terraform repo
*.tfstate
*.tfstate.*
*.tfvars
!example.tfvars # allow a sanitized example
.terraform/
crash.log
override.tf
The IaC leak checklist:
| Leak | Where it hides | Scan / control | Fix |
|---|---|---|---|
| Hardcoded provider creds | provider "aws" { access_key = ... } |
trivy fs --scanners secret |
Env/OIDC auth; never inline |
| Secrets in state | terraform.tfstate attributes (plaintext) |
fs scan; .gitignore |
Remote encrypted backend + RBAC |
.tfvars with secrets |
prod.tfvars committed |
fs scan; .gitignore |
Vault data source; keep out of git |
| Secrets in plan output | terraform plan in CI logs |
Log scanning | -out a binary plan; don’t log it |
| Sensitive outputs | output "db_pw" { value = ... } |
Code review | sensitive = true; reference from vault |
Triaging alerts: validity, ownership, and false-positive control
A scanner that fires a thousand alerts nobody triages is worse than no scanner — it trains people to ignore the channel, so the one alert that matters drowns. Three disciplines keep the signal alive.
Validity checking first. GitHub’s secret scanning performs validity checks for many partner patterns, labeling an alert active, inactive, or unknown. Triage active first — those are live, working credentials, i.e. incidents. Pull the queue programmatically and sort by validity:
# List open alerts with their validity; active ones are your incident queue
gh api "/repos/KloudVin/platform-core/secret-scanning/alerts?state=open" \
--jq 'sort_by(.validity) | .[] | {number, secret_type, validity, created_at, html_url}'
The validity states and what each means for your response:
| Validity | Meaning | Response urgency | Note |
|---|---|---|---|
| active | Provider confirms the key works right now | Incident — rotate this hour | The real fire |
| inactive | Provider confirms it is already dead | Low — note and resolve | Still purge history if required |
| unknown | No validity check available/possible | Medium — assume live, rotate | Custom patterns are usually unknown |
Ownership routing. Map the file path or repo to a team via CODEOWNERS and your service catalog, then auto-assign. An alert with no owner ages forever. The rule: the team that owns the code owns the rotation; security owns the SLA and the audit trail. Automate the routing so no alert lands ownerless:
# Sketch: for each new alert, resolve the repo's owning team and notify them
gh api "/repos/KloudVin/platform-core/secret-scanning/alerts?state=open" \
--jq '.[] | select(.validity=="active") | {number, secret_type, url: .html_url}' \
| while read -r alert; do
# look up owning team from CODEOWNERS / service catalog, post to their channel
notify-team --from-codeowners platform-core --payload "$alert"
done
False-positive control. Every false positive is a tax on attention. Resolve it in a way that suppresses the class, not the instance: refine the custom pattern’s before/after or must-not-match, add an allowlist path in .gitleaks.toml, or mark it in the detect-secrets baseline. Track your false-positive rate per rule as a first-class metric; if it climbs above roughly 20% for a given rule, the rule — not the developers — needs fixing.
Where to suppress a false positive depends on which layer produced it:
| Producing layer | Suppression mechanism | Scope of suppression |
|---|---|---|
| Gitleaks (local/CI) | [allowlist] path/regex in .gitleaks.toml |
The matching class, repo-wide |
| detect-secrets | Mark in .secrets.baseline via audit |
The specific finding, tracked |
| GitHub custom pattern | Must not match / refine before/after |
Org-wide for that pattern |
| GitHub alert (partner) | “Close as” false positive / used in tests | The single alert |
| TruffleHog CI | --only-verified (dead keys don’t fire) |
All non-live matches |
The alert-resolution reasons and when each is correct — using the wrong one corrupts your metrics:
| Resolution | Use when | Do NOT use when |
|---|---|---|
| revoked | You rotated/disabled the credential | The secret is still live anywhere |
| false_positive | It is genuinely not a secret | It is a real secret you haven’t rotated |
| used_in_tests | It is a real-shaped but non-production fixture | It is a production credential |
| wont_fix | Documented, accepted, non-sensitive | Almost never — audit-hostile |
The remediation runbook: revoke, rotate, verify — history purge last
When a real secret is confirmed exposed, follow a fixed order. The instinct to “scrub the history first” is the wrong first move — it is slow, it breaks every open PR and every clone, and it does nothing to a credential an attacker already copied.
Order of operations: Revoke → Rotate → Verify → Resolve → (optionally) Purge history. The credential’s exposure ends at revoke, not at history rewrite. Do the arithmetic on attacker dwell time: the key is exploitable from the second it hit the remote until the second you revoke it. Optimize that window and nothing else.
Step 1 — Revoke / disable at the provider immediately. This is the only step that actually stops the bleed. Disable first (instant), delete after rotation is confirmed healthy:
# AWS access key — disable is instant; delete only after the new key is live
aws iam update-access-key --access-key-id AKIA... --status Inactive \
--user-name svc-platform
Step 2 — Rotate. Issue a fresh credential, deliver it to the workload through your vault (next section), and confirm the application is healthy on the new value before you delete the old one. Never delete the old key until the new one is proven working, or you turn a leak into an outage.
Step 3 — Verify the old credential is dead. Do not assume Inactive means gone — prove it fails:
# With the OLD key set, an API call must now be rejected
AWS_ACCESS_KEY_ID=AKIA... AWS_SECRET_ACCESS_KEY=... \
aws sts get-caller-identity # expect: InvalidClientTokenId / AuthFailure
Step 4 — Resolve the alert with revoked, linking the rotation change. This is your audit trail — the record that connects “leak detected” to “credential killed” to “code that did it”:
gh api --method PATCH \
/repos/KloudVin/platform-core/secret-scanning/alerts/42 \
-f state=resolved -f resolution=revoked \
-f resolution_comment="Rotated in change CR-8842; old key verified dead 2026-06-08T14:22Z"
Step 5 — History purge is optional and last. If compliance requires the value gone from history (or the repo is public and you want the string removed on principle), use git filter-repo — but only after rotation, and with full awareness that it rewrites every subsequent SHA and forces every collaborator to re-clone. The modern tool is git filter-repo (BFG is the older, JVM-based alternative, still fine for the simple “replace these strings” case):
# git filter-repo — replace the leaked value everywhere in history
echo 'AKIAIOSFODNN7EXAMPLE==>***REMOVED***' > /tmp/replacements.txt
git filter-repo --replace-text /tmp/replacements.txt
git push --force --all # rewrites remote history; every clone must re-clone
# BFG alternative for the same job (simpler UX, needs a fresh mirror clone)
git clone --mirror https://github.com/KloudVin/platform-core.git
bfg --replace-text /tmp/replacements.txt platform-core.git
cd platform-core.git && git reflog expire --expire=now --all && git gc --prune=now
git push --force
The remediation steps as a checklist with the exact command and the failure to avoid at each:
| # | Step | Command | Confirm | Failure to avoid |
|---|---|---|---|---|
| 1 | Revoke | aws iam update-access-key --status Inactive |
Provider shows key disabled | Purging history first (key stays live) |
| 2 | Rotate | Issue new key → deliver via vault | App healthy on new value | Deleting old key before new one works |
| 3 | Verify | aws sts get-caller-identity (old key) |
InvalidClientTokenId returned |
Assuming Inactive = dead without proof |
| 4 | Resolve | gh api ... resolution=revoked |
Alert closed, comment links change | Resolving as false_positive (breaks metrics) |
| 5 | Purge (opt.) | git filter-repo --replace-text |
String gone from history | Doing this instead of rotating |
Provider-specific revocation — the “revoke” command is different everywhere, so keep this table in the runbook:
| Credential type | Revoke command / action | Verify it’s dead |
|---|---|---|
| AWS access key | aws iam update-access-key --status Inactive, then delete |
aws sts get-caller-identity → InvalidClientTokenId |
| Azure SP client secret | az ad app credential delete --id <appId> --key-id <kid> |
Token request → AADSTS7000215 |
| GitHub PAT | Revoke in Settings → Developer settings; or gh auth token regen |
gh api /user with old token → 401 |
| GitHub App / OAuth | Rotate client secret; revoke installation tokens | Old token → 401 |
| Stripe API key | Roll in dashboard (with optional overlap window) | Old key → 401 from Stripe API |
| Slack token | Revoke in app config / auth.revoke |
auth.test with old token → invalid_auth |
| Database password | ALTER USER ... PASSWORD; rotate connection strings |
Connect with old password → auth error |
| Private signing key | Revoke cert / rotate key; re-sign; update trust | Verify signature with old key fails |
When history purge is genuinely warranted versus when it is theater:
| Situation | Purge history? | Why |
|---|---|---|
| Key rotated, private repo, internal | Optional | A dead string is harmless; low priority |
| Repo is (or was) public | Yes, after rotation | Reduce copy-paste of a real-looking string |
| Compliance/contract requires removal | Yes, after rotation | Meet the obligation; document it |
| Secret still live / not yet rotated | No — rotate first | Purge does nothing to a live key |
| Huge repo, many collaborators, low risk | Weigh cost | Rewrite breaks everyone’s clones/PRs |
The one-line takeaway to write on the runbook’s first page: a rotated credential in your history is an annoyance; a live credential in your history is an incident. Spend your urgency accordingly.
Preventing recurrence: vault references and OIDC
Each remediated secret is an opportunity to delete the category of leak, not just the instance. The end state is no secret value in source, CI, or app settings — only a reference resolved at runtime by a managed identity, or no stored credential at all.
Store the rotated value in a vault (Azure Key Vault shown; the pattern is identical for HashiCorp Vault and AWS Secrets Manager):
az keyvault secret set \
--vault-name kv-kloudvin-prod \
--name svc-platform-db-password \
--value "$NEW_PASSWORD"
Reference it from an App Service / Function App by managed identity, so the deployed configuration holds a pointer, never the secret. The platform resolves the reference at startup using the app’s own identity — the value never appears in config, in a template, or in a pipeline variable:
az webapp config appsettings set \
--name app-kloudvin-api --resource-group rg-platform-prod \
--settings "DbPassword=@Microsoft.KeyVault(VaultName=kv-kloudvin-prod;SecretName=svc-platform-db-password)"
In IaC, the same reference is wired to the app’s identity, and the secret value is nowhere in the template:
resource site 'Microsoft.Web/sites@2023-12-01' = {
name: 'app-kloudvin-api'
location: location
identity: { type: 'SystemAssigned' }
properties: {
siteConfig: {
appSettings: [
{
name: 'DbPassword'
// A reference, not a value — resolved at runtime by the managed identity.
value: '@Microsoft.KeyVault(VaultName=${kvName};SecretName=svc-platform-db-password)'
}
]
}
}
}
For CI/CD, close the loop completely with workload identity federation (OIDC) so the pipeline holds no stored secret to leak in the first place — the runner exchanges a short-lived OIDC token for cloud access at job time. This removes the most-rotated, most-leaked credential class (pipeline service-principal passwords and long-lived cloud keys) from the board entirely:
# GitHub Actions authenticating to Azure with NO stored secret — OIDC federation
permissions:
id-token: write # allow the job to request an OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }} # not a secret — just an ID
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
# No client-secret: the federated credential trusts this repo's OIDC token
The prevention ladder — each rung removes more of the leakable surface:
| Approach | What’s stored | Leak surface remaining | Effort |
|---|---|---|---|
| Secret in source/config | The value | Everything (this is the problem) | None (the bad baseline) |
| Secret in CI variable | The value, in the CI system | CI logs, CI config exports | Low |
| Vault + retrieved into env | The value, briefly at runtime | Runtime memory, accidental logs | Medium |
| Vault reference (managed identity) | Only a pointer | Almost none (no value in config) | Medium |
| OIDC / workload federation | Nothing (short-lived token) | None (no stored credential) | Medium–High |
The migration mindset: every alert you close with revoked should end with the question “can this whole class of secret stop existing?” A leaked database password becomes a Key Vault reference. A leaked pipeline service-principal secret becomes OIDC. A leaked storage key becomes use_azuread_auth. You are not just cleaning up a leak; you are retiring a leak category.
Choosing your scanning stack: GitHub-native, Gitleaks, TruffleHog, GitGuardian
You will likely run more than one of these; they overlap deliberately (defense in depth) but each has a center of gravity. Pick the primary for each layer and let the others backstop.
| Tool | Runs where | Validity checks | Custom patterns | Push protection | Cost model | Center of gravity |
|---|---|---|---|---|---|---|
| GitHub Secret Protection | GitHub server | Yes (partner patterns) | Yes (org-level) | Yes | Per active committer (SKU) | The server-side enforcement boundary |
| Gitleaks | Local + CI + anywhere | No | Yes (TOML) | No (pre-push only) | Free / OSS | Fast local + CI + history scanning |
| TruffleHog | Local + CI + anywhere | Yes (700+ detectors) | Yes | No | Free OSS / paid Enterprise | Verified live-secret detection in CI |
| GitGuardian | SaaS + CI + pre-commit | Yes | Yes | Via ggshield pre-receive | Commercial (free tier) | Managed platform, cross-VCS, dashboards |
| detect-secrets | Local + CI | No | Yes (plugins) | No | Free / OSS | Auditable baseline workflow |
The decision rule, by what you are optimizing for:
| If you need… | Primary choice | Backstop |
|---|---|---|
| The unbypassable pre-leak boundary on GitHub | GitHub push protection | Pre-commit hook (speed) |
| Free, fast, self-hosted history + CI scanning | Gitleaks | TruffleHog --only-verified |
| “Is this key live?” as a hard CI gate | TruffleHog --only-verified |
GitHub validity checks |
| Cross-VCS (GitLab + Bitbucket + GitHub) managed | GitGuardian | Gitleaks per-repo |
| An auditable baseline of accepted findings | detect-secrets | — |
| Not on GitHub (GitLab/Bitbucket) at all | Gitleaks + GitGuardian | Native scanner (e.g. GitLab) |
A realistic org stack that most teams land on: GitHub Secret Protection (push protection + secret scanning + custom patterns) as the server-side enforcement boundary, Gitleaks as the pre-commit hook and the free full-history/CI scanner, and TruffleHog --only-verified as a nightly and release-gate check that fails only on live secrets. That combination gives you prevention (push protection), breadth (Gitleaks everywhere), and a live-key hard gate (TruffleHog) without paying for two commercial platforms.
Architecture at a glance
Picture the program as a conveyor belt carrying a change from a developer’s keyboard to production, with a scanner stationed at every point where a secret could fall off, and a separate remediation loop that fires when one does.
At the far left is the developer’s machine. A change is staged; the pre-commit hook (Gitleaks via the pre-commit framework) scans the staged diff with anchored-regex-plus-entropy rules from .gitleaks.toml. This station is fast and cheap but skippable (--no-verify) — its job is honest mistakes, not enforcement. If the hook fires, the secret never becomes a commit object and the belt has not even started.
The change moves right to the push, where it meets the first unbypassable station: GitHub push protection. This is server-side; there is no local flag to skip it. If the push contains a supported partner pattern or one of your org custom patterns, the server rejects the push and the secret never lands on the remote — or the developer takes an audited bypass whose reason is routed straight to security. Immediately behind push protection sits secret scanning, which scans both the newly-landed push and the entire existing history, raising alerts and running validity checks that label each finding active, inactive, or unknown.
The belt continues into CI, where a Gitleaks job (full history, fetch-depth: 0) and a TruffleHog --only-verified gate run — the latter failing the build only on secrets confirmed live against their provider, keeping the false-positive rate near zero. Two more stations sit off to the side scanning the surfaces that source scanning misses: Trivy walks every layer of the built container image for baked-in secrets, and Trivy fs scans the working tree for hardcoded IaC credentials and committed Terraform state. The whole belt feeds a triage queue, where alerts are sorted by validity (active first), routed to the owning team by CODEOWNERS, and either suppressed as a tuned false positive or escalated.
The remediation loop hangs below the belt and fires on any confirmed live secret. It runs strictly left to right: revoke at the provider (the only step that stops the bleed) → rotate to a fresh value delivered via a vault reference or OIDC → verify the old value is dead → resolve the alert as revoked with a link to the change → and only then, if compliance demands it, purge history with git filter-repo. The arrow that matters most is the one you do not follow first: the belt never jumps straight to “purge history,” because a scrubbed history around a live key is a clean-looking breach. Everything converges on one instrument panel — the metrics dashboard — tracking mean-time-to-rotate (the exposure-window proxy), secret density, and false-positive rate per rule. Localize the leak to a station, run that station’s control, and if it is live, drop into the remediation loop and rotate first.
Real-world scenario
Northwind Freight runs a logistics platform: roughly 1,400 repositories across a single GitHub org, about 260 engineers, a mix of Node and .NET services deployed to Azure, and a SOC 2 Type II attestation they cannot afford to jeopardize. Their secrets posture before the program was typical-bad: no org-level scanning, pre-commit hooks that maybe a third of engineers had installed, and a “security channel” that already had 4,000 unread Gitleaks alerts from a one-time sweep nobody triaged. Monthly GitHub spend was about ₹6,20,000; they had budgeted to add GitHub Secret Protection.
The trigger was an AWS abuse notice: a AKIA... key with s3:* and ec2:* had been mining cryptocurrency for eleven days. The key was in a repo that had been flipped public for a demo four months earlier and never flipped back. The on-call engineer’s first instinct — the wrong one — was to git filter-repo the key out of history and force-push. They spent ninety minutes on it, broke seven open pull requests, and the key stayed live the entire time because nobody had revoked it at AWS. The security lead caught it, ran aws iam update-access-key --status Inactive in about fifteen seconds, and the mining stopped. That fifteen-second command was the actual fix; the ninety-minute history rewrite had accomplished nothing but breaking everyone’s clones. The eleven-day dwell time — not the leak itself — was the finding that scared the CISO.
They rolled out the program in four waves over six weeks. Wave 1: enable secret scanning and push protection as org defaults for new repos, and script them onto all 1,400 existing repos with the gh repo list | while read loop. This immediately raised a wall: push protection began rejecting pushes to several long-lived release branches that legitimately carried Bitnami sealed-secrets manifests — high-entropy base64 ciphertext (AgB... prefix) that tripped a generic entropy rule. Releases stalled. They could not disable push protection (a SOC 2 control) and could not rewrite the sealed-secrets workflow on a deadline. The fix was surgical: a Must not match condition on the AgB[0-9A-Za-z+/=]{100,} ciphertext prefix in the org custom pattern, plus a path-scoped Gitleaks allowlist for the sealed-secrets directory, validated via dry run against the release branches before publishing. Zero false positives on sealed secrets, push protection stayed fully on, and the dry run surfaced one genuine plaintext token in an unrelated repo that got rotated the same afternoon.
Wave 2: the 4,000-alert backlog. Instead of triaging 4,000 findings, they enabled validity checks and filtered to active — which collapsed the queue to 137 live secrets. Those 137 got routed by CODEOWNERS to owning teams with a 48-hour rotation SLA, security holding the audit trail. Wave 3: pre-commit hooks pushed to every repo template plus a secret-scan CI job (Gitleaks full-history + TruffleHog --only-verified) required by branch protection. Wave 4: the prevention endgame — they migrated the most-leaked class, pipeline AWS keys, to OIDC federation, deleting 300+ stored long-lived cloud credentials across the org, and moved discovered database passwords to Key Vault references.
Six months later the numbers told the story. Median time-to-rotate had fallen from eleven days (the incident) to under two hours; the p90 sat at 6 hours, driven by a few repos with unclear ownership they were still fixing. Secret density dropped from 137 active leaks to a rolling average of 3, all of them rotated within the SLA. Push-protection blocks were running at ~40/week — 40 secrets per week that never reached a remote — with a bypass rate under 4%, each bypass reviewed. The false-positive rate per rule stayed under 8% after the sealed-secrets tuning. The lesson the team wrote into the top of their runbook, in bold: “Revoke at the provider FIRST. History rewrites are cleanup, not remediation. A live key in clean history is still a breach.”
The incident-to-fix timeline, because the order is the lesson:
| Time | Event | Action taken | Effect | What it should have been |
|---|---|---|---|---|
| Day 0, 09:00 | AWS abuse notice (11-day-old key) | On-call opens the repo | — | Revoke the key immediately |
| Day 0, 09:10 | Panic | git filter-repo + force-push |
Broke 7 PRs; key still live | Do not touch history yet |
| Day 0, 10:40 | Security lead intervenes | aws iam update-access-key --status Inactive |
Mining stops (15 s) | This should have been step 1 |
| Day 0, 11:00 | Rotation | New key via Key Vault; verify old dead | App healthy, old key InvalidClientTokenId |
Correct |
| Week 1 | Rollout wave 1 | Org push protection + scanning on all repos | Releases stall on sealed secrets | Dry-run patterns first |
| Week 1 | Tuning | Must not match AgB... + path allowlist |
Zero FP; found 1 real token | The surgical fix |
| Week 2 | Backlog | Filter 4,000 alerts to active (137) |
Triageable queue | Validity-first triage |
| Month 6 | Steady state | OIDC + vault refs; metrics live | MTTR 11 days → <2 h; density 137 → 3 | The program working |
Advantages and disadvantages
A layered scanning-and-remediation program is powerful, but it has real costs and failure modes. Weigh it honestly:
| Advantages | Disadvantages |
|---|---|
| Push protection stops secrets before they land — the only pre-leak boundary a dev can’t skip locally | Requires a paid SKU (Secret Protection); OSS tools alone can’t block server-side |
| Validity checks turn a firehose of matches into a prioritized, live-key-first queue | Validity checks exist only for partner patterns; your custom-pattern alerts are unknown |
| Defense-in-depth (pre-commit + CI + push) fails gracefully — one layer’s miss is another’s catch | Three layers to configure, maintain, and keep in sync (rules drift between local and CI) |
| A rotate-first runbook shrinks the exposure window to the revoke latency, not the cleanup latency | Cultural: engineers instinctively reach for git filter-repo first — needs constant reinforcement |
| Vault references + OIDC retire whole categories of secret, not just instances | Migration to secretless is real engineering work across many services |
| Metrics (MTTR, density, FP rate) make the program’s value provable to auditors and leadership | Bad metrics discipline (wrong resolution codes) silently corrupts the numbers |
| Custom patterns catch internal tokens the defaults never would | A sloppy custom pattern published live can stall a whole monorepo and flood the queue |
History purge tools (filter-repo/BFG) exist for genuine compliance needs |
Purge rewrites SHAs, breaks every clone/PR — expensive, and useless without rotation |
The program is right for any org past a handful of repos, and essential for anyone with a compliance attestation or public-facing repos. It bites hardest where teams treat scanning as the whole answer (scanning without a fast rotation SLA just documents your exposure), where custom patterns are published without dry runs (self-inflicted outages), and where the culture rewards clever history rewrites over boring, correct rotation. Every disadvantage is manageable — but only if you know it exists, which is the point.
Hands-on lab
Reproduce the full loop on a throwaway repo: plant a secret, watch each layer catch it, then remediate correctly. Free-tier friendly — you need git, the gh CLI (authenticated), Docker, and optionally gitleaks, trufflehog, and trivy installed locally. Delete everything at the end.
Step 1 — Create a throwaway repo and enable protection.
gh repo create kv-secrets-lab --private --clone
cd kv-secrets-lab
# Enable secret scanning + push protection on this repo
gh api --method PATCH /repos/$(gh api /user --jq .login)/kv-secrets-lab \
-f 'security_and_analysis[secret_scanning][status]=enabled' \
-f 'security_and_analysis[secret_scanning_push_protection][status]=enabled'
Expected: two JSON blocks showing "status": "enabled" for both features.
Step 2 — Add a pre-commit hook and custom rules.
cat > .pre-commit-config.yaml <<'YAML'
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
YAML
cat > .gitleaks.toml <<'TOML'
title = "lab rules"
[extend]
useDefault = true
[[rules]]
id = "lab-internal-pat"
description = "Lab internal PAT"
regex = '''kv_pat_[0-9a-zA-Z]{40}'''
keywords = ["kv_pat_"]
entropy = 3.5
TOML
pip install pre-commit && pre-commit install
Expected: pre-commit installed at .git/hooks/pre-commit.
Step 3 — Plant an internal token and watch pre-commit block it.
printf 'token=kv_pat_%s\n' "$(head -c30 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c40)" > config.txt
git add config.txt
git commit -m "add config" # EXPECT: gitleaks FAILS the commit
Expected: the commit is rejected with a Gitleaks finding naming lab-internal-pat and the file/line. The secret never became a commit.
Step 4 — Bypass pre-commit and watch push protection block the push. Plant a partner-pattern test secret (use a documented example, never a real key) so GitHub’s server-side detection engages:
# Use AWS's own documented EXAMPLE key so nothing real leaks
printf 'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n' >> config.txt
git add config.txt
git commit -m "bypass local hook" --no-verify # skip pre-commit deliberately
git push # EXPECT: push REJECTED by push protection (for a real key shape)
Expected: the push is rejected server-side with a message identifying the secret type and an inline bypass URL. Note that GitHub allowlists its own documented example keys, so for a live demo of a block you may need a non-allowlisted supported pattern; the point is that the rejection happens at the server, unbypassable by --no-verify.
Step 5 — Scan for a secret baked into a container image.
cat > Dockerfile <<'DOCKER'
FROM alpine:3.20
ENV LEAKED_API_KEY=kv_pat_ABCdef123456GHIjkl789MNOpqr012STUvwx345
CMD ["sh"]
DOCKER
docker build -t kv-secrets-lab:leak .
trivy image --scanners secret --exit-code 1 kv-secrets-lab:leak # EXPECT: finding + exit 1
Expected: Trivy reports the secret in the image’s ENV/layer and exits non-zero.
Step 6 — Scan the working tree (IaC/fs).
gitleaks detect --source . --config .gitleaks.toml -v || echo "gitleaks found secrets (expected)"
trufflehog filesystem . --only-verified || echo "trufflehog verified-only scan complete"
Expected: Gitleaks reports the planted tokens; TruffleHog --only-verified reports nothing (the planted values are not live).
Step 7 — Practice the remediation order (simulated). In a real leak you would revoke at the provider first. Here, simulate: resolve the class by removing the values and rotating references. Then, only if required, purge history:
# Remove the values from the tree (rotate would happen at the provider in reality)
printf 'DbPassword=@Microsoft.KeyVault(VaultName=kv-demo;SecretName=db-pw)\n' > config.txt
git add config.txt && git commit -m "replace secret with vault reference" --no-verify
# OPTIONAL, LAST: purge the string from history (demonstration)
echo 'kv_pat_ABCdef123456GHIjkl789MNOpqr012STUvwx345==>***REMOVED***' > /tmp/repl.txt
git filter-repo --replace-text /tmp/repl.txt --force # rewrites history
Expected: config.txt now holds a vault reference, not a value; git log -p shows the secret string replaced with ***REMOVED***.
Step 8 — Validate no active alerts remain and tear down.
gh api "/repos/$(gh api /user --jq .login)/kv-secrets-lab/secret-scanning/alerts?state=open" \
--jq '[.[] | select(.validity=="active")] | length' # EXPECT: 0
cd .. && rm -rf kv-secrets-lab
gh repo delete "$(gh api /user --jq .login)/kv-secrets-lab" --yes
docker rmi kv-secrets-lab:leak
Expected: zero active alerts, and the repo, clone, and image are gone.
The lab, mapped to the layer each step exercises:
| Step | Layer exercised | What it proves |
|---|---|---|
| 3 | Pre-commit (Gitleaks) | Local hook blocks before a commit object exists |
| 4 | Push protection | Server-side rejection is unbypassable by --no-verify |
| 5 | Image scanning (Trivy) | Secrets in ENV/layers are found post-build |
| 6 | fs/history scanning | Gitleaks finds tokens; TruffleHog verifies liveness |
| 7 | Remediation + prevention | Vault reference replaces the value; history purge is last |
| 8 | Triage/metrics | Zero active alerts is the exit criterion |
Common mistakes & troubleshooting
The failure modes that actually happen, with the exact way to confirm and fix each:
| # | Symptom | Root cause | Confirm | Fix |
|---|---|---|---|---|
| 1 | Ran git filter-repo, key still being abused |
History rewritten but credential never revoked at provider | Test the old key: aws sts get-caller-identity still succeeds |
Revoke at provider first; purge is optional/last |
| 2 | Deleted .env, scanner still flags it |
Blob reachable via old commit SHA; delete ≠ removal | git log --all --full-history -- .env; git show <sha>:.env |
Rotate the secret; purge history if required |
| 3 | Secret in .gitignore but still committed |
Added to .gitignore after the file was committed |
`git ls-files | grep .env` shows it tracked |
| 4 | Push protection publishing thousands of alerts | Custom pattern published live without dry run | Alert count spiked at pattern publish time | Delete/refine pattern; use dry run next time |
| 5 | Legit release branch push rejected | Generic entropy rule matches sealed-secrets ciphertext | Bypass reason “false positive” on AgB... blobs |
Add Must not match on ciphertext prefix + path allowlist |
| 6 | Container “clean” but secret found in image | Secret in an earlier layer; later RUN rm doesn’t remove it |
docker history <img>; trivy image --scanners secret |
BuildKit --mount=type=secret; never ENV/COPY the secret |
| 7 | CI scan passes but a secret is in the repo | actions/checkout fetched only HEAD (shallow) |
Job log shows no full history fetched | Set fetch-depth: 0 on checkout |
| 8 | CI log leaked a secret despite masking | Value derived at runtime, or set -x/env printed it |
Search the run log for the value | echo ::add-mask::; remove set -x/env dumps |
| 9 | Terraform state full of plaintext secrets, committed | No remote backend; local .tfstate swept into a commit |
`git ls-files | grep tfstate` |
| 10 | Alert queue is 4,000 items, nobody triages | No validity filter; no ownership routing | All alerts unassigned, validity unfiltered |
Filter to active; route by CODEOWNERS; set SLA |
| 11 | False positives flood one rule | Entropy floor too low / no anchor on a generic rule | FP rate for that rule > 20% | Add anchor + raise entropy; allowlist known-safe class |
| 12 | Pre-commit “not catching anything” | Dev never ran pre-commit install, or used --no-verify |
.git/hooks/pre-commit missing, or bypass in reflog |
Enforce via CI + push protection (not the local hook) |
| 13 | Key Vault reference resolves empty, app crashes | Identity/RBAC/firewall/secret issue at boot | Environment variables blade shows red reference error | Fix identity + Key Vault Secrets User role + firewall |
| 14 | Rotated the key but the app broke | Deleted old key before the new one was proven working | App auth errors right after rotation | Overlap: deploy new, verify healthy, then delete old |
| 15 | “Secret scanning enabled” but nothing scans | Enabled on new-repo default only, not on existing repos | Repo settings show scanning off | PATCH each repo’s security_and_analysis explicitly |
| 16 | Resolved alerts, but MTTR metric looks fake | Alerts closed as false_positive instead of revoked |
Resolution distribution skews to false_positive | Use revoked for rotated secrets; fix past miscodes |
The decision table for the most common confusion — “a scanner flagged something, what now?”:
| If the finding is… | It’s probably… | Do this |
|---|---|---|
active validity, partner pattern |
A live, working credential | Rotate this hour; resolve revoked |
unknown validity, custom pattern |
Likely live (can’t verify) | Assume live; rotate; resolve revoked |
A documented example key (...EXAMPLE) |
A docs placeholder | Allowlist the exact string; false_positive |
| A high-entropy base64 blob in a known dir | Sealed-secret ciphertext (safe) | Path-scope allowlist + Must not match |
A value only in a _test/testdata path |
A fixture | Verify non-production; allowlist path; used_in_tests |
| Already-rotated key still in history | A dead string | Note it; purge history only if compliance requires |
Best practices
- Rotate first, always. The order is revoke → rotate → verify → resolve → (optional) purge. Write it on the runbook’s first page. The exposure window ends at revoke, not at history rewrite.
- Never treat a pre-commit hook as an enforcement boundary. It is advisory (
--no-verifyexists). Enforcement lives in push protection and required CI checks. - Dry-run every custom pattern before publishing. A sloppy live pattern can stall a monorepo and flood the queue. Validate the match count and eyeball the matches first.
- Filter triage by validity, then route by ownership.
activefirst; assign viaCODEOWNERS; security holds the SLA and audit trail. An ownerless alert ages forever. - Scan all six surfaces, not just repo files. History, live pushes, CI logs, image layers, IaC/state, and package config. Scanning only source is scanning a third of the attack surface.
- Never
ENV/COPY/ARGa secret into an image. Use BuildKit--mount=type=secretso the value lives only during theRUNand never in a layer. - Move state to a remote, encrypted, identity-authenticated backend, and
.gitignore*.tfstate/*.tfvarsfrom day one. Treat state as plaintext-secret-bearing. - Retire secret categories, not just instances. Every rotation should ask “can this class stop existing?” — vault references for values, OIDC for CI credentials.
- Track false-positive rate per rule. Above ~20% for a rule, the rule needs fixing, not the developers. Suppress the class, not the instance.
- Use
revokedas the resolution for rotated secrets. Miscoding asfalse_positivesilently corrupts your MTTR metric and your audit trail. - Overlap credentials during rotation. Deploy the new value, verify the app is healthy, then delete the old one — never the reverse, or a leak becomes an outage.
- Enable protection on existing repos explicitly, not just the new-repo default. Script it across the org’s repo list; the default only covers repos created afterward.
Security notes
- Least privilege on the leaked credential is your blast-radius control. A leaked key scoped to one bucket is a contained incident; a leaked key with
*:*is a catastrophe. The rotation urgency is the same, but the impact is entirely set by how narrowly the credential was scoped before it leaked. Scan findings should feed back into “why did a key with this much power exist?” - The scanning service itself needs least privilege. The token your CI scanner uses (
GITHUB_TOKEN, or a PAT) should be read-only where possible. A scanner with write access is a new secret to protect. - Validity checks call the provider — understand the data flow. GitHub’s validity checking sends a (partner-defined, minimal) request to test the key. This is by design and low-risk, but know it happens; some regulated environments want it acknowledged in a DPA.
- Bypass approvals are a privileged action — audit them. A push-protection bypass lets a real secret through. Route every bypass to security, log the actor and reason, and review the trend. A rising bypass rate is a rising leak rate.
- Never store the vault’s own bootstrap credential in the pipeline. Use managed identity or OIDC to reach the vault; if the pipeline holds a long-lived vault credential, you have just recreated the problem one level up. This is the recursion the OIDC section closes.
- History purge is a data-destruction operation — treat it with care.
git filter-repo --forcerewrites history irreversibly for the local copy and, after force-push, for the remote. Take a backup mirror first, coordinate with all collaborators, and never run it under incident panic before rotation. - Public repos change the math. For a public (or ever-was-public) repo, assume any secret that was ever committed is already scraped — automated bots scan new public commits within seconds. Rotation is not optional and not slow-rollable for public exposure.
Cost & sizing
What drives the bill and how to size the program:
| Cost driver | Model | Rough figure | Notes / how to control |
|---|---|---|---|
| GitHub Secret Protection | Per active committer / month (SKU) | Metered per active committer | Only active committers count; not all seats |
| Gitleaks / detect-secrets | Free / OSS | ₹0 | Self-hosted; only CI compute cost |
| TruffleHog OSS | Free / OSS | ₹0 | Enterprise tier for scale/SSO/dashboards |
| GitGuardian | Commercial (free tier for small teams) | Per developer / month | Managed SaaS; cross-VCS; dashboards |
| CI compute for scans | Runner minutes | Marginal | Diff-scope on PRs; full history nightly, not per-PR |
| Trivy image/fs scanning | Free / OSS | ₹0 | Runner minutes only; cache the DB |
| Secrets manager (Key Vault) | Per operation + per secret | Low (₹ per 10k ops) | Reference resolution is cheap |
| Remediation labor | Engineer time | Hours per leak → minutes with runbook | The runbook is the cost-reduction |
| Breach cost avoided | (Risk) | Very high | The whole ROI is the un-happened breach |
Sizing guidance by org shape:
| Org shape | Recommended stack | Why |
|---|---|---|
| Small (<20 repos), GitHub | Push protection + Gitleaks pre-commit/CI | Cheap; push protection is the key control |
| Mid (hundreds of repos), GitHub, compliance | Secret Protection (all controls) + Gitleaks + TruffleHog gate + metrics | Enforcement + breadth + live-key gate + audit |
| Large multi-VCS (GitHub+GitLab+Bitbucket) | GitGuardian (managed) + native scanners + Gitleaks | One dashboard across VCSs; managed triage |
| Regulated / air-gapped | Self-hosted Gitleaks + TruffleHog; internal patterns | No SaaS data flow; full control |
The single highest-ROI spend is rotation speed, and it is mostly free: a written runbook, CODEOWNERS routing, and a validity-first triage filter turn an eleven-day dwell time into a two-hour one at essentially zero tooling cost. The tooling stops leaks; the runbook shrinks the ones that get through — and the runbook is the cheap half.
Interview & exam questions
1. A key was removed in a later commit. Is the repo clean? No. The blob is still reachable via the old commit SHA (git show <sha>:file), it is in every clone and fork made while it was present, and the “removing” commit changed nothing about the credential itself. The repo is clean only after the credential is rotated (making the value dead) and, if required, the history is purged. Removal-in-a-later-commit is meaningless for remediation.
2. What is the correct order of operations when a live secret is confirmed leaked, and why? Revoke at the provider → rotate to a new value → verify the old value is dead → resolve the alert as revoked → optionally purge history. The order is set by the exposure window: the key is exploitable until revoke, so revoke first stops the bleed. History purge is last because it does nothing to a credential an attacker already copied — a scrubbed history around a live key is a clean-looking breach.
3. Why is a pre-commit hook not an enforcement boundary, and what is? A pre-commit hook runs locally and is bypassable with git commit --no-verify — it catches honest mistakes cheaply but cannot stop a determined or careless developer. The enforcement boundaries are server-side: push protection (rejects the push before the secret lands, unbypassable by a local flag) and required CI checks (via branch protection). Pre-commit is for speed; push protection is for the guarantee.
4. What does a validity check do, and how does it change triage? It calls the provider (or does a cheap authenticated no-op) to determine whether a matched secret is live right now, labeling the alert active, inactive, or unknown. It reorders triage: active findings are live credentials to rotate immediately, while inactive/unknown can be deprioritized or verified. It collapses a firehose of pattern matches into a prioritized, live-key-first queue.
5. A secret was deleted from a Dockerfile with RUN rm, but a scanner still finds it in the image. Why, and what’s the fix? Docker images are layered, and all layers persist; a secret added by an earlier COPY/ENV/RUN remains in that layer even if a later RUN rm deletes the file. docker history and a layer scanner still surface it. The fix is to never let the secret enter a layer: use BuildKit --mount=type=secret, which exposes the value only during that RUN and never writes it to the image.
6. Why is .gitignore not a remediation for a committed .env? .gitignore only prevents future additions of matching files. If .env was committed before it was ignored, the already-committed version stays in history, fully exposed; adding it to .gitignore afterward does nothing to the existing exposure. You must git rm --cached it, rotate the secret (it’s in history), and purge history if required.
7. How do you enable secret scanning across an org with 1,400 existing repos without clicking 1,400 settings pages? Set the org-level defaults for new repos via gh api PATCH /orgs/<org> with secret_scanning_enabled_for_new_repositories and the push-protection equivalent, then script the existing repos: gh repo list <org> --json name piped into a loop that PATCHes each repo’s security_and_analysis[secret_scanning] and push-protection status. The org default does not retroactively cover existing repos — you must enable those explicitly.
8. What causes a custom pattern to stall an entire monorepo, and how do you prevent it? Publishing a custom pattern live without a dry run: if the pattern is too broad (e.g. a loose entropy rule), it push-protects every matching push and floods the security tab with thousands of historical alerts. Prevention is the dry run — preview the pattern’s matches against real history first, validate the count, then publish. When a legitimate workflow (like sealed secrets) trips it, add a Must not match on the safe artifact’s prefix and a path-scoped allowlist rather than widening any bypass.
9. Your pipeline authenticates to AWS with a stored access key that keeps leaking. What’s the structural fix? Eliminate the stored credential with OIDC / workload identity federation: the CI runner requests a short-lived OIDC token and exchanges it for cloud access at job time, so there is no long-lived key to leak. This retires the entire most-leaked credential class (pipeline cloud keys) rather than rotating it repeatedly. In GitHub Actions, grant id-token: write and configure a federated credential trusting the repo’s OIDC subject.
10. When is git filter-repo genuinely warranted, and when is it theater? It is warranted when compliance/contract requires the string physically removed, or when a repo is (or was) public and you want the real-looking value gone — but always after rotation. It is theater when done instead of rotation (the live key is untouched), or when the repo is private and internal and the key is already rotated (a dead string is harmless, and the rewrite breaks every clone and open PR for no security gain).
11. What is SNAT-style “it passes in test, fails in prod” for secrets — i.e., why do leaks often surface late? Because the leak’s impact is realized only when someone (or a bot) exploits the credential, which can be days after the commit. Automated scrapers hit new public commits within seconds, but private-repo leaks may sit until a fork, a visibility flip, or an insider surfaces them. This latency is why dwell time (leak-to-revoke) is the metric — the credential was exploitable the whole time, whether or not anyone had exploited it yet.
12. How do you measure whether a secrets program is actually working? Three metrics: mean-time-to-rotate (alert-to-revoked latency — the exposure-window proxy; drive it to hours, watch p90 for the long tail), secret density (active leaks per 1,000 repos, trending to zero), and false-positive rate per rule (above ~20%, fix the rule). Plus operational signals: push-protection block count (secrets stopped pre-leak) and bypass rate (a rising bypass rate is a rising leak rate).
These map to AZ-500 (Azure Security Engineer) — secure DevOps, secret management, managed identities — GitHub Advanced Security certification/skills, and the DevSecOps domains of SC-100 / CKS where supply-chain and secret handling appear. A compact cert mapping:
| Question theme | Primary cert / domain | Objective area |
|---|---|---|
| Revoke/rotate order, remediation | AZ-500 / SC-100 | Incident response; secret management |
| Push protection, custom patterns | GitHub Advanced Security | Secret scanning configuration |
| Vault references, managed identity | AZ-500 / AZ-204 | Secure app configuration |
| OIDC / workload federation | AZ-500 / SC-100 | Secretless CI/CD; identity |
| Image-layer secrets, BuildKit | CKS / supply-chain | Container image security |
| Metrics, program governance | SC-100 | Security posture & governance |
Quick check
- A developer removed a leaked API key in a follow-up commit and says the repo is clean. Is it? What two steps actually make it clean?
- A live AWS key is leaking right now. What is the first command you run, and why is running
git filter-repofirst a mistake? - Why can a pre-commit Gitleaks hook not be your enforcement boundary, and what is?
- Your container’s
RUN rm .envshould have removed the secret, but Trivy still finds it in the image. Why, and what’s the structural fix? - You have 4,000 open secret-scanning alerts and no time to triage them all. What one filter turns this into a manageable incident queue?
Answers
- No. The blob is still reachable via the old commit SHA and exists in every clone/fork. It becomes clean only after you (a) rotate the credential at the provider (making the value dead) and (b) optionally purge history if compliance requires the string gone. The “removing” commit accomplished nothing for remediation.
aws iam update-access-key --access-key-id AKIA... --status Inactive— revoke at the provider first, because that is the only step that stops the credential working. Runninggit filter-repofirst is a mistake because it rewrites history (breaking every clone and open PR) while the key stays fully live everywhere it was copied — you have cleaned the repo and left the breach open.- A pre-commit hook runs locally and is bypassable with
git commit --no-verify, so it catches honest mistakes but cannot enforce. The enforcement boundary is push protection (server-side rejection of the push, unbypassable by a local flag) plus required CI checks via branch protection. - Docker images are layered and all layers persist — a secret added in an earlier
COPY/ENVremains in that layer even after a laterRUN rmdeletes the file.docker historyand Trivy still surface it. The structural fix is a BuildKit secret mount (--mount=type=secret), which exposes the value only during thatRUNand never writes it to any layer. - Filter to
validity == active— GitHub’s validity checks label live credentialsactive, so filtering to active collapses thousands of pattern matches down to the handful that are actually working, live secrets. Those are your incident queue; route them byCODEOWNERSwith a rotation SLA and handle the rest as lower priority.
Glossary
- Secret sprawl — the steady-state accumulation of live credentials across repos, history, CI logs, images, IaC/state, and config; a condition, not a single incident.
- Pre-commit hook — a local git hook (here, Gitleaks via the pre-commit framework) that scans staged changes before a commit object exists; advisory and bypassable with
--no-verify. - Push protection — GitHub’s server-side rejection of a push that contains a detectable secret, so the credential never lands on the remote; the only pre-leak boundary a developer cannot skip locally.
- Secret scanning — GitHub’s scanning of full history and ongoing pushes for known partner patterns and custom patterns, raising an alert per finding.
- Partner pattern — a vendor-registered secret format (AWS, Azure, Stripe, GitHub, Slack, etc.) that GitHub recognizes and can often validity-check.
- Custom pattern — your own regex (org-level in GitHub, or in
gitleaks.toml) for internal token shapes the built-in rules do not know. - Validity check — a provider call that tests whether a matched secret is live right now, labeling the alert
active,inactive, orunknown. - Shannon entropy — a measure of a string’s randomness; used to catch high-randomness secrets that no anchored regex anticipated, at the cost of false positives on other random-looking strings.
- Rotation — issuing a fresh credential and invalidating the old one at the provider; the fix that ends a leak’s exposure.
- Revoke — disabling/invalidating a credential at its provider; the first and only urgent remediation step.
- History purge — physically removing a string from git objects with
git filter-repoor BFG; optional, last, and useless without rotation. git filter-repo— the modern tool for rewriting git history to remove strings/files; rewrites SHAs and forces re-clones.- BFG Repo-Cleaner — an older JVM-based history-rewriting tool for the simple “replace these strings” case.
- BuildKit secret mount —
--mount=type=secretin a DockerfileRUN, exposing a value at build time without persisting it in any image layer. - Vault reference — an app setting that points to a secret (e.g.
@Microsoft.KeyVault(...)) resolved at runtime by a managed identity, so no value lives in config. - OIDC / workload identity federation — a CI-to-cloud trust that exchanges a short-lived token for access at job time, eliminating the stored credential entirely.
- MTTR-secret (mean-time-to-rotate) — the latency from an alert firing to the credential being confirmed dead; the operational proxy for exposure window.
- Secret density — active leaks per 1,000 repos; a program-health metric trending toward zero.
- CODEOWNERS — the file that maps repo paths to owning teams; the basis for auto-routing alerts to whoever owns the rotation.
Next steps
You can now find, block, and remediate leaked secrets as a program, with rotation first and metrics on top. Build outward:
- Next: Workload Identity Federation: Secretless CI/CD — retire the most-leaked credential class (pipeline keys) entirely with OIDC, so there is nothing to scan for.
- Related: Pipeline Secrets Management — where the rotated secret should live and how pipelines should consume it safely.
- Related: Secret Management in Pipelines with Key Vault and Managed Identity — the zero-plaintext end state your remediations should converge on.
- Related: Azure Key Vault: Secrets, Keys & Certificates — the vault mechanics behind the references you migrate secrets into.
- Related: Securing the Container Supply Chain with Private ACR — the image-scanning and build-hygiene side of keeping secrets out of layers.
- Related: Software Supply Chain: SBOM, VEX & Admission Verification — the broader supply-chain controls that sit alongside secret scanning.
- Related: Security Incident Response Runbooks, Tabletops & Cloud Forensics — where a serious leak escalates when rotation alone isn’t the whole response.