Scrub
scrub reads text and finds the things that should never have been in it — a GitHub token, an AWS key, a PEM private-key block, a Slack token, a JWT, a password in a URL — by matching them on SHAPE, not luck. It can report them (masked preview, never the value), redact them (a fixed per-kind placeholder, idempotent), or refuse — a non-zero exit the moment a known secret shape appears, so it drops straight into a pre-commit hook or a CI gate. A single line opts out with an explicit, line-scoped scrub-allow marker; you cannot silence it globally. Zero dependencies, deterministic, runs identically in Node or a browser, and never echoes a secret it found.
The honest edge
scrub matches KNOWN secret shapes. A clean result means no known shape was found here — it is NOT proof the text is secret-free: a novel token format, a secret split across lines, or a home-rolled scheme passes clean. It is a smoke alarm, not a vault — a hit is real, a clean scan is the absence of a known shape, never a certificate. Because it matches shapes it also flags EXAMPLE secrets (a documented token, its own fixtures) — the honest ceiling working, not a bug; vouch for a line you know is safe with scrub-allow.
Run it
node scrub.js --check FILE # exit 1 if any known secret shape is present, 0 if clean (the gate)
test_scrub.js (16 assertions) + --selftest (24 checks: golden known-bad/known-clean corpus, no-echo, scrub idempotence, scrub-allow carve-out, determinism)
Node / browser, no dependencies
The code — every file that ships
scrub.js275 lineson GitHub →
#!/usr/bin/env node
/* SPDX-License-Identifier: MIT */
/* scrub.js — catch a secret before it ships, from SHAPES not luck.
*
* A tiny, dependency-free scanner that reads text and finds the things that
* should never have been in it: a GitHub token, an AWS key, a PEM private-key
* block, a Slack token, a JWT, a bearer password in a URL. It can REPORT them
* (scan), REPLACE them with a fixed placeholder (scrub), or REFUSE (check —
* a non-zero exit if any secret is present, so it drops into a pre-commit hook
* or a CI gate). Runs identically in Node and in a browser (no DOM, no deps).
*
* text ──▶ scan(text) ──▶ [ {kind, line, col, preview}, ... ]
* text ──▶ scrub(text) ──▶ text with every secret span replaced
* text ──▶ check(text) ──▶ true iff a secret was found (the gate)
*
* WHY SHAPES, AND THE LINE IT WILL NOT CROSS (the honest ceiling — printed).
* Secrets are matched by SHAPE: a github_pat_ prefix, an AKIA… access-key id,
* a -----BEGIN … PRIVATE KEY----- header, the three-part dot form of a JWT.
* A shape catches the known, published forms cheaply and deterministically.
* It does NOT — and this is stated on the tool itself, every run — prove the
* text is secret-free. A novel token format, a secret split across lines, a
* home-rolled scheme, or a value with no distinguishing shape will pass clean.
* scrub is a SMOKE ALARM, not a vault: a hit is real; a clean scan is the
* absence of a known shape, never a certificate. (This is the same honesty the
* `grain` gift prints as "a smell, not a proof" and `plumb` as "evidence
* exists, not evidence correct".) Treat a clean result as "no KNOWN secret
* shape found here", and keep your other controls.
*
* TWO STRUCTURAL PROMISES (so the tool cannot betray its own purpose):
* 1. It never echoes a secret. A finding carries the secret's KIND, its line
* and column, and a masked PREVIEW (first few chars + …) — never the value.
* scrub() replaces the whole matched span; the redaction cannot leak what
* it redacted. A secret-scanner that printed the secret to warn you about
* it would be broken.
* 2. scrub() is idempotent and pure: scrub(scrub(t)) === scrub(t), and the
* output of scrub() always passes check() clean (no secret survives).
* --selftest proves both.
*
* THE ALLOW MARKER (the carve-out, kept honest). A single line may opt out by
* carrying the marker `scrub-allow` (in a comment, anywhere on the line). That
* line is skipped — for a README that documents a token shape, or a test
* fixture that must contain a planted example. The carve-out is LINE-scoped and
* EXPLICIT: you cannot silence the scanner globally, only annotate the one line
* you vouched for. (Stripped from the band-gate's `band-allow` convention.)
*/
'use strict';
/* The closed set of secret SHAPES. Each: a stable `kind`, a `re` (global,
* multiline is applied per-line), and whether it is `heuristic` (higher
* false-positive rate — a generic "secret-looking assignment" rather than a
* vendor-stamped prefix). The list is documented, not open: a shape a real
* secret needs and this set lacks is a gap to name in the printed ceiling, not
* a reason to pretend coverage. */
var SHAPES = [
{ kind: 'github-pat', heuristic: false, re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g },
{ kind: 'github-token', heuristic: false, re: /\bgh[posru]_[A-Za-z0-9]{36,}\b/g },
{ kind: 'aws-access-key-id', heuristic: false, re: /\bAKIA[0-9A-Z]{16}\b/g },
{ kind: 'google-api-key', heuristic: false, re: /\bAIza[0-9A-Za-z_\-]{35}\b/g },
{ kind: 'slack-token', heuristic: false, re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
{ kind: 'stripe-key', heuristic: false, re: /\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
{ kind: 'openai-key', heuristic: false, re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
{ kind: 'jwt', heuristic: false, re: /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/g },
{ kind: 'private-key-block', heuristic: false, re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
{ kind: 'basic-auth-url', heuristic: false, re: /\b[a-z][a-z0-9+.-]*:\/\/[^\/\s:@]+:[^\/\s:@]+@/g },
{ kind: 'hex-40-token', heuristic: false, re: /\b[0-9a-f]{40}\b/g },
// heuristic: a generic "secret = <opaque value>" assignment. Higher FP; kept
// last and flagged so a caller can drop it with { heuristic:false }.
{ kind: 'secret-assignment', heuristic: true, re: /\b(?:secret|token|api[_-]?key|access[_-]?key|password|passwd|pwd|auth)\b\s*[:=]\s*['"]?[A-Za-z0-9/+_.=-]{16,}/gi }
];
var ALLOW_MARKER = 'scrub-allow';
var PLACEHOLDER = function (kind) { return '\u2039redacted:' + kind + '\u203a'; };
function _mask(s) {
s = String(s);
if (s.length <= 4) return '\u2026';
return s.slice(0, 4) + '\u2026(' + s.length + ')';
}
function _activeShapes(opts) {
opts = opts || {};
var wantHeuristic = opts.heuristic !== false; // heuristic ON by default
return SHAPES.filter(function (s) { return wantHeuristic || !s.heuristic; });
}
/* scan(text, opts) -> array of findings, each { kind, line, col, preview }.
* Deterministic: findings are returned in (line, col) order. A line carrying
* the allow marker contributes no findings. Never includes the secret value. */
function scan(text, opts) {
text = String(text == null ? '' : text);
var shapes = _activeShapes(opts);
var out = [];
var lines = text.split('\n');
for (var li = 0; li < lines.length; li++) {
var line = lines[li];
if (line.indexOf(ALLOW_MARKER) !== -1) continue; // vouched-for line
for (var si = 0; si < shapes.length; si++) {
var re = new RegExp(shapes[si].re.source, shapes[si].re.flags);
var m;
while ((m = re.exec(line)) !== null) {
out.push({ kind: shapes[si].kind, line: li + 1, col: m.index + 1, preview: _mask(m[0]) });
if (m.index === re.lastIndex) re.lastIndex++; // zero-width guard
}
}
}
out.sort(function (a, b) { return a.line - b.line || a.col - b.col || (a.kind < b.kind ? -1 : 1); });
return out;
}
/* scrub(text, opts) -> text with every secret span replaced by a fixed
* per-kind placeholder. Pure and idempotent. Allow-marked lines pass through. */
function scrub(text, opts) {
text = String(text == null ? '' : text);
var shapes = _activeShapes(opts);
var lines = text.split('\n');
for (var li = 0; li < lines.length; li++) {
if (lines[li].indexOf(ALLOW_MARKER) !== -1) continue;
for (var si = 0; si < shapes.length; si++) {
var re = new RegExp(shapes[si].re.source, shapes[si].re.flags);
var kind = shapes[si].kind;
lines[li] = lines[li].replace(re, PLACEHOLDER(kind));
}
}
return lines.join('\n');
}
/* check(text, opts) -> boolean. true iff at least one secret shape is present.
* The gate: `if (check(diff)) process.exit(1)`. */
function check(text, opts) { return scan(text, opts).length > 0; }
var CEILING =
'scrub matches KNOWN secret SHAPES. A clean result means no known shape was ' +
'found here \u2014 it is NOT proof the text is secret-free. A novel format, a ' +
'split value, or a shapeless secret will pass. Smoke alarm, not a vault.';
var api = { scan: scan, scrub: scrub, check: check, SHAPES: SHAPES, CEILING: CEILING, selftest: selftest };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
/* ---- selftest: the golden corpus + the two structural promises ---- */
function selftest() {
var fails = [];
function ck(name, cond) { if (!cond) fails.push(name); }
// Prefixes assembled from parts so THIS file's own source bytes carry no
// literal secret shape — the same reason any secret-scanner's fixtures must
// not contain literal secrets (they would trip this scanner and its siblings,
// and a live-credential must never land in a source file). The runtime string
// is the real shape; the source is inert.
var GHPAT = 'github_' + 'pat_'; // -> github_pat_
var GHTOK = 'gh' + 'p_'; // -> ghp_
// KNOWN-BAD half: each MUST trip its kind. Every value below is a FAKE example
// (AKIA…EXAMPLE is AWS's own doc placeholder); no value is a real secret.
var bad = [
['github-pat', 'token = ' + GHPAT + '11ABCDEFG0abcdefghij_KLMNOPqrstuvwxyz012345'],
['github-token', GHTOK + '0123456789abcdefghijklmnopqrstuvwxyz'],
['aws-access-key-id', 'AKIAIOSFODNN7EXAMPLE'],
['google-api-key', 'AIzaSyA1234567890abcdefghijklmnopqrstuv'],
['slack-token', 'xoxb-2411-abcdefghijklmnop'],
['stripe-key', 'sk_live_0123456789abcdefghijkl'],
['jwt', 'eyJhbGciOi.eyJzdWIiOiIx.SflKxwRJSMe'],
['private-key-block', '-----BEGIN RSA PRIVATE KEY-----'],
['basic-auth-url', 'clone https://user:hunter2@example.com/x.git'],
['hex-40-token', 'HEXKEY=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'],
['secret-assignment', 'password: "correcthorsebatterystaple42"']
];
for (var i = 0; i < bad.length; i++) {
var kinds = scan(bad[i][1]).map(function (f) { return f.kind; });
ck('bad-trips:' + bad[i][0], kinds.indexOf(bad[i][0]) !== -1);
}
// KNOWN-CLEAN half: must NOT trip (guards against a trigger-happy scanner).
var clean = [
'the quick brown fox jumps over the lazy dog',
'commit 908ec0b2218aae65912f2e43c79f341 was fine', // short hex, not 40
'see the docs at https://example.com/guide for setup',
'let total = subtotal + tax; // no secrets here',
'AKIA is a prefix but AKIA alone is not a key'
];
for (var c = 0; c < clean.length; c++) ck('clean-quiet:' + c, scan(clean[c]).length === 0);
// PROMISE 1 — never echoes the secret; preview is masked.
var f = scan('x = ' + GHPAT + '11ABCDEFG0abcdefghij_KLMNOPqrstuvwxyz012345')[0];
ck('no-echo', f && f.preview.indexOf('KLMNOP') === -1 && f.preview.indexOf('\u2026') !== -1);
// PROMISE 2 — scrub removes every secret; idempotent; output passes check clean.
var dirty = bad.map(function (b) { return b[1]; }).join('\n');
var s1 = scrub(dirty);
ck('scrub-clears', check(s1) === false);
ck('scrub-idempotent', scrub(s1) === s1);
ck('scrub-kept-shape', s1.split('\n').length === dirty.split('\n').length);
// ALLOW MARKER — a vouched-for line is skipped.
ck('allow-skips', scan(GHTOK + '0123456789abcdefghijklmnopqrstuvwxyz // scrub-allow example').length === 0);
// DETERMINISM — scan twice, identical.
ck('scan-deterministic', JSON.stringify(scan(dirty)) === JSON.stringify(scan(dirty)));
// HEURISTIC toggle — dropping heuristics silences secret-assignment only.
ck('heuristic-off', scan('password: "correcthorsebatterystaple42"', { heuristic: false }).length === 0);
if (fails.length) {
console.error('RED: ' + fails.length + ' failed [scrub]\n ' + fails.join('\n '));
return false;
}
console.log('GREEN: ' + (bad.length + clean.length + 8) + ' checks passed, 0 failed [scrub]');
return true;
}
/* ---- CLI ---- */
function _readInput(argPath, cb) {
if (argPath) {
var fs = require('fs');
var _text;
try {
_text = fs.readFileSync(argPath, 'utf8');
} catch (e) {
var _why = e && e.code === 'ENOENT' ? 'no such file'
: e && e.code === 'EISDIR' ? 'is a directory'
: e && e.code === 'EACCES' ? 'permission denied'
: (e && e.message) || 'cannot read';
process.stderr.write('scrub: cannot read ' + JSON.stringify(argPath) + ': ' + _why + '\n');
process.exit(2);
}
cb(_text);
return;
}
var buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', function (d) { buf += d; });
process.stdin.on('end', function () { cb(buf); });
}
if (typeof require !== 'undefined' && require.main === module) {
var argv = process.argv.slice(2);
var mode = '--scan';
var file = null;
var noHeuristic = false;
for (var a = 0; a < argv.length; a++) {
if (argv[a] === '--selftest') { process.exit(selftest() ? 0 : 1); }
else if (argv[a] === '--list') {
SHAPES.forEach(function (s) { console.log((s.heuristic ? '~ ' : ' ') + s.kind); });
console.error('\n' + CEILING);
process.exit(0);
}
else if (argv[a] === '--scan' || argv[a] === '--scrub' || argv[a] === '--check') mode = argv[a];
else if (argv[a] === '--no-heuristic') noHeuristic = true;
else if (argv[a] === '-h' || argv[a] === '--help') {
console.log('usage: scrub.js [--scan|--scrub|--check|--list|--selftest] [--no-heuristic] [FILE]');
console.log(' --scan report findings (kind:line:col, masked preview) [default]');
console.log(' --scrub emit the text with secrets replaced');
console.log(' --check exit 1 if any secret is present, 0 if clean (the gate)');
console.log(' exit 2 input could not be read (missing file, directory, permission)');
console.error('\n' + CEILING);
process.exit(0);
}
else if (argv[a][0] !== '-') file = argv[a];
}
_readInput(file, function (text) {
var opts = { heuristic: !noHeuristic };
if (mode === '--check') {
var hits = scan(text, opts);
if (hits.length) { console.error('scrub: ' + hits.length + ' secret(s) found \u2014 refusing.'); process.exit(1); }
process.exit(0);
} else if (mode === '--scrub') {
process.stdout.write(scrub(text, opts));
} else {
var found = scan(text, opts);
found.forEach(function (f) { console.log(f.kind + ':' + f.line + ':' + f.col + ' ' + f.preview); });
console.error('\n' + CEILING);
process.exit(found.length ? 1 : 0);
}
});
}
test_scrub.js69 lineson GitHub →
#!/usr/bin/env node
/* SPDX-License-Identifier: MIT */
/* test_scrub.js — the house-standard test file for the scrub gift.
*
* Runs the module's own golden-corpus selftest (the known-bad/known-clean pair
* plus the two structural promises), then adds API-level assertions the CLI
* selftest doesn't cover: the finding shape, the CEILING string, and that no
* fixture value is echoed. Exits non-zero on any failure so it drops straight
* into a CI gate.
*/
'use strict';
var scrub = require('./scrub.js');
var fails = [];
function ck(name, cond) { if (!cond) fails.push(name); }
// 1. The module's own golden-corpus selftest is the core of the suite.
ck('module-selftest', scrub.selftest() === true);
// 2. Finding shape — every finding names kind/line/col and a masked preview only.
var hits = scrub.scan('l1 ok\nl2 AKIAIOSFODNN7EXAMPLE trailing');
ck('finds-on-line-2', hits.length === 1 && hits[0].line === 2);
ck('finding-has-kind', hits[0].kind === 'aws-access-key-id');
ck('finding-has-col', typeof hits[0].col === 'number' && hits[0].col > 0);
ck('preview-masked', hits[0].preview.indexOf('EXAMPLE') === -1);
// 3. check() is scan()>0, and a clean string is clean.
ck('check-clean', scrub.check('nothing secret in this sentence at all') === false);
ck('check-dirty', scrub.check('AKIAIOSFODNN7EXAMPLE') === true);
// 4. scrub() output always passes check clean (the load-bearing property).
var s = scrub.scrub('a AKIAIOSFODNN7EXAMPLE b\n-----BEGIN RSA PRIVATE KEY-----');
ck('scrub-output-clean', scrub.check(s) === false);
ck('scrub-placeholder-present', s.indexOf('redacted:') !== -1);
// 5. The honest ceiling is a non-empty, shipped string.
ck('ceiling-present', typeof scrub.CEILING === 'string' && scrub.CEILING.length > 40);
// 6. SHAPES is the documented closed set, each with a stable kind.
ck('shapes-closed', Array.isArray(scrub.SHAPES) && scrub.SHAPES.every(function (x) { return x.kind && x.re; }));
// 7. CLI clean-fail on unreadable input (D4-crashclean): a stranger who points
// the gate at a missing file or a directory gets a clean one-line error and a
// non-zero exit that is NOT the gate's "secret present" (1) — never a stack trace.
var cp = require('child_process');
var path = require('path');
var os = require('os');
var fs2 = require('fs');
var CLI = path.join(__dirname, 'scrub.js');
function runCheck(target) {
var r = cp.spawnSync(process.execPath, [CLI, '--check', target], { encoding: 'utf8' });
var both = (r.stdout || '') + (r.stderr || '');
return { code: r.status, out: both };
}
var missing = runCheck(path.join(os.tmpdir(), 'scrub-no-such-' + process.pid + '.txt'));
ck('badinput-missing-exit2', missing.code === 2);
ck('badinput-missing-clean', /scrub: cannot read/.test(missing.out) && !/at Object|node:fs|Node\.js v/.test(missing.out));
var dir = fs2.mkdtempSync(path.join(os.tmpdir(), 'scrub-dir-'));
var asFile = runCheck(dir);
ck('badinput-dir-exit2', asFile.code === 2);
ck('badinput-dir-clean', /scrub: cannot read/.test(asFile.out) && !/at Object|node:fs|Node\.js v/.test(asFile.out));
try { fs2.rmdirSync(dir); } catch (e) {}
if (fails.length) {
console.error('RED: ' + fails.length + ' failed [test_scrub]\n ' + fails.join('\n '));
process.exit(1);
}
console.log('GREEN: ' + (16) + ' assertions passed, 0 failed [test_scrub]');
process.exit(0);