Skip to content

Repository files navigation

scrubjay

Find the API keys and personal data in your dataset before you upload it.

Live: https://scrubjay.benrichardson.dev


what it is

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.

how it works

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.

the two spans

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.

why it never parses your JSON

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.parseJSON.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.

why gzip goes through fflate and not DecompressionStream

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.

the recognisers

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 entropy gate, and why it is off

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.

browser APIs used

  • File.stream() + ReadableStream — multi-gigabyte streaming with no upload. .getReader() and a while loop, never for await…of a stream: async iteration of a ReadableStream is 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. No SharedArrayBuffer, which is why this runs on GitHub Pages with no COOP/COEP at all.
  • Web CryptoHMAC-SHA256 for stable pseudonyms, PBKDF2 (600,000 rounds) + AES-GCM-256 for the mapping container, getRandomValues for the project secret and passphrase.
  • File System AccessshowSaveFilePickercreateWritable, 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.

security / privacy model

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.html is 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 localStorage keys, both named in the Privacy modal: scrubjay.theme and scrubjay.keyFp.

Not protected

  • No personal names, no postal addresses. There is no language model here — GLiNER was cut because gliner_small-v2.1 is 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.csv carries 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-7465 does match.
  • frame-ancestors is 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.

stack

  • Vite 7 + vanilla TypeScript (strict), Vitest, vite-plugin-pwa
  • fflate for gzip, hyparquet
    • hyparquet-compressors for 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.

local development

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/ locally

The demo dataset is generated at build time and never committedscripts/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).

license

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.

About

Find the API keys and personal data in your dataset before you upload it — entirely in your browser.

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages