Last year I found a production database password in one of my own public repos. Not a test password. Not a placeholder. The real one, in a config file, visible to anyone who knew how to read git history. It had been there for 8 months.

I didn't find it during an audit. I found it because a friend asked a question that made me look.

That's the uncomfortable truth about secret leaks: they almost never happen in one dramatic moment. There's no single "oops, I committed the .env" incident you can point to and learn from. There's a slow accumulation of small, reasonable decisions that add up to a credential sitting in public view.

This post is a field guide. I'll walk through the 6 ways secrets actually slip into a repo, and for each one I'll show you how to catch it. I'll also show you a small zero-dependency scanner I built after that 8-month incident — dotguard — and how to wire it into your workflow so the catching happens automatically.

1. The .env file that was never meant to be public

The classic. You create .env, put real values in it, and one of three things happens:

  • You commit it before adding it to .gitignore
  • You add it to .gitignore after the first commit
  • You commit it to a private repo, then make the repo public

The second one is the sneaky one. .gitignore only prevents new files from being tracked. If .env was already committed, adding it to .gitignore does nothing — git keeps tracking it, and the file stays in every clone. I've watched teams "fix" a leak by adding .env to .gitignore, then wonder why the file is still there.

How to catch it:

# What is git actually tracking? git ls-files | grep -i env 

If that prints .env, it's tracked, and .gitignore is not helping you. You need git rm --cached .env plus a history cleanup.

For the ongoing case, a scanner that walks your working tree and flags files with secret-looking content is the right tool. That's what dotguard does:

npm i -g @wuchunjie/dotguard dotguard 

One command. It scans the current directory — .env files, config files, source code — and reports anything that looks like a hardcoded credential. Zero dependencies, so it runs anywhere Node runs, including on a CI runner with nothing preinstalled.

2. The .env.example with real values

.env.example is the file you're supposed to commit. It's the template teammates copy when they start. The problem is how it gets made: someone copies their real .env, deletes a few lines, and calls it an example. The "example" values are production values.

I've seen DATABASE_URL=postgres://admin:RealPassword123@prod-db:5432/app sitting in a .env.example for over a year. Every new clone of the repo carried it. Every new developer had it in their shell history.

How to catch it:

  • Treat .env.example as code. Review it in PRs like you would any other file.
  • Scan it. It has the same shape as .env, so a content scanner should treat it the same way.

dotguard scans config files, not just files named .env, so a .env.example with real values gets flagged the same way as the real thing. That's the whole point of scanning content instead of filenames.

3. Hardcoded in source

Not every secret lives in an env file. Some live in:

  • config.js or settings.py
  • Client-side code — the worst kind, because it ships to every user's browser
  • Test fixtures
  • Dockerfiles (ENV API_KEY=...)
  • CI config files

The client-side case deserves special attention. A public API key in frontend code is a design decision, not a leak — the key is meant to be public. But a private key, a signing secret, or an admin token in a bundle is a leak that ships to millions of devices, and no amount of .gitignore fixes it.

How to catch it:

# Crude, but catches a lot git grep -n "API_KEY\|SECRET\|TOKEN\|PASSWORD" 

The problem with git grep is that you have to guess the variable names. A content scanner doesn't guess — it looks at the shape of the value: long high-entropy strings, known key prefixes, password = "..." assignments. That's a different failure mode, and it catches the secrets that don't have obvious names.

dotguard --path ./src 

4. The log line

console.log("user:", user, "token:", token); 

Logs are where secrets go to die — and to be copied. A token printed to stdout ends up in:

  • Local log files
  • CI logs (public on public repos)
  • Error reporting services
  • Screenshots pasted into Slack

The fix is two-part. First, don't log secrets: audit your logging and redact known fields. Second, scan the places where logs accumulate:

dotguard --path ./logs 

This is a low-frequency, high-impact check. Run it when you're cleaning up a project, or when you're about to open a repo up.

