diff --git a/docs/src/registry.ts b/docs/src/registry.ts index aa4ef0e..e93a138 100644 --- a/docs/src/registry.ts +++ b/docs/src/registry.ts @@ -81,6 +81,12 @@ export const PII_IDS: ParserId[] = [ "BIC", ]; +/** PII + protocol-qualified URLs (SensitiveParsers preset). */ +export const SENSITIVE_IDS: ParserId[] = [ + ...PII_IDS, + "URL", +]; + /** Parser ordering sent to the worker (priority: specific → generic). */ export const PARSER_PRIORITY: ParserId[] = [ "Email", diff --git a/mod.ts b/mod.ts index 571ebc6..bfb3cc2 100644 --- a/mod.ts +++ b/mod.ts @@ -157,6 +157,64 @@ export const PIIParsers: ParserTuple< BIC.parser, ]; +/** + * Union of entity types considered sensitive — extends {@link PIIEntity} with + * protocol-qualified URLs (https/http/ftp), which routinely carry reset tokens, + * user IDs, signed query parameters, and credentials. + * + * Bare domains (e.g. `example.com`) are intentionally excluded; use + * {@link Duckling} with {@link URL.parser} directly if you need those too. + */ +export type SensitiveEntity = PIIEntity | URLEntity; + +/** + * Pre-built parser tuple targeting sensitive entities. + * + * Includes everything in {@link PIIParsers} plus protocol-qualified URLs + * (`https://…`, `http://…`, `ftp://…`). Bare domains are **not** matched. + * + * @example + * ```ts + * import { Duckling, SensitiveParsers } from "@claudiu-ceia/ts-duckling"; + * + * Duckling(SensitiveParsers).redact( + * "Reset your password: https://example.com/reset?token=abc123", + * ); + * // → "Reset your password: ████████████████████████████████████████" + * ``` + */ +export const SensitiveParsers: ParserTuple< + [ + EmailEntity, + PhoneEntity, + IPAddressEntity, + SSNEntity, + CreditCardEntity, + UUIDEntity, + ApiKeyEntity, + IBANEntity, + MACAddressEntity, + JWTEntity, + CryptoAddressEntity, + BICEntity, + URLEntity, + ] +> = [ + Email.parser, + Phone.parser, + IPAddress.parser, + SSN.parser, + CreditCard.parser, + UUID.parser, + ApiKey.parser, + IBAN.parser, + MACAddress.parser, + JWT.parser, + CryptoAddress.parser, + BIC.parser, + dot(URL.Full), +]; + type NonEmptyArray = [T, ...T[]]; type ParserTuple> = { [K in keyof T]: Parser; diff --git a/tests/redact.test.ts b/tests/redact.test.ts index ed6bf47..9ce6519 100644 --- a/tests/redact.test.ts +++ b/tests/redact.test.ts @@ -5,8 +5,12 @@ import { Email, IPAddress, Phone, + type PIIEntity, + type SensitiveEntity, + SensitiveParsers, SSN, URL, + type URLEntity, UUID, } from "../mod.ts"; @@ -173,3 +177,48 @@ Deno.test("redact: result length matches input length with single-char mask", () const result = Duckling([SSN.parser]).redact(input); assertEquals(result.length, input.length); }); + +// --------------------------------------------------------------------------- +// SensitiveParsers preset +// --------------------------------------------------------------------------- + +Deno.test("SensitiveParsers: redacts protocol-qualified URLs", () => { + const url = "https://example.com/reset?token=abc123"; + const prefix = "Reset your password: "; + const result = Duckling(SensitiveParsers).redact(prefix + url); + assertEquals(result, prefix + "█".repeat(url.length)); +}); + +Deno.test("SensitiveParsers: does not redact bare domains", () => { + const result = Duckling(SensitiveParsers).redact( + "Visit example.com for help", + ); + assertEquals(result, "Visit example.com for help"); +}); + +Deno.test("SensitiveParsers: redacts PII alongside full URLs", () => { + const input = "Email a@b.com, see https://example.com/reset?token=xyz"; + const result = Duckling(SensitiveParsers).redact(input); + // Email should be redacted (no @ remaining) + assertEquals(result.includes("@"), false); + // Full URL should be redacted (no :// remaining) + assertEquals(result.includes("://"), false); +}); + +Deno.test("SensitiveParsers: bare domain is not redacted when full URL is also present", () => { + const input = "Docs at docs.example.com or https://example.com/docs"; + const result = Duckling(SensitiveParsers).redact(input); + // bare domain should not be redacted + assertEquals(result.startsWith("Docs at docs.example.com"), true); + // full URL should be redacted (no protocol prefix remaining) + assertEquals(result.includes("://"), false); +}); + +// Type-level checks: confirm preset membership at compile time. +// These are intentionally no-op at runtime; they fail to compile if wrong. +Deno.test("SensitiveParsers: type membership — URLEntity extends SensitiveEntity", () => { + const _assertURLEntity: SensitiveEntity = null as unknown as URLEntity; + // PIIEntity is a subset of SensitiveEntity + const _assertPIIEntity: SensitiveEntity = null as unknown as PIIEntity; + assertEquals(true, true); +});