I was re-selecting a connected folder one morning. The dialog showed the working folder I use every day, and there was no reason to hesitate.
Then I stopped.
I could not actually say what was inside it. Four site repositories, operations documents, image assets, old backups. Add to a folder for a few years as an indie developer and this is simply what happens.
Before deciding how much to expose, I decided to count.
The short version: filenames miss about three quarters of it
Here is the result up front. The target was the same working folder I connect every day.
| Method | Hits | Actually contained a key | Missed |
|---|---|---|---|
| Search by filename | 24 | 5 | 13 files |
| Search by file contents | 18 | 18 | — |
Searching for names containing token or secret produced 24 hits, but only 5 of them held an actual key string. The other 19 were things like article drafts about how token counting works. A check that is wrong four times out of five is a check people stop reading.
The content search produced 18 files, and 13 of those carry no hint at all in their names.
That is where "protect it by naming convention" stops working.
What was actually counted: 14,814 files out of 103,267
The first number I got was 103,267 files and 4.0 GB. That number is close to meaningless, because most of it is node_modules.
I excluded the following before scanning.
| Excluded directory | Why |
|---|---|
node_modules | Not mine, and not somewhere keys live. Nearly 90% of the file count |
.next / dist / build | Build output. Reading the source is enough |
.git | History needs a different hunt (keys already committed), which is a separate question |
.wrangler | Deploy tooling scratch space |
After exclusions: 14,814 files and 2.1 GB, a little over a tenth of the original. Without that step the scan takes minutes every time and produces a result list nobody wants to read.
One note on skipping .git. A key committed to history survives deletion from the working tree, so that hunt still matters — but it answers a different question than "is it safe to expose this folder right now." Here I only looked at the working tree.
Of the 24 filename hits, 5 were real
All 19 false positives were article drafts. Their filenames contained token because they discussed token estimates and rate limits. Nothing more.
The 5 real ones broke down like this:
- A note holding a personal access token in plain text — 1
- Dated backups of that same note — 3
- A note holding a webhook signing secret — 1
There is a practical cost hiding in that 19. A check that mostly cries wolf gets skimmed, then scheduled less often, then quietly dropped. I had run a name-based grep like this before and remembered it as "clean," which in hindsight probably means I stopped reading past the article filenames. Precision is not a cosmetic property of a security check; it decides whether the check survives contact with a busy week.
The line that stopped me was the three backups. You can rotate a token and still leave the old value alive under a slightly different filename. Backups quietly extend the lifetime of a key.
Stray .bak files cause trouble on the commit side too. I wrote up how a helper script's leftovers get swept in by git add -A in git add -A sweeps up your .bak backups.
The 18 content hits, and why 13 of them are invisible by name
Grouped by extension:
| Extension | Count | What they were |
|---|---|---|
.sh | 10 | Throwaway scripts written to set up billing configuration |
.bak_* (dated) | 4 | Generational backups of documents and notes |
.txt | 3 | Notes holding tokens and configuration values |
.md | 1 | A troubleshooting log with a real key pasted into the repro steps |
Shell scripts dominated. Register products and prices in bulk, create webhooks, replace descriptions across a catalog — each one ran once, did its job, and stayed. When I wrote them they were "things I run locally," and that assumption evaporates the moment the folder is handed to something else. Working as an indie developer, nobody reviews that assumption for you.
The single .md file was the one that stung. A record of a problem I had hit months earlier included the real key inside its reproduction steps. My assumption that documentation is not where keys live had become the hole in my own checking.
What those 13 invisible files have in common is simple: the person who wrote them did not think of them as secrets. They were not secret when they were named. They became secret later. Names cannot track that.
The scanner, in a form you can run today
It is written so that no key value is ever printed. The output is paths and counts only.
#!/usr/bin/env bash
# Count the exposure surface of a connected folder. Never prints key values.
set -uo pipefail
ROOT="${1:?usage: scan-exposure.sh <folder>}"
PRUNE=( -name node_modules -o -name .next -o -name .git
-o -name .wrangler -o -name dist -o -name build )
NAME_PAT=( -iname "*token*" -o -iname "*secret*" -o -iname "*credential*"
-o -iname "*.pem" -o -iname "*.key" -o -iname ".env*"
-o -iname "id_rsa*" -o -iname "*api?key*" )
VALUE_RE='ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-ant-[A-Za-z0-9-]{20,}'
VALUE_RE="$VALUE_RE"'|sk_live_[A-Za-z0-9]{20,}|whsec_[A-Za-z0-9]{20,}|AIza[A-Za-z0-9_-]{30,}'
all=$(find "$ROOT" -type f 2>/dev/null | wc -l)
kept=$(find "$ROOT" \( "${PRUNE[@]}" \) -prune -o -type f -print 2>/dev/null | wc -l)
byname=$(find "$ROOT" \( "${PRUNE[@]}" \) -prune -o -type f \( "${NAME_PAT[@]}" \) -print 2>/dev/null | wc -l)
tmp=$(mktemp)
find "$ROOT" \( "${PRUNE[@]}" \) -prune -o -type f -size -2M -print0 2>/dev/null \
| xargs -0 grep -lIE "$VALUE_RE" 2>/dev/null > "$tmp"
printf 'files(all) %s\n' "$all"
printf 'files(scanned) %s\n' "$kept"
printf 'hits(by filename) %s\n' "$byname"
printf 'hits(by content) %s\n' "$(wc -l < "$tmp")"
printf -- '--- by extension ---\n'
sed 's|.*/||; s|^[^.]*$|(no ext)|; s|.*\.|.|' "$tmp" | sort | uniq -c | sort -rn
rm -f "$tmp"Real output from my machine:
files(all) 103267
files(scanned) 14814
hits(by filename) 24
hits(by content) 18
--- by extension ---
10 .sh
3 .txt
1 .md
1 .bak_20260822
1 .bak_20260602b
1 .bak_2026-07-28
1 .bak_20260602
real 0m32.147sThree notes on why it is written this way.
grep -l returns filenames only and never the matching line, so the scan result itself does not become a new leak path. -I skips binaries, which matters a lot in a folder full of image assets. -size -2M keeps large logs and build artifacts out of the comparison; a key note is never two megabytes.
The regex list only covers keys with fixed prefixes. Values without a recognizable prefix will not be caught this way. I use it knowing that limit, as a tool that reliably catches what it can catch.
Thirty-two seconds is light enough to run once a month without thinking about it.
What I changed afterwards
Three things went into my own routine:
- Key notes move outside the connected folder. When the unit of sharing is a whole folder, "no secrets live in this folder" has to be decided first, or the boundary shifts every time.
- No generational backups of key notes. Those three backups were keeping replaced values alive. One copy now, updated in place.
- Scan by content, not by name, once a month. A check with a 79% false positive rate was not worth continuing.
Order matters in the cleanup, and I got it wrong on the first pass. My instinct was to delete the files immediately, which felt satisfying and accomplished very little: the values had already been sitting in a shared folder, so the exposure had happened regardless of whether the file still existed. Rotating the credential is what actually ends it. Deleting first also destroys the evidence of which key was where, which is exactly what you need in order to rotate the right things. So the sequence I use now is rotate, then relocate, then delete — and only then re-run the scan to confirm the count went to zero.
Counting is only the entrance. The next step is making the files unreadable in the first place, and that belongs to configuration. The sandbox.credentials settings that stop credential files from being read at the OS level, along with how to verify they are working, are written up with the implementation in The sandbox can run your code without reading your credentials. Reading it after you have your own counts makes it much clearer which setting removes which files from the list.
The useful part of today was not the number 18. It was that 13 of them were invisible by name — the exact size of the gap between how well I thought I was managing this and how well I actually was.
If you have a connected folder, run the script once. A count of zero is a real basis for confidence. A count above zero is something you can act on today, while it is still cheap.