5. Git history

This is the one that got me. You delete the file, you feel better, you move on. But git doesn't delete — it remembers. Every commit that ever contained the secret is still reachable from history.

The 8-month password was in a file I'd deleted months earlier. A single command found it:

git log --all -p -S "password" 

Thirty seconds. Eight months of exposure.

How to catch it:

# Search all history for a string git log --all -p -S "SECRET_PATTERN" # Grep every commit (slow on big repos) git grep "PATTERN" $(git rev-list --all) 

The uncomfortable part: if a secret was in public history, it's already leaked. Rewriting history with BFG or git filter-repo doesn't un-leak it — it just stops the bleeding for future clones. The only real fix is rotation.

How to prevent it from happening in the first place:

  • A pre-push hook that runs a scanner and rejects the push if it finds something
  • A CI gate that fails the build on a finding

dotguard's CI integration is one line:

- name: Secret Scan run: npx @wuchunjie/dotguard 

If you want a pre-push hook, the pattern is the same: run the scanner, and if it reports findings, exit non-zero. Check dotguard's output behavior and wire it in — the point is to make the scan happen before the push, not after the leak.

6. The screenshot and the error report

The weirdest vector, and the one no tool can catch. A stack trace with a connection string pasted into a GitHub issue. A screenshot of a terminal with the env dump visible. An error report that includes request headers.

This is a process problem, not a tooling problem:

  • Redact before you paste. Make it a habit, not a rule.
  • Review public issues and PRs for sensitive content before they go out.
  • Configure your error reporting to strip headers and query params.

You can't scan for a screenshot. You can only build the habit of looking before you share.

What to do when you find one

Order matters here, and people get it wrong:

  1. Rotate the credential first. Not after cleanup. The moment you find a leaked secret, assume it's compromised. Rotation is the fix; everything else is cleanup.
  2. Remove it from the working tree.
  3. Clean history if it was public (BFG, git filter-repo).
  4. Add the pattern to your scanner and your .gitignore.
  5. Note down which credential was affected and for how long, so the team can check for misuse.

A note on false positives

Any content scanner will flag things that aren't real secrets: test fixtures with fake keys, documentation examples, placeholder values. That's expected, and it's why the output is a list to review, not a list to panic about.

The workflow is: run the scanner, look at each hit, and decide. If it's a real secret, rotate it. If it's a fixture, either change the value to something obviously fake or add it to your allowlist. A scanner that's fast and mostly right is still better than no scanner, because it turns "do we have a leak?" from a vague worry into a concrete list you can work through.

Why a security tool with zero dependencies

There's a reason dotguard has no dependencies, and it's not just minimalism. A secret scanner is a tool you want to trust with your most sensitive files. Every dependency is another supply chain you have to audit, another version to pin, another package that could get compromised. A scanner with zero dependencies is a scanner you can read end to end in an afternoon.

It also means it works in the places you actually need it: a fresh CI runner, a locked-down corporate machine, a container with nothing but Node. No npm install step that could fail, no lockfile to manage, no transitive dependency that quietly pulls in something you didn't ask for.

For a tool whose job is to protect you from things you didn't notice, being small and boring is a feature.

The 5-minute setup

If you take nothing else from this post, take this:

npm i -g @wuchunjie/dotguard dotguard 

One command. Zero dependencies. Scans your whole project for hardcoded secrets. Add the CI step if you want it to be a gate instead of a suggestion.

The habit, not the tool

Secrets leak slowly, and the fix isn't a single tool — it's a habit: scan before you push, scan in CI, and when you find something, rotate first.

dotguard is the smallest version of that habit: one command, no config, no dependencies. It's at @wuchunjie/dotguard on npm, MIT licensed, source at github.com/wuchunjie00/dotguard.

If you've found a secret in your own repo, I'd love to hear how you found it. The slow leaks are the ones worth talking about.