@frmwrkd/snag-redact
v0.1.3
Published
PII and secret scrubbing for Snag captures.
Readme
@frmwrkd/snag-redact
PII and secret scrubbing. Runs in the page before anything leaves it, and again in the daemon.
Licence: Apache-2.0
Spec: docs/02-BACKEND-SPEC.md §4.6. Do not relitigate locked decisions in docs/00-MASTER-BUILD-PLAN.md §7.
Redaction is destructive
Nothing in this package keeps a copy. There is no original field, no reversible
transform, and no key under which removed bytes could be recovered. The
RedactionReport (@frmwrkd/snag-core's Redaction) is a receipt — three counts and
a list of header names — not a way back.
Screenshot blur is the same rule in a different medium: it must be baked into the pixels by the capture engine, never rendered as an overlay.
The six stages
| # | Module | What it does |
|---|---|---|
| 1 | structural.ts | rrweb recorder options (maskAllInputs: true — rrweb ships false), plus user maskTextSelector / blockSelector, plus masking of form values harvested outside the recorder. |
| 2 | headers.ts | Strips authorization, cookie, set-cookie, x-api-key, proxy-authorization and anything matching /token\|secret\|session\|auth/i. Value becomes ••••; the key name is recorded so the UI can show what went. |
| 3 | patterns.ts | JWTs, AKIA…, sk- / sk-ant- / ghp_ / github_pat_ / xox[baprs]-, PEM blocks, emails, and Luhn-checked card-shaped digit runs. Plus values behind credential-named keys. |
| 4 | entropy.ts | Any token ≥ 24 chars with Shannon entropy above privacy.entropy_threshold (4.0) that is not a known-safe shape. |
| 5 | bodies.ts | Captures only application/json, text/*, application/x-www-form-urlencoded. Over privacy.max_body_bytes (1 MiB) the body is truncated with an explicit marker, never dropped silently. |
| 6 | allowlist.ts | If privacy.domain_allowlist is set, capture nothing on non-listed origins. Fails closed. |
index.ts runs them in order and returns { value, report, blocked, blocked_reason, counts, rrweb }.
Two places the literal ordering is adjusted, both documented in src/index.ts:
stage 6 short-circuits first (its outcome is "capture nothing", so running 1–5
first only computes a redaction of data about to be discarded), and stage 5's
content-type gate and byte cap are applied to a body before stages 3–4 read it
(bytes that are never kept are never scanned). Within kept text, stage 3 still
runs before stage 4 — that ordering does change the output, because a JWT
caught by stage 3 is labelled jwt rather than high_entropy.
Usage
import { defaultConfig } from '@frmwrkd/snag-core';
import { redact, redactText, rrwebPrivacyConfig } from '@frmwrkd/snag-redact';
const privacy = defaultConfig().privacy;
// Recorder options for rrweb.record()
rrweb.record({ ...rrwebPrivacyConfig(privacy), emit });
// One string
const { text, hits, counts } = redactText(consoleLine, privacy);
// A whole capture payload
const { value, report, blocked } = redact(
{ origin: location.origin, url: location.href, text, console, network, inputs },
privacy,
);Tests
Two corpora, per docs/02-BACKEND-SPEC.md §13:
test/corpus.ts→SECRET_CORPUS— real-shaped, fake-valued credentials (published vendor test vectors and values generated for the file). Asserted at zero false negatives: the literal that went in never appears in what comes out, including anywhere in the serialised pipeline result.test/corpus.ts→NEAR_MISS_CORPUS— order IDs, 40-char git SHAs, base64 image data URIs, UUIDs, semver ranges, a 16-digit number that fails Luhn, long camelCase identifiers and URL slugs. Asserted byte-identical after redaction.
pnpm --filter @frmwrkd/snag-redact test
pnpm --filter @frmwrkd/snag-redact typecheck