SYS://FIELD-REPORT-05
Your .env Is in Your Git History — Deleting It Didn't Help
The API ran. The tests passed. The repo was public on GitHub and, on the surface, tidy — no .env sitting in the file tree. It was also leaking a live database password and a Stripe secret to anyone who typed one git command, and the owner genuinely believed he’d already fixed it. Here’s the teardown, worst finding first.
This is a representative audit — the exact pattern I run into over and over on backends spun up with Cursor, Claude Code, Bolt, and friends. Names and paths are changed, but every finding below is real-shaped and drawn straight from the kind of report the launchworthy skill produces. Call the app relaykit: a Node / Express API, built with Cursor, pushed to a public GitHub repo, about to go live with real payment traffic.
It got a scorecard. The scorecard was not kind.
One critical is all it takes here. A leaked service credential doesn’t need company — it ends the app on its own.
The finding that ends the app: your secrets are in the history
relaykit’s .gitignore did not list .env. Not .env, not .env.local, not .env* — the file was never ignored, which means the very first git add . swept it into a commit. Config values, a Postgres connection string with the password inline, and a STRIPE_SECRET_KEY all went up to a public repo.
Per the checklist, .env* missing from .gitignore is [CRITICAL] — fix before any real user. And because it was already committed, there’s a second, compounding fact: an .env that was ever committed is [HIGH], and the secrets in it must be rotated, not just deleted.
CRITICAL · CAUGHT
.env* missing from .gitignore, secrets already pushed
A live Postgres password and a Stripe secret key are in the commit history of a public repo. Anyone who clones it — or reads it through the GitHub API — has full database access and a key that can move money. You would not get an alert. The first sign is a charge you didn’t make.
The fix has two halves, and the order matters. Stop the bleeding is not the same as clean up the spill.
THE FIX · IN ORDER
Rotate first, then ignore, then purge
- Rotate every secret that was ever in that file — right now. New database password, new Stripe key, new anything else. Assume the committed ones are burned. This is the only step that actually un-leaks you; everything after it is hygiene.
- Add the ignore rule so it can never happen again:
.env .env.* !.env.example - Remove the tracked file from the index (it stays on disk):
git rm --cached .env, then commit. - Purge it from history with
git filter-repo(or the BFG), then force-push. This closes the door for future clones — but note step 1 already had to happen, because anyone who cloned before the purge still has the old commits.
The false alarm: “I deleted it, so it’s gone”
Here’s the part that matters most for credibility, and the reason relaykit’s owner thought he was in the clear. He had noticed the committed .env. A few commits later he did this:
git rm .env
git commit -m "remove env file"
git push
The file vanished from the file tree. ls shows nothing. The GitHub web view shows nothing. Case closed, he figured.
FALSE POSITIVE
"The .env is deleted, so the secret is safe."
It is not safe. Git is a history, not a folder. Deleting a file in a new commit removes it from the tip — but every earlier commit that contained it is still there, and git log -p -- .env or the GitHub API prints the secret in full. The delete commit doesn’t un-leak anything; it just hides the file from the one place nobody attacking you is looking.
And someone is looking. Public GitHub is scanned continuously by bots that grep every push for credential shapes — connection strings, sk_live_ prefixes, private keys. The window between “pushed a secret” and “secret is being used” is measured in minutes, not days. The delete commit is a comforting gesture aimed at a human browsing the repo; the bots read the diff.
So the load-bearing distinction is this:
- Deleting the file → makes the tree look clean. Changes nothing about the leak.
- Purging history → removes it from future clones. Still doesn’t help anyone who cloned before you purged.
- Rotating the secret → the only action that makes the leaked value worthless. Do this one first, always.
Verify it by hand
Run git log --all --full-history -- .env in the repo. If it returns any commits, the file is still in history no matter what the current tree shows. Then git log -p -S 'sk_live' --all to confirm no live secret is recoverable from any commit. Empty output on both is your evidence the purge landed — but it does not replace rotation.
The rest of the punch list
The committed secret is the blocker. Two more findings sat behind it, ranked so the scope stays honest:
FIX THIS WEEK
After the rotation
- [HIGH]
npm auditreports critical vulnerabilities in the dependency tree. Run it, read the top of the report, and take the fixes it offers (npm audit fix, or a manual bump where it can’t auto-resolve). Critical vulns are a fix-this-week item, not a someday. - [MEDIUM] Hardcoded
http://URLs in production config —src/config/services.ts:12points an internal service call at a plain-http://host. In production that’s cleartext on the wire; move it tohttps://and read the base URL from an env var, not a literal.
Three findings total, but note the shape: one can end the business, and two are homework. That ranking is the point. A flat list reads as three equal chores. Worst-first with severities reads as “rotate the keys before lunch, then handle the other two this week.”
ASK YOURSELF
“No one will find my repo — it’s not like anyone’s looking.” Bots find it. Public GitHub is indexed and grepped for credential shapes continuously; “nobody knows my URL” was never a security control, and it especially isn’t one on a repo with a git push timestamp on it. You are not too small to be scanned. Scanning is automated and free.
Why this keeps happening
None of this means Cursor is bad, or that relaykit’s owner did anything foolish. AI builders are extraordinary at getting you to it runs. They are, by their nature, silent about git hygiene — because .gitignore isn’t a feature you asked for, the commit that leaked the secret looked identical to every other commit, and deleting the file genuinely feels like fixing it. The gap between “the file is gone from my folder” and “the secret is worthless to an attacker” is invisible from where you’re standing.
That gap is the whole reason launchworthy exists. It plays bouncer at the door of production: it knows that a deleted .env is still a leaked .env, it refuses to mark a secret safe until it’s been rotated rather than merely removed, and it hands you the fix in order — rotate, ignore, purge — not just the finding.
The hard part was never the git rm. It was knowing that the git rm didn’t count.