Skip to content

Commit 72f2f47

Browse files
committed
feat: more PII parsers
1 parent 73dd09a commit 72f2f47

14 files changed

Lines changed: 965 additions & 67 deletions

README.md

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ npx jsr add @claudiu-ceia/ts-duckling
110110

111111
### Extract entities
112112

113-
Call `Duckling()` with no arguments to use **all 15 built-in parsers**:
113+
Call `Duckling()` with no arguments to use **all 18 built-in parsers**:
114114

115115
```ts
116116
import { Duckling } from "@claudiu-ceia/ts-duckling";
@@ -362,23 +362,33 @@ const entities = Duckling([Email.parser, Hashtag.parser]).extract(
362362

363363
## Supported entities
364364

365-
| Entity | Kind | Example match | Notes |
366-
| --------------- | ------------- | ----------------------------------------- | ------------------------------------- |
367-
| **Time** | `time` | `tomorrow at 3pm`, `2024-01-15T10:30:00Z` | Relative, day-of-week, ISO timestamps |
368-
| **Range** | `range` | `2020-2024`, `20°C to 30°C` | Time, year, and temperature ranges |
369-
| **Temperature** | `temperature` | `72°F`, `20 celsius` | Fahrenheit and Celsius |
370-
| **Quantity** | `quantity` | `5 kg`, `100 miles` | Units of measurement |
371-
| **Location** | `location` | `United States`, `Germany` | Countries (dataset-backed) |
372-
| **URL** | `url` | `https://example.com/path` | Full URLs with TLD validation |
373-
| **Email** | `email` | `user@example.com` | Standard email addresses |
374-
| **Institution** | `institution` | `University of Oxford` | Known institutions |
375-
| **Language** | `language` | `English`, `Japanese` | Language names (dataset-backed) |
376-
| **Phone** | `phone` | `+14155552671` | E.164-ish phone numbers |
377-
| **IP address** | `ip_address` | `192.168.1.1`, `::1` | IPv4 + IPv6 full form |
378-
| **SSN** | `ssn` | `123-45-6789` | US Social Security Numbers |
379-
| **Credit card** | `credit_card` | `4111111111111111` | Luhn-validated card numbers |
380-
| **UUID** | `uuid` | `550e8400-e29b-41d4-a716-446655440000` | RFC 4122 UUIDs |
381-
| **API key** | `api_key` | `sk-abc123...`, `AKIA...` | Common provider prefixes |
365+
| Entity | Kind | Example matches |
366+
| --------------- | -------------- | --------------------------------------------------------- |
367+
| **Time** | `time` | `tomorrow at 3pm`, `next Friday`, `2024-01-15T10:30:00Z` |
368+
| **Range** | `range` | `2020-2024`, `20°C to 30°C`, `Monday to Friday` |
369+
| **Temperature** | `temperature` | `72°F`, `20 celsius`, `-5°C` |
370+
| **Quantity** | `quantity` | `5 kg`, `100 miles`, `3,500.00` |
371+
| **Location** | `location` | `United States`, `Germany`, `Japan` |
372+
| **URL** | `url` | `https://example.com/path?q=1` |
373+
| **Institution** | `institution` | `University of Oxford`, `New York City Hall` |
374+
| **Language** | `language` | `English`, `Japanese`, `Portuguese` |
375+
376+
### PII
377+
378+
Available via `PIIParsers` for targeted redaction with `Duckling(PIIParsers).redact(…)`.
379+
380+
| Entity | Kind | Example matches |
381+
| --------------- | -------------- | --------------------------------------------------------- |
382+
| **Email** | `email` | `user@example.com`, `first.last@company.io` |
383+
| **Phone** | `phone` | `+14155552671`, `+44 20 7123 4567`, `(415) 555-2671` |
384+
| **IP address** | `ip` | `192.168.1.1`, `2001:db8::1`, `::1` |
385+
| **SSN** | `ssn` | `123-45-6789` |
386+
| **Credit card** | `credit_card` | `4111 1111 1111 1111`, `5500-0000-0000-0004` |
387+
| **UUID** | `uuid` | `550e8400-e29b-41d4-a716-446655440000` |
388+
| **API key** | `api_key` | `sk-proj-abc123…`, `ghp_abc123…`, `AKIA…` |
389+
| **IBAN** | `iban` | `GB29NWBK60161331926819`, `DE89 3704 0044 0532 0130 00` |
390+
| **MAC address** | `mac_address` | `00:1A:2B:3C:4D:5E`, `001A.2B3C.4D5E` |
391+
| **JWT** | `jwt` | `eyJhbGciOiJIUzI1NiIs…` |
382392

