Stop secrets and PII from reaching your logs, search indexes, and third-party tools.
High-throughput JSON log redaction for Node.js — 600k+ lines/sec on Apple Silicon. Drop it in front of your log pipeline and every field named password, token, credit_card, or any of hundreds of locale-aware aliases becomes [**REDACTED**] before the line leaves your process. Zero runtime dependencies. TypeScript-native. Node.js 20+.
npm install bluestreakTwo lines to go from leaking logs to safe ones:
import { compileRecommendedPolicy, redactLine } from "bluestreak";
const policy = compileRecommendedPolicy();
console.log(redactLine(JSON.stringify({ password: "x", message: "[email protected]" }), policy));
// {"password":"[**REDACTED**]","message":"[**REDACTED**]"}The recommended preset gives you English locale + GDPR/PCI keywords + email, bearer token, and auth header patterns — enough for most SaaS APIs with no configuration.
For full control:
import {
compilePolicy,
redactLine,
BUILTIN_PATTERNS,
SOURCE_CONTROL_PATTERNS,
PAYMENT_PATTERNS,
MESSAGING_PATTERNS,
} from "bluestreak";
const policy = compilePolicy({
complianceProfiles: ["GDPR", "PCI_DSS"],
locales: ["en-en"],
scanTextFields: ["log", "message", "msg", "error", "stack", "body"],
maxFieldScanLength: 4096,
panSeparatorChars: " -._",
customPatterns: [
BUILTIN_PATTERNS.EMAIL,
BUILTIN_PATTERNS.BEARER_TOKEN,
BUILTIN_PATTERNS.AUTH_HEADER,
SOURCE_CONTROL_PATTERNS.GITHUB_PAT,
PAYMENT_PATTERNS.STRIPE_KEY,
MESSAGING_PATTERNS.SLACK_TOKEN,
],
});
const line = JSON.stringify({
level: "info",
password: "hunter2",
message: "charged [email protected] card 4111 1111 1111 1111",
});
console.log(redactLine(line, policy));
// {"level":"info","password":"[**REDACTED**]","message":"charged [**REDACTED**] card [**REDACTED**]"}Compile the policy once at startup — it's a plain object you can share across workers, queue consumers, and services. Never call compilePolicy per line.
Bluestreak fits anywhere you need consistent, policy-driven scrubbing at the log-line boundary — not just in-process object mutation.
-
Ship-side / pipeline redaction — Run
redactLineon each event before logs reach Elasticsearch, Datadog, S3, or a vendor SDK so secrets and PII never leave your network, with the same policy everywhere. -
Key-based PII and secrets — Rely on merged locale packs and compliance profiles so fields like
password,token, and translated or aliased key names are redacted by normalized key matching, without hand-maintaining huge path lists. -
Secrets inside free text — Use
scanTextFieldspluscustomPatterns/ built-in patterns to redact emails, bearer tokens, card-like digit runs, and so on insidemessage,stack,error, or other long string fields where path rules alone are not enough. -
Exact paths and carve-outs — Use
redactPathsfor values sensitive at a specific location (e.g.request.headers.authorization), andexcludePathsto preserve known-safe values (e.g. a publicproduct.id) even when the bare key would otherwise match a global sensitive list. -
Non-JSON and broken lines — Lines that aren't valid JSON still go through raw high-risk scanning, so truncated payloads, plain-text errors, or mixed formats don't bypass redaction.
-
Shared org policy — Compile one
CompiledPolicyat startup and reuse it across workers, queue consumers, and services so security and platform teams own a single definition of "redacted."
When something else is a better fit: If you only need fast path redaction on JavaScript objects inside a logger (and you already control object shape), a dedicated path engine like fast-redact (e.g. via Pino) can be lighter. Bluestreak targets line-level JSON, combined key + path + pattern behavior, and operational consistency across the full log path.
Recipes for wiring Bluestreak into common Node loggers (dependencies stay out of this package):
- Pino
- Winston
- Architecture — data flow and precedence
- Known limitations
- Console, browser, and streams —
console.log, bundlers,bluestreak/node
Bluestreak uses two complementary mechanisms on every line:
1. Key-name matching — at compile time, keyword lists from your chosen locales and compliance profiles are merged into a single hash set. At runtime, every JSON key is normalized (lowercased, separators stripped) and checked against that set in O(1). A hit replaces the entire value with [**REDACTED**], regardless of what the value looks like.
{ "API_KEY": "abc" } → normalizeKey("API_KEY") = "apikey" → in set → [**REDACTED**]
{ "api-key": "abc" } → normalizeKey("api-key") = "apikey" → in set → [**REDACTED**]
{ "apiKey": "abc" } → normalizeKey("apiKey") = "apikey" → in set → [**REDACTED**]
2. Pattern scanning — for fields you name in scanTextFields (typically message, log, error), the string value is scanned using the patterns you configure. Only the first maxFieldScanLength characters are scanned; the rest is appended unchanged so very long fields don't become a performance cliff.
Lines that fail JSON.parse (truncated, binary, plain text) fall through to a raw scrubber that applies the same patterns to the whole string, so nothing breaks and no sensitive content leaks on a bad line.
No patterns run by default. You choose exactly what gets scanned: use the exported BUILTIN_PATTERNS constants, patterns from a locale pack, your own regexes, or any combination.
Bluestreak is designed to stay fast when you compile once and keep hot-path work predictable. Latest benchmark on this repo's reference machine (darwin arm64, Node v20.15.0, 2026-04-20):
| Scenario | Throughput (lines/sec) | vs baseline |
|---|---|---|
| Baseline (profiles + locales, no extra patterns/paths) | 645 466 | — |
+10 customPatterns (medium-complexity regex set) |
521 451 | −19% |
Exact-path: 3 redactPaths + 1 excludePaths |
651 898 | ≈ parity |
| Exact-path: 10 paths + 2 excludes | 433 506 | −33% |
pathCensor callback (3 paths) |
556 989 | −14% |
Benchmark: 120 000 lines, 10 000 warmup, median of 3 rounds, mixed JSON + raw lines. Run on your hardware:
npm run perf- Compile one policy per hot path — call
compilePolicyat startup, never per line. - Minimize
customPatterns— each pattern is a full pass over every scanned text field. Prefer tight anchors (\b, known literal prefixes). - Use exact paths sparingly — keep
redactPaths/excludePathsto ≤ 5 entries for ultra-hot pipes. For many paths, split policies by module. - Prefer a static
pathCensorstring over a callback in latency-sensitive code. - Re-run
npm run perfafter material policy changes.
| Mode | Use when | Entry point | Typical APIs |
|---|---|---|---|
| Non-stream | You already have a line/object in app code | bluestreak |
redactLine, tryRedactLine, redactParsed, redactLines |
| Stream | You are piping log output through Node streams/transports | bluestreak/node |
createLineRedactingTransform, redactTransform |
Non-stream — request handlers, jobs, CLI commands, browser logging wrappers:
import { compileRecommendedPolicy, redactLine } from "bluestreak";
const policy = compileRecommendedPolicy();
const redacted = redactLine(line, policy);Stream — Pino/Winston transports, file pipelines, log shipping:
import { createReadStream, createWriteStream } from "node:fs";
import { compileRecommendedPolicy } from "bluestreak";
import { createLineRedactingTransform } from "bluestreak/node";
const policy = compileRecommendedPolicy();
createReadStream("app.log")
.pipe(createLineRedactingTransform(policy, { safe: true }))
.pipe(createWriteStream("app.redacted.log"));import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
import { compilePolicy, redactLines } from "bluestreak";
const policy = compilePolicy({
complianceProfiles: ["GDPR", "SOC2"],
locales: ["en-en"],
scanTextFields: ["message", "log"],
maxFieldScanLength: 4096,
});
const rl = createInterface({
input: createReadStream("app.log"),
crlfDelay: Infinity,
});
for await (const line of redactLines(rl, policy)) {
process.stdout.write(line + "\n");
}const policy = compilePolicy({
complianceProfiles: ["GDPR"],
locales: ["en-en", "de-de", "fr-fr"], // password + passwort + mot_de_passe → all caught
scanTextFields: ["message", "log"],
maxFieldScanLength: 4096,
});Supported locales: en-en, de-de, es-es, fr-fr.
redactLine never throws. Invalid JSON falls back to a raw scrubber that applies the same configured patterns to the whole string:
redactLine("Authorization: Bearer supersecret123", policy);
// → "Authorization: Bearer [**REDACTED**]"
redactLine("not json at all, nothing sensitive", policy);
// → "not json at all, nothing sensitive"import { compilePolicy, redactTree } from "bluestreak";
const { value, changed } = redactTree(parsedObject, policy);
if (changed) {
storeRedacted(JSON.stringify(value));
} else {
storeOriginal(rawLine); // original formatting preserved, no extra allocations
}import { composeKeywordSet } from "bluestreak";
const tenantFields = await db.query(
"SELECT field_name FROM sensitive_fields WHERE tenant_id = ?",
[tenantId],
);
const policy = composeKeywordSet({
complianceProfiles: ["GDPR"],
locales: ["en-en"],
extraKeywordLists: [tenantFields.map((r) => r.field_name)],
scanTextFields: ["message", "log"],
maxFieldScanLength: 4096,
});Build one policy per tenant and cache it. Don't call per log line.
| Profile | Adds keywords for |
|---|---|
GDPR |
Names, addresses, national IDs, location, tax IDs |
HIPAA |
Patient IDs, medical records, diagnoses, insurance |
PCI_DSS |
Card numbers, CVV/CVC, expiry, track data |
SOC2 |
API keys, session tokens, private keys, auth headers |
Pass any combination: complianceProfiles: ["GDPR", "SOC2"]. Use "*" to merge all profile buckets at once.
Each locale pack covers its own language only. Include "en-en" explicitly whenever you need English coverage alongside other locales.
Key-name matching protects fields you can name. Pattern scanning protects values with a recognisable shape, regardless of the key.
Protocol-level (BUILTIN_PATTERNS) — universal, safe to add to almost any policy:
| Export | Matches | Example |
|---|---|---|
BUILTIN_PATTERNS.EMAIL |
Email addresses (ASCII domains) | [email protected] |
BUILTIN_PATTERNS.BEARER_TOKEN |
Bearer token value (prefix preserved) | Bearer eyJhbGci… |
BUILTIN_PATTERNS.AUTH_HEADER |
Authorization: header value |
Authorization: tok123 |
panSeparatorChars config |
Card numbers (Luhn-validated) | 4111 1111 1111 1111 |
Vendor / service-specific (SERVICE_PATTERNS) — add only the services your application uses:
| Category | Patterns |
|---|---|
SOURCE_CONTROL_PATTERNS |
GitHub (4 token types), GitLab (3 types), npm |
CLOUD_PATTERNS |
AWS, Google Cloud / Firebase / Gemini, DigitalOcean, HashiCorp Vault |
PAYMENT_PATTERNS |
Stripe (secret + restricted), Square (OAuth + app secret) |
MESSAGING_PATTERNS |
Slack, Twilio, SendGrid, Mailgun |
ECOMMERCE_PATTERNS |
Shopify, WooCommerce |
MONITORING_PATTERNS |
New Relic, Grafana, PlanetScale |
CRM_PATTERNS |
HubSpot, Mailchimp |
AI_PATTERNS |
OpenAI (legacy + project-scoped), Anthropic, Hugging Face |
AUTH_PATTERNS |
JSON Web Token |
All patterns are also available on SERVICE_PATTERNS as a flat object, and individually by name (e.g. GITHUB_PAT_PATTERN, AWS_ACCESS_KEY_PATTERN).
Builds a policy from locale packs and compliance profiles. Returns a CompiledPolicy safe to share across calls and workers. Call once at startup.
const policy = compilePolicy({
complianceProfiles: ["*"],
locales: ["en-en"],
preset: "logging", // "logging" | "strict" | "recommended"
scanTextFields: ["message", "log"],
maxFieldScanLength: 4096,
extraKeywordLists: [["tenant_secret_key"]],
panSeparatorChars: " -._",
customPatterns: [],
redactPaths: ["session.id", "request.headers.authorization"],
excludePaths: ["product.id"],
pathCensor: "[**REDACTED**]", // string or (value, path) => unknown
});Shorthand for compilePolicy({ ...config, preset: "recommended" }). Defaults to en-en locale, GDPR + PCI_DSS profiles, and baseline text patterns.
Main entry point. Parses raw as JSON, redacts the tree, returns a string. Unchanged lines are returned as-is (no extra allocations). Falls back to raw scrubber on invalid JSON.
Never throws. Returns raw unchanged if redaction fails for any reason. Use on hot sinks.
Async generator wrapper around redactLine. Works with any AsyncIterable<string>.
Redacts an already-parsed object — skips JSON.parse when you already have a value.
Redacts an already-parsed JSON value using copy-on-write. Returns { value, changed }.
Same option surface as compilePolicy. Use for runtime keyword composition (tenant-specific lists, per-request overrides).
The same key normalization used internally: lowercase ASCII, keep digits, strip everything else.
normalizeKey("X-Api-Key"); // → "xapikey"
normalizeKey("user_id"); // → "userid"| Function | Description |
|---|---|
createLineRedactingTransform(policy, options?) |
Buffers UTF-8 chunks, splits on \n, redacts each complete line. options.safe uses tryRedactLine. |
redactTransform(policy) |
Node.js Transform where each chunk is a single complete log line. Split first if chunks don't align to newlines. |
Define a LocaleKeywordPack in your own codebase — no fork required:
import type { LocaleKeywordPack } from "bluestreak";
export const myPack: LocaleKeywordPack = {
generalKeywords: ["vault_token", "internal_secret"],
profileKeywords: {
GDPR: ["customer_ref", "account_holder"],
HIPAA: ["mrn_internal"],
PCI_DSS: ["payment_ref"],
SOC2: [],
},
patterns: [
/\bINT-\d{8}\b/g, // internal transaction ID
/\bACCT-[A-Z0-9]{12}\b/g, // account reference
],
};
const policy = compilePolicy({
locales: ["en-en", myPack],
complianceProfiles: ["GDPR"],
scanTextFields: ["message", "log"],
maxFieldScanLength: 4096,
});LocaleKeywordPack.patterns |
customPatterns in compilePolicy |
|
|---|---|---|
| Use for | Jurisdiction-specific PII formats — IBANs, national IDs, local phone formats | App-specific formats — internal token shapes, external services |
| Scope | Bound to a locale pack | Global |
| Examples | German IBAN, French NIR | AWS access keys, internal order IDs |
At runtime, both are merged into the same flat list and applied identically.
// Enable with common separators
const policy = compilePolicy({ panSeparatorChars: " -._" });
// Also match pipe-separated formats
const policy = compilePolicy({ panSeparatorChars: " -._|" });
// Contiguous digits only (no separators)
const policy = compilePolicy({ panSeparatorChars: "" });Key-name matching (card_number, pan, etc.) catches the value regardless of format.
npm test # run the test suite (vitest)
npm run test:coverage # coverage report
npm run perf # throughput benchmark — results appended to PERFORMANCE_RESULTS.mdMIT
