Skip to content
Open
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
10 changes: 8 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

156 changes: 156 additions & 0 deletions packages/transformabl-core/__tests__/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,162 @@ describe("detectPii", () => {
});
});

// ─────────────────────────────────────────────────────────────────────
// Presidio-parity recognizers (v0.4.0)
// ─────────────────────────────────────────────────────────────────────

describe("us_bank_number", () => {
it("detects 10-digit bank account", () => {
const matches = detectPii("account 9845302145 for deposit");
expect(matches.some((m) => m.type === "us_bank_number")).toBe(true);
});

it("detects 17-digit routing/account combos", () => {
const matches = detectPii("wire to 12345678901234567");
expect(matches.some((m) => m.type === "us_bank_number")).toBe(true);
});

it("does NOT match 7-digit values (too short)", () => {
const matches = detectPii("code 1234567");
expect(matches.some((m) => m.type === "us_bank_number")).toBe(false);
});
});

describe("us_itin", () => {
it("detects ITIN format 9XX-7X-XXXX", () => {
const matches = detectPii("ITIN: 912-78-5551");
expect(matches.some((m) => m.type === "us_itin")).toBe(true);
});

it("detects ITIN format 9XX-8X-XXXX", () => {
const matches = detectPii("taxpayer 988-83-0099");
expect(matches.some((m) => m.type === "us_itin")).toBe(true);
});

it("regular SSN 123-45-6789 does not match ITIN pattern", () => {
const matches = detectPii("ssn 123-45-6789");
expect(matches.some((m) => m.type === "us_itin")).toBe(false);
});
});

describe("us_passport", () => {
it("detects 9-digit passport", () => {
const matches = detectPii("passport 123456789 expires");
expect(matches.some((m) => m.type === "us_passport")).toBe(true);
});

it("detects 1-letter + 8-digit format", () => {
const matches = detectPii("passport A12345678");
expect(matches.some((m) => m.type === "us_passport")).toBe(true);
});
});

describe("us_drivers_license", () => {
it("detects 1-letter + 7-digit DL", () => {
const matches = detectPii("DL D1234567 state CA");
expect(matches.some((m) => m.type === "us_drivers_license")).toBe(true);
});

it("detects DL: prefix form", () => {
const matches = detectPii("license DL:12345678");
expect(matches.some((m) => m.type === "us_drivers_license")).toBe(true);
});
});

describe("iban", () => {
it("detects German IBAN", () => {
const matches = detectPii("wire to DE89370400440532013000");
expect(matches.some((m) => m.type === "iban")).toBe(true);
});

it("detects UK IBAN", () => {
const matches = detectPii("acct GB82WEST12345698765432");
expect(matches.some((m) => m.type === "iban")).toBe(true);
});

it("does NOT match arbitrary long alphanum", () => {
const matches = detectPii("token ABCDEFGHIJKLMNOP12345");
expect(matches.some((m) => m.type === "iban")).toBe(false);
});
});

describe("phone (international)", () => {
it("detects +44 UK number", () => {
const matches = detectPii("call +44 20 7946 0958");
expect(matches.some((m) => m.type === "phone")).toBe(true);
});

it("detects +81 Japan number", () => {
const matches = detectPii("support +81-3-1234-5678");
expect(matches.some((m) => m.type === "phone")).toBe(true);
});
});

describe("ip_address (IPv6)", () => {
it("detects full-form IPv6", () => {
const matches = detectPii("addr 2001:0db8:85a3:0000:0000:8a2e:0370:7334");
expect(matches.some((m) => m.type === "ip_address")).toBe(true);
});

it("detects compressed IPv6", () => {
const matches = detectPii("addr 2001:db8::8a2e:370:7334");
expect(matches.some((m) => m.type === "ip_address")).toBe(true);
});
});

describe("icd_10", () => {
it("detects F32.9 (major depressive disorder)", () => {
const matches = detectPii("diagnosis code F32.9 confirmed");
expect(matches.some((m) => m.type === "icd_10")).toBe(true);
});

it("detects E11.65 (type 2 diabetes)", () => {
const matches = detectPii("pt has E11.65 on chart");
expect(matches.some((m) => m.type === "icd_10")).toBe(true);
});

it("detects code without decimals", () => {
const matches = detectPii("dx: Z00 routine");
expect(matches.some((m) => m.type === "icd_10")).toBe(true);
});
});

describe("icd_9", () => {
it("detects numeric ICD-9 with decimal", () => {
const matches = detectPii("legacy code 250.01 on record");
expect(matches.some((m) => m.type === "icd_9")).toBe(true);
});

it("detects V-code", () => {
const matches = detectPii("status V70.0");
expect(matches.some((m) => m.type === "icd_9")).toBe(true);
});
});

describe("npi", () => {
it("detects 10-digit NPI starting with 1", () => {
const matches = detectPii("provider NPI 1234567893 treating");
expect(matches.some((m) => m.type === "npi")).toBe(true);
});
});

