There are good credential scanners and I did not need to write one. gitleaks and trufflehog are mature, well tested, and aimed at a git repository, where a leaked key is a commit that will outlive the mistake.
The thing I wanted to scan was a Downloads folder.
That is a different problem than it first looks. Nobody reviews a Downloads folder. A config file you were sent on Slack in March, a .env a contractor mailed you, a server export you opened once — they land there, they are never opened again, and they are never deleted either. The folder is a sediment. And because it is not a repo, nothing scans it, ever.
So I added a check to a Mac app I make. It reads the plain text files in the folders you watch and tells you if a password or an API key is sitting in one of them. It never shows the secret itself, only the file and what kind of thing it found, because a scanner that prints the key into a log has invented a second copy of your problem.
Writing it took an afternoon. Getting it to shut up took considerably longer, and that part was the education.
The high-confidence half is just shapes. An AWS access key ID is AKIA and sixteen uppercase alphanumerics. A GitHub token is ghp_ and thirty-plus characters. OpenAI is sk-, Slack is xox, Stripe is sk_live_. A private key announces itself in capital letters. These are unambiguous and there is nothing to think about.
The interesting half is the assignment: some word that means secret, then a separator, then a value. And my first attempt at that missed the single most common real case in the world.
db_password = Tr0ub4dor3xKz9
I had written \bpassword. The word boundary never fires, because in every regex flavour that matters the underscore is a word character. db_ and password are the same word as far as \b is concerned, so there is no boundary between them to match.
Which means the pattern found password = hunter2 and skipped db_password = hunter2, and it is the second one that appears in real configuration files. I did not notice by reading the code. I noticed because a test failed.
The same shape bit me from the other side:
aws_secret_access_key = wJalrXUtnFEMIK7MDENG
Here the keyword is secret, and it is followed by more words before the equals sign. So the pattern needed to allow a suffix chain after the keyword. I wrote that, felt clever, and shipped a scanner that cried wolf at everything.
The suffix chain I had written was, roughly, "the keyword, then any more word characters, then the separator". Which is how I ended up with this in the results:
secretaria_email: maria@empresa.cl
"Secretaria" is Spanish for secretary. It starts with the letters s-e-c-r-e-t.
I am fairly sure this bug does not exist in any scanner written by someone working in English, because nobody working in English has a common noun that begins with the word secret. Mine flagged a contact list as a credential leak, in a file that contained no credentials at all, because of a word I have typed ten thousand times in my life without ever noticing it was hiding another one.
The fix is small and slightly humbling: the keyword has to be followed by an actual separator — an underscore or a hyphen — not by more letters. aws_secret_access_key passes. secretaria does not.
Once I started looking, the file was full of them.
tokens: 15000000
token_expira: 2026-08-18
api_key_docs: https://ejemplo.com/docs
A count of tokens is not a token. A date is not a token. A link to the documentation about an API key is not an API key. All three matched, and every one of them is the kind of line that shows up in an ordinary YAML file that has nothing to hide.
So the value side needed guards too: not a bare number, not an ISO date, not something that starts with http.
The other large category is files that are about secrets without containing any.
API_KEY=your-api-key-here
PASSWORD=changeme
API_KEY="<your-key-here>"
Every example repository ships one of these. If your scanner treats them as findings, then the first thing a person sees when they turn the feature on is a screen of alarms about a tutorial they downloaded, and they turn it off and never turn it back on.
There is also a minimum length, which took me two tries to get right. password = 12345 is not a credential, it is a joke or a placeholder. But eight characters is not much of a filter either, and I know it: monkey = 12345678 passes length and fails common sense. I catch that one only because the value is all digits.
Here is where it ended up. It is not elegant and I am not going to pretend it is.
(?i)(password|passwd|api[_\-]?key|secret|token)
(?:[_\-][A-Za-z0-9]+)*
\s*[:=]\s*["']?
(?!(your|xxx|placeholder|changeme|example|<|https?://
|[0-9]{4}-[0-9]{2}-[0-9]{2}|[0-9]+\b))
[^\s"']{8,}
Every clause in that negative lookahead is a specific afternoon.
This one had nothing to do with regexes and I would have shipped it without noticing.
I was reading the first 512 KB of each file and decoding it with the Swift equivalent of "give me a string from these bytes, assuming UTF-8". That call returns nothing if any byte in the buffer is invalid UTF-8 — or if the cut at 512 KB happens to land in the middle of a multi-byte character, which it will, eventually, on some file, on someone's machine.
The failure mode is the bad one: it does not throw, it does not warn, it returns nothing and the file is skipped. A scanner that quietly declines to look at a file, and then reports that everything is fine, is worse than no scanner. It is a scanner that lies.
The fix is one line — decode with replacement instead of failing — and the test that proves it is three bytes of garbage in front of a real AWS key.
I also stopped memory-mapping the file. Mapping is the natural way to read a bounded prefix of something large, but if the file is truncated while it is mapped — a log rotating, a sync client replacing a file inside a watched Dropbox folder — touching those pages is a SIGBUS, and you cannot catch a SIGBUS. Watched folders are exactly where files get replaced underneath you. A plain bounded read is slower and cannot do that.
The obvious next step is entropy: score the value, flag the high-entropy ones, catch the keys that do not sit next to a helpful keyword. I have not done it, and I keep going back and forth.
The argument against is the whole point of this post. The cost of a miss here is low — the file was already sitting in Downloads, and it will still be there tomorrow. The cost of a false positive is that someone turns the feature off. In a security product aimed at people who are already worried, one wrong alarm is expensive in a way that one quiet miss is not, and entropy scoring is famously good at flagging base64 blobs, minified JavaScript, UUIDs, and hashes that are not secrets at all.
The argument for is that the keyword approach can only ever find credentials that someone politely labelled.
If you have shipped entropy-based detection to non-technical users and it did not drown them, I would genuinely like to hear how you tuned it. That is the part I do not know how to do.
This is one check inside Tidy, a Mac menu bar app I make on my own in Santiago, Chile. $9 once, macOS 14 and up, and all of it runs on the machine — no account, no server, nothing uploaded. It never deletes anything either: everything it does goes to the Trash and undoes in one click. I answer every email myself.