383393
## API reference
384394

@@ -405,7 +415,7 @@ function Duckling<T>(parsers: ParserTuple<T>): {
405415
};
406416
```
407417

408-
Creates an extractor/renderer/redactor. Without arguments, uses all 15 built-in
418+
Creates an extractor/renderer/redactor. Without arguments, uses all 18 built-in
409419
parsers and returns `AnyEntity[]`. When given an explicit parser array, the
410420
return type narrows to the union of those entity types.
411421

@@ -550,7 +560,7 @@ replace the span.
550560

551561
### `AnyEntity`
552562

553-
Union of all 15 built-in entity types. This is the return element type of
563+
Union of all 18 built-in entity types. This is the return element type of
554564
`Duckling().extract(...)`.
555565

556566
### `PIIEntity`

mod.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ import { SSN, type SSNEntity } from "./src/SSN.ts";
3232
import { CreditCard, type CreditCardEntity } from "./src/CreditCard.ts";
3333
import { UUID, type UUIDEntity } from "./src/UUID.ts";
3434
import { ApiKey, type ApiKeyEntity } from "./src/ApiKey.ts";
35+
import { IBAN, type IBANEntity } from "./src/IBAN.ts";
36+
import { MACAddress, type MACAddressEntity } from "./src/MACAddress.ts";
37+
import { JWT, type JWTEntity } from "./src/JWT.ts";
3538

3639
/**
3740
* Union of all entity types produced by the built-in parsers.
@@ -55,7 +58,10 @@ export type AnyEntity =
5558
| SSNEntity
5659
| CreditCardEntity
5760
| UUIDEntity
58-
| ApiKeyEntity;
61+
| ApiKeyEntity
62+
| IBANEntity
63+
| MACAddressEntity
64+
| JWTEntity;
5965

6066
const DefaultParsers: [Parser<AnyEntity>, ...Parser<AnyEntity>[]] = [
6167
Range.parser,
@@ -73,13 +79,17 @@ const DefaultParsers: [Parser<AnyEntity>, ...Parser<AnyEntity>[]] = [
7379
CreditCard.parser,
7480
UUID.parser,
7581
ApiKey.parser,
82+
IBAN.parser,
83+
MACAddress.parser,
84+
JWT.parser,
7685
];
7786

7887
/**
7988
* Union of entity types considered Personally Identifiable Information (PII).
8089
*
8190
* Covers: email addresses, phone numbers, IP addresses, Social Security
82-
* Numbers, credit card numbers, UUIDs, and API keys.
91+
* Numbers, credit card numbers, UUIDs, API keys, IBANs, MAC addresses,
92+
* and JWTs.
8393
*/
8494
export type PIIEntity =
8595
| EmailEntity
@@ -88,7 +98,10 @@ export type PIIEntity =
8898
| SSNEntity
8999
| CreditCardEntity
90100
| UUIDEntity
91-
| ApiKeyEntity;
101+
| ApiKeyEntity
102+
| IBANEntity
103+
| MACAddressEntity
104+
| JWTEntity;
92105

93106
/**
94107
* Pre-built parser tuple targeting PII entities.
@@ -112,6 +125,9 @@ export const PIIParsers: ParserTuple<
112125
CreditCardEntity,
113126
UUIDEntity,
114127
ApiKeyEntity,
128+
IBANEntity,
129+
MACAddressEntity,
130+
JWTEntity,
115131
]
116132
> = [
117133
Email.parser,
@@ -121,6 +137,9 @@ export const PIIParsers: ParserTuple<
121137
CreditCard.parser,
122138
UUID.parser,
123139
ApiKey.parser,
140+
IBAN.parser,
141+
MACAddress.parser,
142+
JWT.parser,
124143
];
125144

126145
type NonEmptyArray<T> = [T, ...T[]];
@@ -483,3 +502,6 @@ export * from "./src/UUID.ts";
483502
export * from "./src/Institution.ts";
484503
export * from "./src/Language.ts";
485504
export * from "./src/ApiKey.ts";
505+
export * from "./src/IBAN.ts";
506+
export * from "./src/MACAddress.ts";
507+
export * from "./src/JWT.ts";

src/Email.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const Email: EmailLanguage = createLanguage<EmailLanguage>({
4949
seq(
5050
map(
5151
manyTill(
52-
any(letter(), digit(), str("."), str("-"), str("-"), str("+")),
52+
any(letter(), digit(), str("."), str("-"), str("+"), str("_")),
5353
str("@"),
5454
),
5555
(p) => p.join(""),

src/IBAN.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import {
2+
type Context,
3+
createLanguage,
4+
many1,
5+
map,
6+
optional,
7+
regex,
8+
repeat,
9+
seq,
10+
str,
11+
} from "@claudiu-ceia/combine";
12+
import type { Parser } from "@claudiu-ceia/combine";
13+
import { dot } from "./common.ts";
14+
import { ent, type Entity } from "./Entity.ts";
15+
import { guard } from "./guard.ts";
16+
17+
/**
18+
* IBAN entity (International Bank Account Number).
19+
*/
20+
export type IBANEntity = Entity<
21+
"iban",
22+
{
23+
iban: string;
24+
country: string;
25+
}
26+
>;
27+
28+
/**
29+
* Helper for constructing an `IBANEntity`.
30+
*/
31+
export const iban = (
32+
value: IBANEntity["value"],
33+
before: Context,
34+
after: Context,
35+
): IBANEntity => {
36+
return ent(value, "iban", before, after);
37+
};
38+
39+
// IBAN lengths per country (ISO 13616).
40+
// https://www.swift.com/standards/data-standards/iban-international-bank-account-number
41+
const IBAN_LENGTHS: Record<string, number> = {
42+
AL: 28,
43+
AD: 24,
44+
AT: 20,
45+
AZ: 28,
46+
BH: 22,
47+
BY: 28,
48+
BE: 16,
49+
BA: 20,
50+
BR: 29,
51+
BG: 22,
52+
CR: 22,
53+
HR: 21,
54+
CY: 28,
55+
CZ: 24,
56+
DK: 18,
57+
DO: 28,
58+
TL: 23,
59+
EG: 29,
60+
SV: 28,
61+
EE: 20,
62+
FO: 18,
63+
FI: 18,
64+
FR: 27,
65+
GE: 22,
66+
DE: 22,
67+
GI: 23,
68+
GR: 27,
69+
GL: 18,
70+
GT: 28,
71+
HU: 28,
72+
IS: 26,
73+
IQ: 23,
74+
IE: 22,
75+
IL: 23,
76+
IT: 27,
77+
JO: 30,
78+
KZ: 20,
79+
XK: 20,
80+
KW: 30,
81+
LV: 21,
82+
LB: 28,
83+
LY: 25,
84+
LI: 21,
85+
LT: 20,
86+
LU: 20,
87+
MK: 19,
88+
MT: 31,
89+
MR: 27,
90+
MU: 30,
91+
MC: 27,
92+
MD: 24,
93+
ME: 22,
94+
NL: 18,
95+
NO: 15,
96+
PK: 24,
97+
PS: 29,
98+
PL: 28,
99+
PT: 25,
100+
QA: 29,
101+
RO: 24,
102+
LC: 32,
103+
SM: 27,
104+
ST: 25,
105+
SA: 24,
106+
RS: 22,
107+
SC: 31,
108+
SK: 24,
109+
SI: 19,
110+
ES: 24,
111+
SD: 18,
112+
SE: 24,
113+
CH: 21,
114+
TN: 24,
115+
TR: 26,
116+
UA: 29,
117+
AE: 23,
118+
GB: 22,
119+
VA: 22,
120+
VG: 24,
121+
};
122+
123+
/**
124+
* Validate IBAN checksum using the ISO 7064 Mod 97-10 algorithm.
125+
*/
126+
const ibanChecksum = (raw: string): boolean => {
127+
const normalized = raw.replace(/\s/g, "").toUpperCase();
128+
// Move first 4 chars to end
129+
const rearranged = normalized.slice(4) + normalized.slice(0, 4);
130+
// Convert letters to numbers (A=10, B=11, ..., Z=35)
131+
let numStr = "";
132+
for (const ch of rearranged) {
133+
const code = ch.charCodeAt(0);
134+
if (code >= 65 && code <= 90) {
135+
numStr += (code - 55).toString();
136+
} else {
137+
numStr += ch;
138+
}
139+
}
140+
// Mod 97 on the large number (process in chunks to avoid BigInt)
141+
let remainder = 0;
142+
for (let i = 0; i < numStr.length; i++) {
143+
remainder = (remainder * 10 + parseInt(numStr[i], 10)) % 97;
144+
}
145+
return remainder === 1;
146+
};
147+
148+
const isValidIBAN = (raw: string): boolean => {
149+
const normalized = raw.replace(/\s/g, "").toUpperCase();
150+
const country = normalized.slice(0, 2);
151+
const expectedLen = IBAN_LENGTHS[country];
152+
if (!expectedLen) return false;
153+
if (normalized.length !== expectedLen) return false;
154+
return ibanChecksum(raw);
155+
};
156+
157+
// Leaf tokens
158+
const upperLetter = regex(/[A-Z]/, "uppercase letter");
159+
const ibanDigit = regex(/\d/, "digit");
160+
const alphanumGroup = regex(/[A-Z0-9]{1,4}/, "BBAN group");
161+
162+
type IBANLanguage = {
163+
/** Country code: 2 uppercase letters */
164+
Country: Parser<string>;
165+
/** Check digits: exactly 2 digits */
166+
CheckDigits: Parser<string>;
167+
/** BBAN body: groups of 1-4 alphanumeric chars, optionally space-separated */
168+
BBAN: Parser<string>;
169+
/** Full IBAN: country + check + BBAN, validated */
170+
Raw: Parser<string>;
171+
Full: Parser<IBANEntity>;
172+
parser: Parser<IBANEntity>;
173+
};
174+
175+
/**
176+
* IBAN parser language.
177+
*
178+
* Structure: `CC` `DD` `BBAN...` where CC is the country code, DD the check
179+
* digits, and BBAN is 3-8 groups of 1-4 alphanumeric characters (optionally
180+
* space-separated). Validated with ISO 7064 Mod 97-10 checksum and per-country
181+
* length checks.
182+
*/
183+
export const IBAN: IBANLanguage = createLanguage<IBANLanguage>({
184+
// Two uppercase letters
185+
Country: () => map(repeat(2, upperLetter), (letters) => letters.join("")),
186+
187+
// Two digits
188+
CheckDigits: () => map(repeat(2, ibanDigit), (digits) => digits.join("")),
189+
190+
// BBAN: multiple groups of 1-4 alphanumerics with optional spaces between.
191+
// Each group is preceded by an optional space, so the first group handles
192+
// the space between check digits and BBAN in the spaced form (GB29 NWBK...).
193+
// `many` stays at the last successful group — it does NOT consume a trailing
194+
// space when the next group fails (lowercase text like "please").
195+
BBAN: () =>
196+
map(
197+
many1(seq(optional(str(" ")), alphanumGroup)),
198+
(parts) => parts.map(([, g]) => g).join(""),
199+
),
200+
201+
// Assemble and validate
202+
Raw: (s) =>
203+
guard(
204+
map(
205+
seq(s.Country, s.CheckDigits, s.BBAN),
206+
(_, b, a) => b.text.substring(b.index, a.index),
207+
),
208+
isValidIBAN,
209+
),
210+
211+
Full: (s) =>
212+
map(s.Raw, (raw, b, a) => {
213+
const normalized = raw.replace(/\s/g, "").toUpperCase();
214+
return iban(
215+
{
216+
iban: normalized,
217+
country: normalized.slice(0, 2),
218+
},
219+
b,
220+
a,
221+
);
222+
}),
223+
parser: (s) => dot(s.Full),
224+
});

0 commit comments

Comments
 (0)