DevSecOps · May 20, 2026 · 5 min read

Secrets outlive the commit that removed them

Deleting a key from the latest commit does not delete it - anyone who clones the repo still has the history.

A developer spots a hardcoded API key in the codebase, deletes the line, and commits the fix. The latest tree is clean, the pull request is green, and everyone moves on. The secret, however, has not gone anywhere. It still sits in the previous commit, and every clone, fork, CI cache and laptop that pulled the repo carries a perfect copy of that history. Git is content-addressable and append-only by design: a commit does not edit the past, it adds to it. Deletion at HEAD is cosmetic.

This is the gap most secret scanners walk straight into. They scan the working tree - the files on disk right now - because that is fast and that is what the diff shows. Attackers do the opposite. They clone the whole thing and run their tooling across every blob in the object database, because that is where the deleted keys live. Defenders scan HEAD; adversaries scan history. apPosture closes that asymmetry by scanning the same surface the attacker does.

01Why a deleted secret is still a live secret

When you remove a key and commit, git records a new tree that no longer references that line. The old blob is untouched. It remains reachable from the earlier commit, which remains reachable from any branch or tag that ever pointed at it. Anyone with a full clone can walk back to it in one command. The only thing that actually neutralises the exposure is rotating the credential at the provider - revoking the old value so the copy in history becomes worthless. Rewriting history with a filter is disruptive, rarely completed across every fork, and still does not invalidate the token. Rotation does.

commit A commit B commit C (HEAD) + AKIA...secret (unrelated) - AKIA...secret "removed" └───────────────┴───────────────┘ naive scanner reads here ──► HEAD only: sees nothing, reports clean ✗ apPosture reads here ──► rev-list --all: blob in A still found ✓ the blob lives in A forever, and on every clone, fork and CI cache
The secret was added in commit A and "removed" in commit C. It never left. A HEAD-only scan reports the repo clean; a full-history walk recovers the blob from commit A.

02Scanning the surface the attacker scans

apPosture runs two passes over a cloned repository. The first walks the working tree, reading every text file and matching it against a curated set of high-signal provider patterns - AWS keys, GitHub and GitLab tokens, Stripe, Slack, Google, SendGrid, private-key headers, database connection URIs - plus a generic high-entropy check for anything assigned to a secret-shaped variable name. The second pass is the one that matters here: it walks git history.

The history pass issues a single `git rev-list --all --objects` to enumerate every blob ever committed, then streams their contents through one `git cat-file --batch` process and runs the same detectors over each blob. Binary blobs are skipped, the walk is bounded by a configurable blob cap and hard subprocess timeouts (60 seconds to enumerate, 120 to stream), so the scan stays fast on large repositories. Crucially, history matches are de-duplicated against the working tree: a key still present at HEAD is reported once as a live tree finding, not twice. Only secrets that were committed and then removed surface as history findings, each labelled "in git history - removed from HEAD" so the team knows exactly what they are looking at.

Every match - tree or history - is masked before it is stored or displayed: first four characters, last two, the middle redacted. The raw secret value is never written to the database, never returned by the API, and never placed in a report. The finding tells you a credential of a known type exists at a known location; it does not hand the credential to whoever reads the dashboard.

03Found in history is not the same fact as still live

A secret recovered from history raises an obvious question: does it still work? Those are two different facts and apPosture keeps them separate. "Present in the repository" is proven by the match itself. "Still live" can only be proven by asking the provider, and that is an outbound network call - so it is strictly opt-in, off by default, and read-only when enabled. The validity check performs the gentlest authenticated read each provider offers (a GitHub `GET /user`, a GitLab `GET /user`, a Slack `auth.test`) purely to observe whether the token is accepted. It never writes, never deletes, never escalates. The raw value is used transiently for that one request and is still never stored.

When the check is enabled and a token comes back accepted, the finding is escalated to critical and marked live - this is no longer a hygiene issue, it is an active key in the wild. When the provider rejects it, the finding is marked revoked: still worth cleaning up, but not an emergency. When the check is off or the provider type is not testable, the state is simply unknown, and the finding stands on the strength of the match alone. No guessing, no inflation.

StateWhat is provenHow it was establishedSeverity
Found in historyA real credential of a known type was committed and is recoverable from the object database.Pattern or entropy match over a rev-list blob. Always on.As classified (e.g. high)
Confirmed liveThe token is still accepted by the provider right now.Opt-in, read-only authenticated GET / auth.test. Off by default.Escalated to critical
RevokedThe token exists in the repo but the provider rejects it.Same opt-in check, negative result.Lowered - cleanup, not incident
UnknownPresent in the repo; liveness not tested.Check disabled or provider not testable.Stands on the match

04Rotation, not deletion, is the fix

The remediation guidance follows directly from how the secret behaves. Deleting the line removes it from the next diff and from no clone that already exists. Rewriting history is loud, breaks every downstream fork, and the leaked value is still valid the entire time it takes to coordinate. The reliable fix is to rotate: revoke the exposed credential at the provider and issue a new one. The moment the old value is revoked, every copy in every history everywhere becomes inert. That is why a confirmed-live history finding is treated as critical - the clock that matters is not when the line was deleted, it is when the key was rotated.

Because these findings persist into the same unified model as the DAST, SAST, SCA, container and IaC results (recorded as CWE-798, OWASP A07), a leaked credential is not an isolated lint warning. It is correlated, risk-weighted, tracked against an SLA, and carried through the same posture score as every other class of finding. A secret that an attacker can recover from your history and confirm against the provider is one of the shortest paths to compromise there is, and it deserves to be measured as such - not quietly dropped because the latest commit happened to look clean.

Secrets outlive the commit that removed them. The only honest way to scan for them is to look where they actually live, prove what you can prove, ask the provider only when invited, and tell the team the one thing that ends the exposure: rotate the key.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →