Where personal data hides in a repository
Ask a team whether their repository contains personal data and the answer is almost always no, said quickly. Ask instead where their test fixtures came from and the answer slows down.
Personal data does not get committed on purpose. It arrives as a side effect of trying to make something realistic, or of debugging something at two in the morning. It then sits there, in a place nobody thinks of as a data store, until a laptop is stolen, a repository is made public, a contractor is onboarded, or an AI coding agent reads the file to answer a question about something else entirely.
That last one changed the exposure. A colleague reads the files they open. An agent reads the files it decides it needs, and you find out which ones afterwards. We wrote about what Claude Code actually sends if you want the specifics for one tool.
Here is where to look.
1. Test fixtures
The most common one, and the most defensible in the moment. A fixture built from a production export has the right shape, the right edge cases, the accented names and the addresses that break the parser. That is exactly why someone made it that way, and why it is real.
2. Seed and migration scripts
A seed file that populates a dev database with "a few real accounts so it looks like something". A data migration written to backfill a column, with a handful of production rows pasted in as a worked example in a comment. Migrations are especially sticky: they are append-only by convention, so nobody ever deletes one.
3. Recorded HTTP traffic
VCR cassettes, snapshot tests, .har files, saved API responses. These are dumps of real requests and responses, complete with the bearer token in the header and the customer object in the body. They are usually large, machine-generated, and never read by a human after the day they were recorded.
4. Logs and error payloads committed for debugging
Someone pastes a stack trace with a full request payload into an issue, then into a file, then commits it because the branch was about fixing that error. Log fixtures used to test a parser are the same thing with a better excuse.
5. Sample uploads
A CSV in fixtures/, a PDF in test/files/, an image in the README. Sample documents are almost always a real document from a real customer, because generating a convincing one is work and the real one is right there.
6. Comments
"Per the call with Maria at Acme, the cutoff is the 15th." "Workaround for ticket 4412, customer's IBAN has a trailing space." Comments carry names, ticket detail and the occasional identifier, and no scanner built for code looks at them closely.
7. Local configuration that escaped
.env is usually ignored. .env.example is usually not, and it is usually populated by copying .env and forgetting one line. The same goes for docker-compose.override.yml, a Postman collection, an editor workspace file with a connection string.
8. Binary and database files
A committed dev.sqlite, a .dump, a spreadsheet used for a one-off calculation. Text search misses them entirely, which is also why nobody has ever audited them.
9. Git history
Deleting the file fixes the working tree, not the repository. The old contents are still in history, still in every clone, and still fetched by CI. This is the one that turns "we removed that years ago" into a false statement.
How to actually look
Start with structure, not with grep:
# Files that were deleted at some point, with their old contents still in history
git log --all --diff-filter=D --name-only --pretty=format:'%h %ad' --date=short
# Everything a text search will never open
git ls-files | grep -Ei '\.(csv|xlsx?|pdf|sqlite3?|db|dump|har|png|jpe?g)$'
# The usual suspects by name
git ls-files | grep -Ei 'fixture|seed|sample|cassette|snapshot|\.env'
Then search content, and be honest about what that finds. Patterns are excellent at structured identifiers: email addresses, IBANs, card numbers, national IDs, API keys. They are useless at the single most common category of personal data, which is a person's name. There is no regular expression for "Maria Öztürk" that does not also match half your variable names.
We measured this on our own benchmark corpus while building the masking path for coding agents: a pattern-only tier misses 37% of the values that must not leak, averaged over nine labelled sets. Not because the patterns are bad, but because names, and the sentences around them, are not patterned. Every result you get from grep is real, and the number you did not get is the one that matters.
What to do about it
In rough order of how well it works:
- Delete what should not be there and stop the source. A fixture generator with fake data is a day of work and it ends the problem permanently.
- If it was ever public or ever on a laptop you no longer control, treat it as disclosed, and handle it as a data incident under your own policy rather than as a cleanup task.
- Rewriting history is possible and expensive.
git filter-reporewrites every commit hash, which breaks open branches, tags, signed commits and anything referencing a SHA. Worth it for a leaked key. Rarely worth it for a fixture. - Assume the rest exists, because the audit above finds what you thought to look for, and put a boundary between it and anything that leaves the machine.
The boundary we build
That last point is what Velum does. It runs on your machine and masks personal data at the moment a file is read by an AI coding agent, so the model works on ⟦PERSON_1⟧ and ⟦IBAN_2⟧ instead of the values. When the agent writes back, the real values go in first, so the file on disk is unchanged. Some files are withheld rather than masked: .env, private keys, certificates. If detection fails, the content is withheld too, because a mask that silently falls back to the real thing is worse than no mask.
Two limits worth stating in the same breath. A prompt you type yourself reaches the model before any file is read, so masking a repository does not protect you from typing a client's name into the chat box. And this is a boundary, not a cleanup: the data is still in the repository afterwards, and points 1 through 3 above are still yours to do.
Detection runs locally. No file content, and no identifier derived from one, is uploaded to us or to anyone else.
Seven-day trial, no key and no account.