describe("crypto_wallet", () => {
it("detects BTC legacy address", () => {
const matches = detectPii("send to 1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2");
expect(matches.some((m) => m.type === "crypto_wallet")).toBe(true);
});

it("detects BTC bech32 address", () => {
const matches = detectPii("payout bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
expect(matches.some((m) => m.type === "crypto_wallet")).toBe(true);
});

it("detects ETH address", () => {
const matches = detectPii("eth 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7");
expect(matches.some((m) => m.type === "crypto_wallet")).toBe(true);
});
});

describe("zero-width character bypass", () => {
it("detects email with zero-width space inside", () => {
const text = "contact john\[email protected] for info";
Expand Down
2 changes: 1 addition & 1 deletion packages/transformabl-core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@gatewaystack/transformabl-core",
"version": "0.3.0",
"version": "0.4.1",
"private": false,
"license": "MIT",
"type": "module",
Expand Down
9 changes: 9 additions & 0 deletions packages/transformabl-core/src/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ const PII_TO_REGULATORY: Record<string, RegulatoryCategory[]> = {
phone: ["gdpr"],
date_of_birth: ["gdpr", "coppa", "hipaa"],
ip_address: ["gdpr"],
us_bank_number: ["pci", "gdpr"],
us_itin: ["pci", "gdpr"],
us_passport: ["gdpr"],
us_drivers_license: ["gdpr"],
iban: ["pci", "gdpr"],
icd_10: ["hipaa"],
icd_9: ["hipaa"],
npi: ["hipaa"],
crypto_wallet: ["gdpr"],
};

/**
Expand Down
100 changes: 90 additions & 10 deletions packages/transformabl-core/src/detect.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// packages/transformabl-core/src/detect.ts
//
// Regex-based PII detection.
// Regex-based PII detection. Recognizer set tracks Microsoft Presidio's
// default recognizer list (US-focused) plus healthcare-specific additions
// that are standard under HIPAA but not covered by Presidio's core list.
//
// FUTURE WORK:
// - ML-based NER detection (plug in spaCy, Presidio, or cloud NER APIs)
// - Address detection (street addresses, zip codes)
// - Name detection (requires NER - too many false positives with regex)
// - International phone number formats
// - Passport numbers, driver's license numbers
// - Country-specific identifiers (UK NHS, AU ABN, IN PAN, etc.)

import type { PiiType, PiiMatch } from "./types.js";
import { stripInvisible } from "./normalize.js";
Expand All @@ -24,35 +25,105 @@ interface PiiPattern {
const BUILTIN_PATTERNS: PiiPattern[] = [
{
type: "email",
pattern: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
// Quantifiers are bounded to RFC 5321 limits (local ≤64, domain ≤255,
// TLD ≤24). The previous unbounded `+…+` form around a single mandatory
// `@` was quadratic (O(n²)) — a long alphanumeric run with no `@` made
// the engine scan-to-end-then-backtrack from every start position, so a
// ~1 MB string of `a`s could block the event loop for minutes (ReDoS).
// Bounding the work per start position makes it effectively linear.
pattern: /[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,255}\.[a-zA-Z]{2,24}/g,
},
{
type: "phone",
// US phone numbers (10+ digits): (xxx) xxx-xxxx, xxx-xxx-xxxx, +1xxxxxxxxxx
// Requires area code (3 digits) + 7-digit number to avoid matching short numbers
pattern: /(?:\+1[-.\s]?)\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\(?\d{3}\)?[-.\s]\d{3}[-.\s]?\d{4}\b/g,
// US 10-digit formats AND international E.164 (+<country><7-14 digits>).
pattern:
/(?:\+1[-.\s]?)\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\(?\d{3}\)?[-.\s]\d{3}[-.\s]?\d{4}\b|\+(?!1[-.\s]?\d)\d{1,3}[-.\s]?\d{1,4}[-.\s]?\d{1,4}[-.\s]?\d{1,9}\b/g,
},
{
type: "ssn",
// US Social Security Numbers: xxx-xx-xxxx
pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
},
{
type: "us_itin",
// US Individual Taxpayer Identification Number: 9XX-(7X|8X)-XXXX where
// the middle group starts with 7, 8, or 9. Must be checked BEFORE the
// generic SSN pattern since ITIN overlaps structurally with SSN — ITIN
// is more specific, so order matters when dedup'ing overlapping hits.
pattern: /\b9\d{2}-[78]\d-\d{4}\b/g,
},
{
type: "credit_card",
// Major card formats: Visa (13-19), MC (16), Amex (15), Discover (16), Diners (14)
// Matches 13-19 digit sequences with optional separators (space or dash)
pattern: /\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2}|6(?:011|5\d{2})|3(?:0[0-5]|[68]\d)\d)[-\s]?\d{4,6}[-\s]?\d{4,5}(?:[-\s]?\d{1,4})?\b/g,
},
{
type: "us_bank_number",
// US bank account numbers: 8-17 digits. Loose by design — banks don't
// publish a format. Tightened with word boundaries + minimum length to
// avoid matching short IDs. Will false-positive on other long digit
// strings; acceptable for a governance layer that prefers over-
// redaction on financial context.
pattern: /\b\d{8,17}\b/g,
},
{
type: "us_passport",
// US passport: 9 digits (older) or 1 letter + 8 digits (newer).
pattern: /\b(?:[A-Z]\d{8}|\d{9})\b/g,
},
{
type: "us_drivers_license",
// US driver's license formats are state-specific. Approximation:
// 1 letter + 7-8 digits, or 7-9 all-digit sequences with "DL:" prefix.
// False-positive rate is non-trivial; callers can disable this type.
pattern: /\b(?:DL[:\s]?)?(?:[A-Z]\d{7,8}|\d{7,9})\b/g,
},
{
type: "iban",
// ISO 13616 IBAN: 2 letters (country) + 2 check digits + 11-30 alphanum.
// Optional spaces every 4 chars are stripped by normalize.ts before scan.
pattern: /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g,
},
{
type: "ip_address",
// IPv4 addresses
pattern: /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b/g,
// IPv4 OR IPv6 (full + compressed forms). Three alternatives:
// (1) IPv4 dotted-quad
// (2) IPv6 full form — 8 hex groups separated by `:`
// (3) IPv6 compressed — contains `::`, with hex groups on either side
pattern:
/\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b|\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|(?<![\w:])(?:[0-9a-fA-F]{1,4}:){1,7}:(?:[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4})*)?(?![\w:])/g,
},
{
type: "date_of_birth",
// Common date formats that might be DOBs: MM/DD/YYYY, DD-MM-YYYY, YYYY-MM-DD
pattern: /\b(?:\d{1,2}[/-]\d{1,2}[/-]\d{4}|\d{4}-\d{2}-\d{2})\b/g,
},
{
type: "icd_10",
// ICD-10-CM diagnosis codes: 1 letter + 2 digits + optional ".Xn" (up
// to 4 decimals). E.g. F32.9, E11.65, Z00. HIPAA PHI.
pattern: /\b[A-TV-Z][0-9][0-9AB](?:\.[0-9A-Z]{1,4})?\b/g,
},
{
type: "icd_9",
// ICD-9-CM: 3 digits + optional decimal + up to 2 digits, OR V/E codes.
// Legacy system but still present in historical records.
pattern: /\b(?:[VE]\d{2}(?:\.\d{1,2})?|\d{3}(?:\.\d{1,2})?)\b/g,
},
{
type: "npi",
// National Provider Identifier: 10 digits starting with 1 or 2.
// Mod-10 Luhn checksum validation is done post-match by checksum logic;
// regex just grabs candidates.
pattern: /\b[12]\d{9}\b/g,
},
{
type: "crypto_wallet",
// Bitcoin (P2PKH/P2SH base58 and bech32) + Ethereum (0x + 40 hex).
pattern:
/\b(?:bc1[a-z0-9]{25,39}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}|0x[a-fA-F0-9]{40})\b/g,
},
];

/**
Expand All @@ -70,6 +141,15 @@ export function detectPii(
// obfuscated span — including the invisible chars themselves.
const { text: scanText, map } = stripInvisible(text);

// Defense-in-depth against pathological inputs: cap the length we scan.
// Even with bounded patterns, running every pattern over an unbounded body
// is a DoS lever (this is an agent/LLM gateway — large tool-call payloads
// are normal). PII beyond this cap is not scanned; callers handling very
// large bodies should chunk. 512 KB comfortably covers real tool I/O.
const MAX_SCAN_LENGTH = 512 * 1024;
const boundedScanText =
scanText.length > MAX_SCAN_LENGTH ? scanText.slice(0, MAX_SCAN_LENGTH) : scanText;

const matches: PiiMatch[] = [];
const allPatterns: Array<{ type: string; pattern: RegExp }> = [
...BUILTIN_PATTERNS,
Expand All @@ -81,7 +161,7 @@ export function detectPii(
const regex = new RegExp(pattern.source, pattern.flags);
let match: RegExpExecArray | null;

while ((match = regex.exec(scanText)) !== null) {
while ((match = regex.exec(boundedScanText)) !== null) {
const s = match.index;
const e = match.index + match[0].length;

Expand Down
16 changes: 15 additions & 1 deletion packages/transformabl-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,26 @@

/** Categories of PII that can be detected. */
export type PiiType =
// Universal
| "email"
| "phone"
| "ssn"
| "credit_card"
| "ip_address"
| "date_of_birth";
| "date_of_birth"
// US identifiers (Presidio parity)
| "us_bank_number"
| "us_itin"
| "us_passport"
| "us_drivers_license"
// International banking
| "iban"
// Healthcare (HIPAA PHI)
| "icd_10"
| "icd_9"
| "npi"
// Financial
| "crypto_wallet";

/** A single PII detection match. */
export interface PiiMatch {
Expand Down
Loading