diff --git a/.gitignore b/.gitignore index 88bfda3ece..b91ffc9f01 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,8 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +# Local gitleaks artifacts (do not commit) +gitleaks-local.json !.env.example !.env.homolog.example !.env.devin-bridge.example diff --git a/README.md b/README.md index ba0cf4d2ba..dad0af6aa7 100644 --- a/README.md +++ b/README.md @@ -513,6 +513,8 @@ Pix copia-e-cola:
+

Developer notes: The project may generate a local .env file during npm install/postinstall for developer convenience. This file is intentionally ignored via .gitignore (see .gitignore) and must never be committed — if accidentally committed, rotate any exposed secrets and remove the file from history. See docs/DEVELOPER-ENVIRONMENT.md for guidance on managing local environment files and secrets.

+ ## 📡 OmniRoute Radar The main free-tier headline remains **~1.53B tokens/month** from the documented, diff --git a/docs/DEVELOPER-ENVIRONMENT.md b/docs/DEVELOPER-ENVIRONMENT.md new file mode 100644 index 0000000000..0b70014bd4 --- /dev/null +++ b/docs/DEVELOPER-ENVIRONMENT.md @@ -0,0 +1,29 @@ +# Developer environment notes + +This page explains the project's local `.env` behavior and how to handle environment files and secrets when developing OmniRoute. + +## .env postinstall behavior + +The project may generate a local `.env` file during `npm install` / `postinstall` for developer convenience. This file is intended only for local development and testing and must never be committed to version control. + +Key points: + +- The repository's `.gitignore` already ignores `.env*` files (see the `.gitignore` entry). Do not remove or alter that rule unless you deliberately intend to commit a specific example file and have a documented process for it. +- If a real secret is accidentally committed to the repo, rotate/revoke the credential immediately and remove it from the repository history (for example, using `git filter-repo` or an equivalent remediation workflow). Contact the security/contact owner if you need help. +- For CI and production, use the CI secrets or a secrets manager (GitHub Actions Secrets, Azure Key Vault, HashiCorp Vault, etc.) rather than committing secrets to files. + +## Recommended local workflow + +- Keep `.env` in your local workspace only. Use `.env.example` (already tracked) to document required variables and acceptable example values. +- When running tests locally that require secret-like values, prefer synthetic placeholders or runtime-generated ephemeral keys rather than real credentials. +- Add a short comment in tests that use placeholders so reviewers understand the fixture is synthetic. + +## Scanner notes + +- Some compiled or binary assets (e.g., embedded base64 WASM blobs) can contain ASCII substrings that look like credentials and may trigger text-based secret scanners. If these assets are legitimate, either mark them in the scanner's allowlist or exclude the directories in the scanner config. + +## If you find a leak + +1. Rotate/revoke the key immediately. +2. Remove the secret from the history and force-push a cleaned branch if necessary. +3. Notify maintainers and follow your org's incident response checklist. diff --git a/tests/unit/github-collector.test.ts b/tests/unit/github-collector.test.ts index e61a4e0d0f..17c725be6c 100644 --- a/tests/unit/github-collector.test.ts +++ b/tests/unit/github-collector.test.ts @@ -129,7 +129,7 @@ void test("scanText: detects eval(base64) pattern", () => { void test("scanText: detects hardcoded private keys", () => { const content = - "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----"; + "-----BEGIN RSA PRIVATE KEY-----\nTEST_RSA_PRIVATE_KEY_PLACEHOLDER_DO_NOT_USE\n-----END RSA PRIVATE KEY-----"; const findings = scanText(content, "leaked.md"); assert.ok(findings.some((f) => f.pattern.includes("Private key"))); }); diff --git a/tests/unit/piiSanitizer.test.ts b/tests/unit/piiSanitizer.test.ts index 76db2557e4..8a035b358d 100644 --- a/tests/unit/piiSanitizer.test.ts +++ b/tests/unit/piiSanitizer.test.ts @@ -147,7 +147,7 @@ test("sanitizePII detects AWS access key", async () => { delete process.env.PII_RESPONSE_SANITIZATION_MODE; const { sanitizePII } = await import("@/lib/piiSanitizer"); - const input = "Key: AKIAIOSFODNN7EXAMPLE"; + const input = "Key: AKIAEXAMPLE123456789"; const result = sanitizePII(input); assert.ok(result.text.includes("[AWS_KEY_REDACTED]"), "AWS access key should be redacted"); diff --git a/tests/unit/sre-redact-logs.test.ts b/tests/unit/sre-redact-logs.test.ts index 4d628fbb2c..6c180fe792 100644 --- a/tests/unit/sre-redact-logs.test.ts +++ b/tests/unit/sre-redact-logs.test.ts @@ -1,256 +1,259 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { Writable } from "node:stream"; -import { redactString, redact, RedactTransform } from "../../scripts/sre/redact-logs.mjs"; - -// ─── 1. email ──────────────────────────────────────────────────────────────── - -test("redactString: standard email is replaced", () => { - const { output, counts } = redactString("contact alice@example.com for details"); - assert.equal(output, "contact [REDACTED_EMAIL] for details"); - assert.equal(counts.EMAIL, 1); -}); - -test("redactString: sub-domain email is replaced", () => { - const { output } = redactString("ping ops+sre@mail.omniroute.dev today"); - assert.equal(output, "ping [REDACTED_EMAIL] today"); -}); - -test("redactString: email-like but missing TLD is preserved", () => { - // "user@host" is not a valid email; should NOT match. - const { output, counts } = redactString("note user@host is mentioned"); - assert.equal(output, "note user@host is mentioned"); - assert.equal(counts.EMAIL ?? 0, 0); -}); - -// ─── 2. IPv4 ──────────────────────────────────────────────────────────────── - -test("redactString: IPv4 address is redacted", () => { - const { output, counts } = redactString("client connected from 192.168.1.42"); - assert.equal(output, "client connected from [REDACTED_IPV4]"); - assert.equal(counts.IPV4, 1); -}); - -test("redactString: IPv4 with port is redacted including port", () => { - const { output } = redactString("connect 10.0.0.1:5432 succeeded"); - assert.equal(output, "connect [REDACTED_IPV4] succeeded"); -}); - -test("redactString: invalid octet (256) is NOT redacted", () => { - const { output, counts } = redactString("value 256.300.1.1 invalid"); - // The "256.300.1.1" should NOT match (octets > 255). - // It's possible that a partial substring like "56.300" might still match - // through other regex runs; assert that no full-IP redaction appears. - assert.equal(output.includes("[REDACTED_IPV4]"), false); - assert.equal((counts.IPV4 ?? 0), 0); -}); - -test("redactString: 127.0.0.1 is redacted (loopback is still PII for log shipping)", () => { - const { output } = redactString("local check from 127.0.0.1 ok"); - assert.equal(output, "local check from [REDACTED_IPV4] ok"); -}); - -// ─── 3. IPv6 ──────────────────────────────────────────────────────────────── - -test("redactString: full IPv6 is redacted", () => { - const { output, counts } = redactString("peer 2001:0db8:85a3:0000:0000:8a2e:0370:7334 connected"); - assert.equal(output, "peer [REDACTED_IPV6] connected"); - assert.equal(counts.IPV6, 1); -}); - -test("redactString: compressed IPv6 is redacted", () => { - const { output } = redactString("from fe80::1 to ::1"); - // Both addresses should be replaced. - assert.match(output, /from \[REDACTED_IPV6\] to \[REDACTED_IPV6\]/); -}); - -test("redactString: ::1 loopback is redacted", () => { - const { output } = redactString("traffic from ::1 only"); - assert.match(output, /traffic from \[REDACTED_IPV6\] only/); -}); - -// ─── 4. Bearer tokens ─────────────────────────────────────────────────────── - -test("redactString: Bearer token in Authorization header is redacted", () => { - const { output, counts } = redactString("Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345"); - assert.match(output, /\[REDACTED_BEARER\]/); - assert.equal(counts.BEARER, 1); -}); - -test("redactString: 'Bearer' word without a token is preserved", () => { - const { output, counts } = redactString("the bearer of bad news"); - // "bad news" is too short to match (needs 16+ chars). - assert.equal(output, "the bearer of bad news"); - assert.equal((counts.BEARER ?? 0), 0); -}); - -// ─── 5. OpenAI keys ───────────────────────────────────────────────────────── - -test("redactString: sk- prefix key is redacted", () => { - const { output, counts } = redactString("OPENAI_KEY=sk-proj-abc123XYZ456def789GHI012jkl"); - assert.match(output, /\[REDACTED_API_KEY\]/); - assert.ok((counts.OPENAI_KEY ?? 0) >= 1 || (counts.GENERIC_KEY ?? 0) >= 1); -}); - -test("redactString: sk- short token (too short) is NOT redacted", () => { - // 18 chars after "sk-" — minimum is 20. - const { output } = redactString("noise: sk-abcdefghijklmnopqr here"); - assert.equal(output, "noise: sk-abcdefghijklmnopqr here"); -}); - -test("redactString: anthropic sk-ant- key is redacted", () => { - const { output, counts } = redactString("key: sk-ant-api03-abcdefghij1234567890ABCD"); - assert.match(output, /\[REDACTED_API_KEY\]/); - assert.equal(counts.ANTHROPIC_KEY, 1); -}); - -test("redactString: Google AIza key is redacted", () => { - // Total 39 chars: "AIza" (4) + 35 alnum/hyphen/underscore. - const key = "AIzaSyD-1234567890abcdefghijklmnopqrstu"; - assert.equal(key.length, 39); - const { output, counts } = redactString(`google_key=${key}`); - assert.match(output, /\[REDACTED_API_KEY\]/); - assert.equal(counts.GOOGLE_KEY, 1); -}); - -// ─── 6. GitHub tokens ─────────────────────────────────────────────────────── - -test("redactString: ghp_ token is redacted", () => { - const token = "ghp_" + "a".repeat(36); - const { output, counts } = redactString(`token=${token}`); - assert.match(output, /\[REDACTED_API_KEY\]/); - assert.equal(counts.GITHUB_TOKEN, 1); -}); - -test("redactString: github_pat_ token is redacted", () => { - const token = "github_pat_" + "B".repeat(40); - const { output } = redactString(`pat is ${token} end`); - assert.match(output, /pat is \[REDACTED_API_KEY\] end/); -}); - -// ─── 7. AWS keys ──────────────────────────────────────────────────────────── - -test("redactString: AKIA access key is redacted", () => { - const key = "AKIAIOSFODNN7EXAMPLE"; // 20 chars - const { output, counts } = redactString(`aws_access_key_id=${key}`); - assert.match(output, /\[REDACTED_AWS_KEY\]/); - assert.equal(counts.AWS_KEY, 1); -}); - -test("redactString: ASIA (temporary) key is redacted", () => { - const key = "ASIAIOSFODNN7EXAMPLE"; - const { output, counts } = redactString(`temp=${key}`); - assert.match(output, /\[REDACTED_AWS_KEY\]/); - assert.equal(counts.AWS_KEY, 1); -}); - -// ─── 8. Generic api_key=value ─────────────────────────────────────────────── - -test("redactString: api_key=value pair is redacted", () => { - const { output, counts } = redactString(`api_key=${"x".repeat(20)}`); - assert.match(output, /\[REDACTED_API_KEY\]/); - assert.equal(counts.GENERIC_KEY, 1); -}); - -test("redactString: password=... is redacted", () => { - const { output, counts } = redactString(`password: ${"hunter2hunter2hunter2"}`); - assert.match(output, /\[REDACTED_API_KEY\]/); - assert.equal(counts.GENERIC_KEY, 1); -}); - -test("redactString: short value (< 12 chars) is NOT redacted", () => { - const { output, counts } = redactString("password: short"); - assert.equal(output, "password: short"); - assert.equal((counts.GENERIC_KEY ?? 0), 0); -}); - -// ─── 9. Combined / order of operations ────────────────────────────────────── - -test("redactString: line with email AND ip AND key redacts all three", () => { - const line = `2026-06-25T07:00:00Z ERROR user=alice@example.com ip=10.0.0.5 key=sk-proj-${"A".repeat(30)}`; - const { output, counts } = redactString(line); - assert.match(output, /\[REDACTED_EMAIL\]/); - assert.match(output, /\[REDACTED_IPV4\]/); - assert.match(output, /\[REDACTED_API_KEY\]/); - // Counts should be at least one of each category. - assert.ok((counts.EMAIL ?? 0) >= 1); - assert.ok((counts.IPV4 ?? 0) >= 1); - assert.ok((counts.OPENAI_KEY ?? 0) >= 1); -}); - -test("redactString: empty string yields empty output", () => { - const { output, counts } = redactString(""); - assert.equal(output, ""); - assert.deepEqual(counts, {}); -}); - -test("redactString: non-PII log line is unchanged", () => { - const line = '2026-06-25T07:00:00Z INFO request_id=req_abc123 method=GET path=/v1/models'; - const { output } = redactString(line); - assert.equal(output, line); -}); - -test("redactString: key inside larger word (not at boundary) is not matched", () => { - // `task-abc123XYZ456def789GHI012jkl345` is not preceded by 'sk-' so should - // not match the OPENAI_KEY pattern. - const { output, counts } = redactString("some task-abcdefghij1234567890KL here"); - assert.equal(output, "some task-abcdefghij1234567890KL here"); - assert.equal((counts.OPENAI_KEY ?? 0), 0); -}); - -// ─── 10. Stable markers across runs ───────────────────────────────────────── - -test("redactString: same input twice yields the same redacted output", () => { - const line = `ip=192.168.0.1 user=${"a".repeat(40)}@example.com`; - const first = redactString(line).output; - const second = redactString(line).output; - assert.equal(first, second); -}); - -// ─── 11. redact() shorthand ───────────────────────────────────────────────── - -test("redact(): shorthand returns just the output", () => { - assert.equal(redact("email bob@example.com here"), "email [REDACTED_EMAIL] here"); -}); - -// ─── 12. RedactTransform stream ───────────────────────────────────────────── - -test("RedactTransform: streams input chunks to output, redacting as it goes", async () => { - const t = new RedactTransform(); - const out = []; - const sink = new Writable({ - write(chunk, _enc, cb) { - out.push(typeof chunk === "string" ? chunk : chunk.toString("utf8")); - cb(); - }, - }); - const src = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("email a@b.com ")); - controller.enqueue(new TextEncoder().encode("ip=1.2.3.4 ")); - controller.enqueue(new TextEncoder().encode("end\n")); - controller.close(); - }, - }); - await src.pipeThrough(new TextDecoderStream()).pipeThrough(t).pipeTo( - new WritableStream({ - write(chunk) { - sink.write(chunk, "utf8", () => {}); - }, - }), - ); - const joined = out.join(""); - assert.match(joined, /\[REDACTED_EMAIL\]/); - assert.match(joined, /\[REDACTED_IPV4\]/); - // Counts accumulated on the transform. - assert.equal(t.counts.EMAIL ?? 0, 1); - assert.equal(t.counts.IPV4 ?? 0, 1); -}); - -// ─── 13. Counts are independent between calls ─────────────────────────────── - -test("redactString: counts do not bleed across calls", () => { - redactString("a@b.com"); - const { counts } = redactString("no pii here at all"); - assert.deepEqual(counts, {}); -}); \ No newline at end of file +import test from "node:test"; +import assert from "node:assert/strict"; +import { Writable } from "node:stream"; +import { redactString, redact, RedactTransform } from "../../scripts/sre/redact-logs.mjs"; + +// ─── 1. email ──────────────────────────────────────────────────────────────── + +test("redactString: standard email is replaced", () => { + const { output, counts } = redactString("contact alice@example.com for details"); + assert.equal(output, "contact [REDACTED_EMAIL] for details"); + assert.equal(counts.EMAIL, 1); +}); + +test("redactString: sub-domain email is replaced", () => { + const { output } = redactString("ping ops+sre@mail.omniroute.dev today"); + assert.equal(output, "ping [REDACTED_EMAIL] today"); +}); + +test("redactString: email-like but missing TLD is preserved", () => { + // "user@host" is not a valid email; should NOT match. + const { output, counts } = redactString("note user@host is mentioned"); + assert.equal(output, "note user@host is mentioned"); + assert.equal(counts.EMAIL ?? 0, 0); +}); + +// ─── 2. IPv4 ──────────────────────────────────────────────────────────────── + +test("redactString: IPv4 address is redacted", () => { + const { output, counts } = redactString("client connected from 192.168.1.42"); + assert.equal(output, "client connected from [REDACTED_IPV4]"); + assert.equal(counts.IPV4, 1); +}); + +test("redactString: IPv4 with port is redacted including port", () => { + const { output } = redactString("connect 10.0.0.1:5432 succeeded"); + assert.equal(output, "connect [REDACTED_IPV4] succeeded"); +}); + +test("redactString: invalid octet (256) is NOT redacted", () => { + const { output, counts } = redactString("value 256.300.1.1 invalid"); + // The "256.300.1.1" should NOT match (octets > 255). + // It's possible that a partial substring like "56.300" might still match + // through other regex runs; assert that no full-IP redaction appears. + assert.equal(output.includes("[REDACTED_IPV4]"), false); + assert.equal(counts.IPV4 ?? 0, 0); +}); + +test("redactString: 127.0.0.1 is redacted (loopback is still PII for log shipping)", () => { + const { output } = redactString("local check from 127.0.0.1 ok"); + assert.equal(output, "local check from [REDACTED_IPV4] ok"); +}); + +// ─── 3. IPv6 ──────────────────────────────────────────────────────────────── + +test("redactString: full IPv6 is redacted", () => { + const { output, counts } = redactString("peer 2001:0db8:85a3:0000:0000:8a2e:0370:7334 connected"); + assert.equal(output, "peer [REDACTED_IPV6] connected"); + assert.equal(counts.IPV6, 1); +}); + +test("redactString: compressed IPv6 is redacted", () => { + const { output } = redactString("from fe80::1 to ::1"); + // Both addresses should be replaced. + assert.match(output, /from \[REDACTED_IPV6\] to \[REDACTED_IPV6\]/); +}); + +test("redactString: ::1 loopback is redacted", () => { + const { output } = redactString("traffic from ::1 only"); + assert.match(output, /traffic from \[REDACTED_IPV6\] only/); +}); + +// ─── 4. Bearer tokens ─────────────────────────────────────────────────────── + +test("redactString: Bearer token in Authorization header is redacted", () => { + const { output, counts } = redactString("Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345"); + assert.match(output, /\[REDACTED_BEARER\]/); + assert.equal(counts.BEARER, 1); +}); + +test("redactString: 'Bearer' word without a token is preserved", () => { + const { output, counts } = redactString("the bearer of bad news"); + // "bad news" is too short to match (needs 16+ chars). + assert.equal(output, "the bearer of bad news"); + assert.equal(counts.BEARER ?? 0, 0); +}); + +// ─── 5. OpenAI keys ───────────────────────────────────────────────────────── + +test("redactString: sk- prefix key is redacted", () => { + const { output, counts } = redactString("OPENAI_KEY=sk-proj-abc123XYZ456def789GHI012jkl"); + assert.match(output, /\[REDACTED_API_KEY\]/); + assert.ok((counts.OPENAI_KEY ?? 0) >= 1 || (counts.GENERIC_KEY ?? 0) >= 1); +}); + +test("redactString: sk- short token (too short) is NOT redacted", () => { + // 18 chars after "sk-" — minimum is 20. + const { output } = redactString("noise: sk-abcdefghijklmnopqr here"); + assert.equal(output, "noise: sk-abcdefghijklmnopqr here"); +}); + +test("redactString: anthropic sk-ant- key is redacted", () => { + const { output, counts } = redactString("key: sk-ant-api03-abcdefghij1234567890ABCD"); + assert.match(output, /\[REDACTED_API_KEY\]/); + assert.equal(counts.ANTHROPIC_KEY, 1); +}); + +test("redactString: Google AIza key is redacted", () => { + // Total 39 chars: "AIza" (4) + 35 alnum/hyphen/underscore. + const key = "AIzaSyD-1234567890abcdefghijklmnopqrstu"; + assert.equal(key.length, 39); + const { output, counts } = redactString(`google_key=${key}`); + assert.match(output, /\[REDACTED_API_KEY\]/); + assert.equal(counts.GOOGLE_KEY, 1); +}); + +// ─── 6. GitHub tokens ─────────────────────────────────────────────────────── + +test("redactString: ghp_ token is redacted", () => { + const token = "ghp_" + "a".repeat(36); + const { output, counts } = redactString(`token=${token}`); + assert.match(output, /\[REDACTED_API_KEY\]/); + assert.equal(counts.GITHUB_TOKEN, 1); +}); + +test("redactString: github_pat_ token is redacted", () => { + const token = "github_pat_" + "B".repeat(40); + const { output } = redactString(`pat is ${token} end`); + assert.match(output, /pat is \[REDACTED_API_KEY\] end/); +}); + +// ─── 7. AWS keys ──────────────────────────────────────────────────────────── + +test("redactString: AKIA access key is redacted", () => { + const key = "AKIAEXAMPLE123456789"; // 20 chars + const { output, counts } = redactString(`aws_access_key_id=${key}`); + assert.match(output, /\[REDACTED_AWS_KEY\]/); + assert.equal(counts.AWS_KEY, 1); +}); + +test("redactString: ASIA (temporary) key is redacted", () => { + const key = "ASIAIOSFODNN7EXAMPLE"; + const { output, counts } = redactString(`temp=${key}`); + assert.match(output, /\[REDACTED_AWS_KEY\]/); + assert.equal(counts.AWS_KEY, 1); +}); + +// ─── 8. Generic api_key=value ─────────────────────────────────────────────── + +test("redactString: api_key=value pair is redacted", () => { + const { output, counts } = redactString(`api_key=${"x".repeat(20)}`); + assert.match(output, /\[REDACTED_API_KEY\]/); + assert.equal(counts.GENERIC_KEY, 1); +}); + +test("redactString: password=... is redacted", () => { + const { output, counts } = redactString(`password: ${"hunter2hunter2hunter2"}`); + assert.match(output, /\[REDACTED_API_KEY\]/); + assert.equal(counts.GENERIC_KEY, 1); +}); + +test("redactString: short value (< 12 chars) is NOT redacted", () => { + const { output, counts } = redactString("password: short"); + assert.equal(output, "password: short"); + assert.equal(counts.GENERIC_KEY ?? 0, 0); +}); + +// ─── 9. Combined / order of operations ────────────────────────────────────── + +test("redactString: line with email AND ip AND key redacts all three", () => { + const line = `2026-06-25T07:00:00Z ERROR user=alice@example.com ip=10.0.0.5 key=sk-proj-${"A".repeat(30)}`; + const { output, counts } = redactString(line); + assert.match(output, /\[REDACTED_EMAIL\]/); + assert.match(output, /\[REDACTED_IPV4\]/); + assert.match(output, /\[REDACTED_API_KEY\]/); + // Counts should be at least one of each category. + assert.ok((counts.EMAIL ?? 0) >= 1); + assert.ok((counts.IPV4 ?? 0) >= 1); + assert.ok((counts.OPENAI_KEY ?? 0) >= 1); +}); + +test("redactString: empty string yields empty output", () => { + const { output, counts } = redactString(""); + assert.equal(output, ""); + assert.deepEqual(counts, {}); +}); + +test("redactString: non-PII log line is unchanged", () => { + const line = "2026-06-25T07:00:00Z INFO request_id=req_abc123 method=GET path=/v1/models"; + const { output } = redactString(line); + assert.equal(output, line); +}); + +test("redactString: key inside larger word (not at boundary) is not matched", () => { + // `task-abc123XYZ456def789GHI012jkl345` is not preceded by 'sk-' so should + // not match the OPENAI_KEY pattern. + const { output, counts } = redactString("some task-abcdefghij1234567890KL here"); + assert.equal(output, "some task-abcdefghij1234567890KL here"); + assert.equal(counts.OPENAI_KEY ?? 0, 0); +}); + +// ─── 10. Stable markers across runs ───────────────────────────────────────── + +test("redactString: same input twice yields the same redacted output", () => { + const line = `ip=192.168.0.1 user=${"a".repeat(40)}@example.com`; + const first = redactString(line).output; + const second = redactString(line).output; + assert.equal(first, second); +}); + +// ─── 11. redact() shorthand ───────────────────────────────────────────────── + +test("redact(): shorthand returns just the output", () => { + assert.equal(redact("email bob@example.com here"), "email [REDACTED_EMAIL] here"); +}); + +// ─── 12. RedactTransform stream ───────────────────────────────────────────── + +test("RedactTransform: streams input chunks to output, redacting as it goes", async () => { + const t = new RedactTransform(); + const out = []; + const sink = new Writable({ + write(chunk, _enc, cb) { + out.push(typeof chunk === "string" ? chunk : chunk.toString("utf8")); + cb(); + }, + }); + const src = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("email a@b.com ")); + controller.enqueue(new TextEncoder().encode("ip=1.2.3.4 ")); + controller.enqueue(new TextEncoder().encode("end\n")); + controller.close(); + }, + }); + await src + .pipeThrough(new TextDecoderStream()) + .pipeThrough(t) + .pipeTo( + new WritableStream({ + write(chunk) { + sink.write(chunk, "utf8", () => {}); + }, + }) + ); + const joined = out.join(""); + assert.match(joined, /\[REDACTED_EMAIL\]/); + assert.match(joined, /\[REDACTED_IPV4\]/); + // Counts accumulated on the transform. + assert.equal(t.counts.EMAIL ?? 0, 1); + assert.equal(t.counts.IPV4 ?? 0, 1); +}); + +// ─── 13. Counts are independent between calls ─────────────────────────────── + +test("redactString: counts do not bleed across calls", () => { + redactString("a@b.com"); + const { counts } = redactString("no pii here at all"); + assert.deepEqual(counts, {}); +});