Find the API keys and personal data in your dataset before you upload it.
Live: https://scrubjay.benrichardson.dev
You have a multi-gigabyte .jsonl of support transcripts and you are three hours from uploading it
to a fine-tuning provider. Someone asks "has anyone checked this for keys?" and you have no answer,
because you do not know what a customer pasted into a chat two years ago.
The tools that would tell you are either a Python package you have to install and learn (Presidio, trufflehog, gitleaks) or an enterprise DLP product behind "contact sales" (Nightfall, Skyflow, Private AI). And every hosted scanner has the same absurdity at its heart: uploading the file to find out whether the file is safe to upload is the disclosure you were trying to avoid.
Scrubjay is a browser tab. Drag a file in, and it streams the whole thing through 27 recognisers in a pool of Web Workers on your own machine, then hands back four things:
| Artefact | What it is |
|---|---|
dataset.clean.jsonl |
Personal data replaced with stable pseudonyms, credentials destroyed. Byte-identical everywhere nothing matched. |
findings.csv |
A record number, JSON pointer, rule and redacted context for every hit. Greppable, and safe to open in Excel. |
mapping.enc |
AES-GCM-256 under a passphrase you choose. The only way to reverse a pseudonym. |
report.txt |
The headline, per-rule counts, the run state, and an honest-limits block. |
File.stream()
└─ reader worker ──── fflate Gunzip (or hyparquet row groups) ────┐
│ 4 MiB chunks, cut at the
│ last newline, TRANSFERRED
┌──────────────────────────────────────────────────────────────┘
├─ scanner worker ×N ── prefilter → recognisers → validators → overlap resolution
│ → pseudonyms (HMAC-SHA256) → byte-offset edit plan
└─ main thread ──── ordered reassembly ──── File System Access / Blob
Backpressure is the point of the credit protocol. Each scanner starts with two credits and
returns one per chunk; the reader physically cannot allocate ahead of the pool. Resident memory is
exactly workers × 2 × chunkBytes — 64 MiB on desktop, 6 MiB on a phone — whatever the file size.
Credits are a count, not a recycled buffer. Handing the actual ArrayBuffer back is the textbook
design and it collides with pass 2: when a chunk has no findings the splicer returns the same
buffer, so it cannot simultaneously be the output and the credit.
Two passes, deliberately. Pass 1 finds; pass 2 rewrites. The gap between them is where you get to change your mind, and it is why triage can start while the scan is still running.
Every pattern carries the d flag, and every finding carries two independent spans:
matchStart/matchEnd— the whole match. Overlap resolution uses this.redactStart/redactEnd— the capture group. Redaction uses this.
They differ on five rules and conflating them is how you break things. Redacting match[0] on
url-embedded-credential would destroy the scheme, username and host of a DSN; on aba-routing it
would delete the words "routing number" from the corpus. And a pem-private-key rule that matches
only the -----BEGIN EC PRIVATE KEY----- header redacts thirty characters and leaves the entire key
body in the file you are calling clean — the single worst failure this product could have. There is
a test asserting that span is longer than 78 characters.
Reproduced verbatim during design:
in : {"id":12345678901234567890,"score":1.0,"e":1e5,"dup":{"a":1,"a":2}}
out: {"id":12345678901234567000,"score":1,"e":100000,"dup":{"a":2}}
JSON.parse → JSON.stringify truncates big integers, collapses 1.0 to 1, normalises
exponents and drops duplicate keys. TextDecoder is lossy in the other direction: it defaults to
fatal: false and substitutes U+FFFD, so sixteen bytes of invalid UTF-8 come back as twenty-four
different bytes — and real multi-gigabyte exports contain truncated writes.
So Scrubjay decodes a copy to find things, and splices the original bytes. spliceBytes(raw, []) returns the same object, zero copies. Everywhere nothing matched, the output is byte-identical.
This costs a measured 2.26× on inflate, and is worth it. cat shard-00.gz shard-01.gz > all.gz is
routine in sharded ML pipelines and produces a legal multi-member gzip stream; browser behaviour on
multi-member input could not be verified; and the obvious sanity check, the gzip ISIZE trailer, is
unsound on it — with equal-sized shards a truncated read passes. Silently inflating the first
member, scanning half the data and reporting "clean" is the worst thing this tool could do.
27 on by default, out of 29. Every count in the interface is computed from the registry at module load; a hand-typed count is a safety claim the user acts on by uploading four gigabytes.
- 4 checksum-validated — Luhn, IBAN mod-97, ABA 3-7-1, and structural exclusion for US SSNs.
- 19 published provider prefixes — AWS, GitHub (classic and fine-grained), Anthropic, OpenAI, Slack (tokens and webhooks), Stripe, Google, Hugging Face, GitLab, npm, SendGrid, Mailchimp, JWT, GCP service-account keys, and PEM private-key blocks (complete and truncated).
- 4 structural — email, E.164 phone, credentials inside a URL, and secrets assigned to a keyword.
- 2 heuristics, both shipped OFF — public IPv4, and length-normalised entropy.
The bar is precision, not recall. A scanner that cries wolf gets turned off, and a scanner that gets
turned off protects nobody. CONTRIBUTING.md states what a new rule has to clear.
The obvious design — Shannon entropy ≥ 3.5 bits/char over 20+ characters — is broken in both directions, and only measurement shows it:
| token | H (bits/char) | at a 3.5 gate |
|---|---|---|
thequickbrownfoxjumpsoverthelazydogagain |
4.53 | flagged (it is English) |
| a 40-character git SHA-1 | 3.91 | flagged |
| uniform random 20-char hex, mean of 2,000 | 3.36 | missed (it is a real secret) |
Maximum achievable entropy at length L is log2(L) — 4.32 at twenty characters — so a constant
bits/char threshold is length-coupled by construction. What ships instead is length-normalised
(η = H / log2(alphabet), threshold 0.92), behind an unbounded candidate tokeniser, ten structural
suppressors and a two-character-class requirement. It is still the noisiest thing here, so it is
still off.
File.stream()+ReadableStream— multi-gigabyte streaming with no upload..getReader()and awhileloop, neverfor await…ofa stream: async iteration of aReadableStreamis Safari 27, which is essentially no iPhone in the field.- Web Workers (module) +
MessageChannel— a reader worker talks to N scanners over private ports, so the main thread never sees a byte of the hot path. - Transferable
ArrayBuffer— zero-copy chunk hand-off. NoSharedArrayBuffer, which is why this runs on GitHub Pages with no COOP/COEP at all. - Web Crypto —
HMAC-SHA256for stable pseudonyms,PBKDF2(600,000 rounds) +AES-GCM-256for the mapping container,getRandomValuesfor the project secret and passphrase. - File System Access —
showSaveFilePicker→createWritable, where the awaited write is the backpressure.mode: 'exclusive'is deliberately not used: it is Chrome 121+ only and writes in place, so a tab crash leaves a truncated file that looks complete. CompressionStream— optional gzip output.- Service Worker (vite-plugin-pwa) — installable, offline app shell plus the precached sample.
Protected
- Every byte is read, scanned, redacted and written inside the tab. There is no endpoint in this
application that receives your data, and the CSP in
index.htmlis the claim that carries the load — it is two lines you can read. - No key is ever tested against a live API. Verifying a key means transmitting it, which is exactly the disclosure this tool exists to prevent. That is not a setting.
- Two
localStoragekeys, both named in the Privacy modal:scrubjay.themeandscrubjay.keyFp.
Not protected
- No personal names, no postal addresses. There is no language model here — GLiNER was cut
because
gliner_small-v2.1is 183,403,734 B plus an 8,657,198 B tokenizer, out of reach on a 4 GB phone. This is the first limit stated in the UI, not a footnote. - Pseudonymisation is not anonymisation. The output remains personal data under GDPR Art. 4(5).
- Your project key is as sensitive as the mapping. Anyone holding it can match a candidate list against every pseudonym essentially instantly.
findings.csvcarries no secret values, but record numbers, offsets and paths let anyone who also has the original file locate every secret.- A checksum rules out a typo, not a coincidence: ~1 in 10 random nine-digit strings passes the ABA
check (asserted in the test suite, because the UI prints that number), and US SSNs have had no
checksum since June 2011 — so an ordinary order number like
192-83-7465does match. frame-ancestorsis ignored in a<meta>CSP and GitHub Pages cannot set headers, so Scrubjay makes no anti-framing claim; it refuses to render in a frame in JavaScript instead, which is weaker and is said plainly.- A clean report means nothing matched. It does not mean the file is clean.
Trust model
- The static bundle served by GitHub Pages, and the TLS chain to it.
- A Cloudflare Web Analytics beacon (anonymous page views, no cookies, no fingerprinting).
- The hosted feedback widget, which sends nothing unless you open it and press Send.
- Vite 7 + vanilla TypeScript (strict), Vitest,
vite-plugin-pwa - fflate for gzip, hyparquet
hyparquet-compressorsfor Parquet
- GitHub Pages, deployed via GitHub Actions
Everything else — the recognisers, the validators, the position-preserving JSON scanner, the byte splicer, the overlap resolver, the Crockford codec, the pseudonym derivation and the encrypted container — is first-party and unit-tested. No cookies, no fingerprinting, no third-party fonts.
npm install
npm run dev # vite dev server on :5173
npm test # 224 unit tests, including the precision gate
npm run build # regenerates the sample, then tsc + vite build
npm run preview # serve dist/ locallyThe demo dataset is generated at build time and never committed — scripts/make-sample.mjs
writes public/samples/support-transcripts.jsonl (2,000 synthetic rows, 14 planted credentials, 30
deliberate near-misses) and public/samples/ is gitignored. A repo file containing a real-shaped
ghp_… or AKIA… token trips GitHub push protection and provider auto-revocation scanners.
tests/precision.test.ts is the gate: all 14 plants must be found with the right rule, all 30
near-misses must stay silent, the scrubbed output must contain no planted literal, every line must
still parse as JSON, and scrub(scrub(x)) must equal scrub(x).
GNU Affero General Public License v3.0 or later, with an attribution requirement added under section 7(b) — see ADDITIONAL-TERMS.md.
In short: you may run, modify, redistribute and even sell this, but if you distribute it — or run a modified version where other people can reach it — you have to publish your source under the same licence and keep the attribution. A separate commercial licence without those obligations is available on request: hi@ben.gy.
The files Scrubjay produces from your data are yours. Nothing about the AGPL reaches
dataset.clean.jsonl, findings.csv or mapping.enc.
Third-party components keep their own licences — see THIRD-PARTY-NOTICES.md.