A small Windows (WPF) tool that sweeps a codebase for specific strings that shouldn't be there — internal names, customer domains, sample keys, private markers — the kind of thing that quietly ends up in otherwise-generic code, comments, and test fixtures.
It finds and surfaces; it doesn't judge or fix. Every hit is a candidate for a human to review, shown with its file, its line, and whether it sits in source vs. test and code vs. comment. You decide what to do with it.
The list of things to look for lives in tokens.json and is entirely yours to
define — plain strings, case-sensitive matches, or regex.
Dogfooding: the scanner run against its own repository. Every hit is the literal string "leak" in its own code and docs — and nothing else, because its own private token list and excludes keep it out of the results.
Token Leak Scanner is an aid for a human review, not a guarantee. It searches for exactly the strings you define, in exactly the file types you configure — nothing more.
- You define what it looks for. It finds the tokens listed in
tokens.json; it has no built-in knowledge of what's sensitive. A string you didn't add is a string it won't find. - You define where it looks. It scans the extensions in
includeExtensionsand skipsexcludeDirectoriesandexcludeFiles. Anything outside that scope is not examined. - Classification is a heuristic. Source/Test and Code/Comment tagging is a fast approximation, not a full parser, and can occasionally mislabel a hit.
- Your token list is your responsibility to keep private. Sensitive tokens belong
in
tokens.private.json(git-ignored), not the committedtokens.json. The tool loads and saves to the private file when it exists, but it can't guarantee your repo actually ignores it — confirm*.private.jsonis in your.gitignoreand not already tracked from an earlier commit. - A clean result is not proof of anything. "No findings" means "none of your tokens matched in your configured scope" — it does not mean the codebase is free of sensitive strings.
Treat every run as a prompt to review, and treat your tokens.json and scan scope as
the thing that determines how much it can help. The responsibility for what ships stays
with you.
Grab the latest TokenLeakScanner.exe from the
Releases page and run it — no install needed.
The build is self-contained (the .NET runtime and WPF are bundled into the exe), so it runs on a clean 64-bit Windows machine with nothing installed. Every release is provenance-attested and ships with a SHA-256 checksum, so you can verify the download came from this repository and wasn't tampered with:
gh attestation verify TokenLeakScanner.exe --repo sascha-codeforfun/TokenLeakScannerOn first launch Windows SmartScreen may warn about an unsigned app — choose More info → Run anyway. That's expected for an unsigned binary; the attestation above is the stronger, verifiable trust signal.
Requirements to run: 64-bit Windows. Nothing else — the runtime is bundled.
- Scan folder — pick the repo or folder to sweep.
- Tokens file — defaults to the
tokens.jsonnext to the exe; Browse to point at your own. (If atokens.private.jsonsits beside it, that's loaded instead — see Private token lists.) - Scan — results stream into the grid live as they're found. A scan runs in the background and can be stopped at any time with Cancel (findings collected so far are kept).
- Filter (free text) matches against the token, the file path, and the matched line; Show narrows by category — All, Source only, Test only, Comments only, or Code only.
- Double-click a finding to open that file at the exact line in Visual Studio (see Jumping to a finding).
- Export CSV saves whatever is currently visible, filters applied (see Exporting results).
The status bar shows how the current token list was loaded (including
(private override) when the private file is active) and a live count:
N shown / M total • K tokens loaded.
Two ways to change what the scanner looks for, both of which write straight back to the currently active tokens file (so if a private override is loaded, edits go to the private file — never the committed one):
- Add token row — type a value (optionally a description, and tick Case-sensitive or Regex), click Add & Save, and it's appended and saved immediately.
- Edit tokens dialog — a full grid for bulk editing:
- Add and delete rows (multi-select delete supported).
- Edit each token's value, description, case-sensitivity, and regex flag inline.
- A separate box for excluded-file glob patterns (one per line) that maps to
excludeFiles. - Regex patterns are validated before saving — an invalid pattern is flagged and the dialog stays open so you can fix it, so one typo can't quietly break every scan.
- Changes are made on a working copy: Cancel discards everything, and only Save commits.
The app reads and writes this file itself (camelCase keys, indented, null fields omitted), so anything you set via Add token or Edit tokens ends up here in the same shape:
{
"tokens": [
{ "value": "AcmeCorp", "description": "why it shouldn't leak" },
{ "value": "TODO-INTERNAL", "caseSensitive": true },
{ "value": "sk-[A-Za-z0-9]{16,}", "isRegex": true }
],
"includeExtensions": [".cs", ".xaml", ".razor", ".ts", ".js", ".json", ".xml", ".config", ".txt", ".md"],
"excludeDirectories": ["bin", "obj", ".git", ".vs", "node_modules", "packages"],
"excludeFiles": ["*-private.json", "*.secrets.*", "src/legacy/*.cs"],
"testPathMarkers": ["test", "tests", "fixture", "fixtures", "spec", "specs"]
}| Field | Purpose |
|---|---|
tokens |
The strings to search for. Each has a value; caseSensitive (default false) and isRegex (default false) are optional per token, and description is a free-text note. When isRegex is true, value is a .NET regular expression. |
includeExtensions |
Only files with these extensions are scanned. Default: .cs .xaml .razor .ts .js .json .xml .config .txt .md. |
excludeDirectories |
Directory names skipped entirely (case-insensitive). Default: bin obj .git .vs node_modules packages. |
excludeFiles |
Glob patterns for individual files to skip (case-insensitive, * and ? supported). Each pattern is tested against both the file name and the path relative to the scan root — so *-private.json, *.secrets.*, and src/legacy/*.cs all work. Also editable in the Edit tokens dialog. Default: none. |
testPathMarkers |
Path/name fragments that mark a file as a test (see classification below). Default: test tests fixture fixtures spec specs. |
Everything except tokens is optional and falls back to the defaults shown above if omitted.
Your token list itself can be sensitive — it may name the very internal things you're watching for. So the tool keeps a public list and a private one separate:
- Commit the shared
tokens.json. - Keep your own local list in
tokens.private.jsonnext to it.
If tokens.private.json exists, the app loads it instead of tokens.json
automatically — on startup, and whenever you Browse or Reload to a tokens.json that
has a private sibling. Browsing directly to a *.private.json file uses it as-is. The
status bar shows (private override) while it's active, and every edit (Add & Save
/ Edit tokens) is written back to the private file, so your private tokens never touch
the repo.
tokens.private.json (and any *.private.json) is listed in .gitignore, and the
build copies it next to the exe when present. To point somewhere else entirely, just
Browse to any .json file.
Each hit is tagged two ways, combined into the Category shown in the grid as
Source / Code, Source / Comment, Test / Code, or Test / Comment:
- Source vs. Test — a file is treated as a test if any path segment matches a
testPathMarkersentry (exactly, or as.marker/markers/.marker.), or the file name ends intest/testsor containsfixture/spec. Default markers:test,tests,fixture,fixtures,spec,specs. - Code vs. Comment — a per-line C-style comment detector marks which characters sit
inside a comment (tracking multi-line
/* ... */blocks and ignoring////*that appear inside double-quoted strings). A hit is classed Comment when at least half of its characters fall in a comment region, otherwise Code.
That comment detector is a fast heuristic, not a full C# lexer: multi-line verbatim
strings (@"...") and char literals can occasionally be misclassified. That's fine for
a leak sweep, where the goal is to surface candidates for a human to review.
A few behaviours worth knowing:
- Streaming and cancelable. Files are walked iteratively (skipping
excludeDirectoriesand permission-denied folders without failing); each hit is reported the moment it's found, and Cancel stops the walk between files. - Unreadable files are skipped. Binary, locked, or otherwise unreadable files that happen to match an included extension are silently passed over rather than aborting the scan.
- Matching. Literal tokens use an ordinal search (case-insensitive unless
caseSensitiveis set); regex tokens compile with culture-invariant options. All occurrences on a line are reported, each with its column. - A bad regex doesn't kill the run. An invalid regex token is skipped with a status warning and the rest of the scan continues (the Edit tokens dialog also validates regexes before saving, so this is a backstop).
- Context is trimmed. The stored context line is trimmed and capped at 200 characters (with an ellipsis) — enough to identify the hit without dragging whole minified lines into the grid or CSV.
excludeFilesglobs are whole-string anchored. A pattern must match the entire file name or the entire scan-root-relative path (with/separators), which is why*-private.jsonworks but a bareprivatewould not.
Export CSV writes whatever is currently visible in the grid — filters and category selection are respected, so you can export just the subset you care about. The file is UTF-8 with proper quoting (values containing commas, quotes, or newlines are escaped), so it opens cleanly in a spreadsheet. Columns:
Category, Token, Description, Line, Column, RelativePath, Context
Double-clicking a finding tries three things in order:
- A running Visual Studio instance whose open solution owns the file — it activates that instance, opens the file, and jumps to the exact line (via COM/DTE automation, with an OLE message filter so the call retries instead of failing when VS is busy).
devenv /edit— if no suitable running instance is found, it asks Visual Studio to open the file (without the line jump).- The OS default editor — as a last resort, it opens the file with its default handler.
The status bar reports which path was taken. This is Windows/Visual Studio specific; outside that setup you'll land on step 3.
Requirements: the .NET 10 SDK on Windows
(WPF only builds and runs on Windows), and — if you use the IDE — a Visual Studio
version with .NET 10 support (Visual Studio 2022 17.14+ or Visual Studio 2026, which
also handle the .slnx solution format).
cd TokenLeakScanner
dotnet run # build and launch
dotnet build -c Release # release buildOr open TokenLeakScanner.slnx in Visual Studio and press F5.
To produce the self-contained single-file exe locally:
dotnet publish TokenLeakScanner.csproj -c Release -r win-x64 --self-contained true ^
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true ^
-p:EnableCompressionInSingleFile=trueReleases are built automatically by the GitHub Actions workflow in
.github/workflows/main.yml when a release is published.
- More languages / comment styles — swap the logic in
Services/CodeScanner.BuildCommentMask. - Whole-word matching — wrap literal tokens as regex with
\b...\b, or setisRegex: true. - CI use — the scanning logic in
CodeScannerhas no WPF dependency, so it can be lifted into a console tool that fails a build when matches are found.
Released under the MIT License — see LICENSE.txt.
