Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
58 changes: 58 additions & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...T[]];
type ParserTuple<T extends NonEmptyArray<unknown>> = {
[K in keyof T]: Parser<T[K]>;
Expand Down
49 changes: 49 additions & 0 deletions tests/redact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import {
Email,
IPAddress,
Phone,
type PIIEntity,
type SensitiveEntity,
SensitiveParsers,
SSN,
URL,
type URLEntity,
UUID,
} from "../mod.ts";

Expand Down Expand Up @@ -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);
});