@link-foundation/sensitive-data-sanitizer 0.19.3 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +22 -2
  3. package/bin/sensitive-data-sanitizer.js +50 -28
  4. package/docs/API.md +355 -16
  5. package/docs/COVERAGE.md +19 -9
  6. package/docs/RULES.md +218 -0
  7. package/examples/second-pass-placeholders.mjs +28 -0
  8. package/package.json +12 -3
  9. package/src/allow-list.js +93 -0
  10. package/src/confidence.js +24 -9
  11. package/src/context.js +287 -40
  12. package/src/credential-spans.js +180 -0
  13. package/src/credentials-entry.d.ts +23 -0
  14. package/src/credentials-entry.js +62 -0
  15. package/src/credentials.js +51 -0
  16. package/src/crypto-addresses.js +74 -0
  17. package/src/detection.js +87 -41
  18. package/src/document-person-cues.js +1 -1
  19. package/src/encoded.js +137 -51
  20. package/src/encrypt.js +95 -0
  21. package/src/engines.js +33 -10
  22. package/src/entities.js +4 -3
  23. package/src/exempt.js +72 -0
  24. package/src/fake-memory.js +32 -0
  25. package/src/fake.js +33 -29
  26. package/src/features.js +29 -0
  27. package/src/files.js +57 -12
  28. package/src/gitleaks.js +82 -27
  29. package/src/html-tags.js +8 -0
  30. package/src/identifier-detector.js +36 -0
  31. package/src/identity-types.js +63 -0
  32. package/src/identity.js +3 -47
  33. package/src/index.d.ts +157 -3
  34. package/src/index.js +13 -0
  35. package/src/known.js +33 -15
  36. package/src/national-checks.js +209 -0
  37. package/src/national.js +310 -92
  38. package/src/pattern-detector.js +219 -0
  39. package/src/personal-context.js +3 -2
  40. package/src/personal-evidence.js +233 -0
  41. package/src/personal-features.js +39 -0
  42. package/src/personal.js +66 -10
  43. package/src/placeholder-types.js +154 -4
  44. package/src/placeholders.js +127 -45
  45. package/src/projection.js +78 -26
  46. package/src/proper-names.js +48 -5
  47. package/src/prose-words.js +120 -0
  48. package/src/record-worker.js +1 -0
  49. package/src/rule-catalog.js +426 -0
  50. package/src/rules.js +8 -0
  51. package/src/sanitizer.js +133 -23
  52. package/src/sarif.js +127 -0
  53. package/src/stream-worker.js +1 -0
  54. package/src/strict-attributes.js +48 -4
  55. package/src/strict.js +1 -1
  56. package/src/structured.js +2 -0
  57. package/src/token-checksum.js +49 -0
  58. package/src/transforms.js +146 -33
  59. package/src/vendor/betterleaks/LICENSE +21 -0
  60. package/src/vendor/betterleaks/provenance.json +11 -0
  61. package/src/vendor/betterleaks/rules-0.json +463 -0
  62. package/src/vendor/betterleaks/rules-1.json +454 -0
  63. package/src/vendor/betterleaks/rules-2.json +461 -0
  64. package/src/vendor/betterleaks/rules-3.json +455 -0
  65. package/src/vendor/betterleaks/rules-4.json +483 -0
  66. package/src/vendor/betterleaks/rules-5.json +35 -0
  67. package/src/vendor/entities/aws.json +1 -1
  68. package/src/vendor/entities/azure.json +6 -2
  69. package/src/vendor/entities/google.json +2 -1
  70. package/src/vocabulary.js +27 -1
  71. package/src/worker-options.js +1 -0
@@ -0,0 +1,219 @@
1
+ import { failure, escapePattern } from './detection.js';
2
+
3
+ // Declarative recognizers in the style of Presidio's PatternRecognizer
4
+ // (patterns with scores, deny lists, context words, validate_result) and
5
+ // Google custom infoTypes (regex/dictionary plus hotword likelihood rules).
6
+ // The result is an ordinary Detector, so it composes with every other port.
7
+ const typePattern = /^[A-Z][A-Z0-9_]{0,63}$/;
8
+ const rulePattern = /^[a-z0-9@/._-]{1,120}$/;
9
+ const maxPatterns = 64,
10
+ maxWords = 10000;
11
+
12
+ const score = (value) =>
13
+ typeof value === 'number' &&
14
+ Number.isFinite(value) &&
15
+ value >= 0 &&
16
+ value <= 1;
17
+ const optional = (value, test) => value === undefined || test(value);
18
+ const words = (list) =>
19
+ Array.isArray(list) &&
20
+ list.length <= maxWords &&
21
+ list.every((word) => typeof word === 'string' && word.trim().length > 0);
22
+
23
+ const validEntry = (raw) =>
24
+ Boolean(raw) &&
25
+ (raw.regex instanceof RegExp ||
26
+ (typeof raw.regex === 'string' && raw.regex.length > 0)) &&
27
+ optional(raw.confidence, score) &&
28
+ optional(raw.group, (group) => Number.isSafeInteger(group) && group >= 0);
29
+
30
+ function globalRegex(regex) {
31
+ try {
32
+ return regex instanceof RegExp
33
+ ? new RegExp(
34
+ regex.source,
35
+ regex.flags.includes('g') ? regex.flags : `${regex.flags}g`
36
+ )
37
+ : new RegExp(regex, 'gu');
38
+ } catch {
39
+ throw failure('ERR_CONFIG');
40
+ }
41
+ }
42
+
43
+ function compile(entry) {
44
+ const raw =
45
+ entry instanceof RegExp || typeof entry === 'string'
46
+ ? { regex: entry }
47
+ : entry;
48
+ if (!validEntry(raw)) {
49
+ throw failure('ERR_CONFIG');
50
+ }
51
+ return {
52
+ regex: globalRegex(raw.regex),
53
+ confidence: raw.confidence,
54
+ group: raw.group ?? 0,
55
+ };
56
+ }
57
+
58
+ function window(value) {
59
+ if (value === undefined) {
60
+ return { before: 64, after: 16 };
61
+ }
62
+ const shape = typeof value === 'number' ? { before: value, after: 0 } : value;
63
+ if (
64
+ !shape ||
65
+ ![shape.before ?? 0, shape.after ?? 0].every(
66
+ (n) => Number.isSafeInteger(n) && n >= 0 && n <= 4096
67
+ )
68
+ ) {
69
+ throw failure('ERR_CONFIG');
70
+ }
71
+ return { before: shape.before ?? 0, after: shape.after ?? 0 };
72
+ }
73
+
74
+ const specChecks = [
75
+ (spec) => typePattern.test(spec.type ?? ''),
76
+ (spec) =>
77
+ optional(spec.category, (c) => ['personal', 'credential'].includes(c)),
78
+ (spec) => optional(spec.rule, (rule) => rulePattern.test(rule)),
79
+ (spec) => optional(spec.confidence, score),
80
+ (spec) => optional(spec.denyListConfidence, score),
81
+ (spec) =>
82
+ optional(
83
+ spec.patterns,
84
+ (list) => Array.isArray(list) && list.length <= maxPatterns
85
+ ),
86
+ (spec) => optional(spec.denyList, words),
87
+ (spec) => optional(spec.context, words),
88
+ (spec) =>
89
+ optional(
90
+ spec.contextBoost,
91
+ (boost) => typeof boost === 'number' && Math.abs(boost) <= 1
92
+ ),
93
+ (spec) => optional(spec.validate, (fn) => typeof fn === 'function'),
94
+ (spec) => Boolean(spec.patterns?.length || spec.denyList?.length),
95
+ ];
96
+
97
+ function compileSpec(spec) {
98
+ if (
99
+ !spec ||
100
+ typeof spec !== 'object' ||
101
+ !specChecks.every((check) => check(spec))
102
+ ) {
103
+ throw failure('ERR_CONFIG');
104
+ }
105
+ const patterns = (spec.patterns ?? []).map(compile);
106
+ if (spec.denyList?.length) {
107
+ patterns.push({
108
+ regex: new RegExp(
109
+ `(?<![\\p{L}\\p{N}_])(?:${[...spec.denyList]
110
+ .sort((a, b) => b.length - a.length)
111
+ .map(escapePattern)
112
+ .join('|')})(?![\\p{L}\\p{N}_])`,
113
+ spec.caseSensitive ? 'gu' : 'giu'
114
+ ),
115
+ confidence: spec.denyListConfidence ?? 1,
116
+ group: 0,
117
+ });
118
+ }
119
+ const context = spec.context?.length
120
+ ? new RegExp(
121
+ `(?<![\\p{L}\\p{N}])(?:${spec.context.map(escapePattern).join('|')})(?![\\p{L}\\p{N}])`,
122
+ 'iu'
123
+ )
124
+ : undefined;
125
+ return Object.freeze({
126
+ type: spec.type,
127
+ category: spec.category ?? 'personal',
128
+ rule: spec.rule ?? 'custom-pattern',
129
+ confidence: spec.confidence ?? 0.6,
130
+ patterns,
131
+ context,
132
+ window: window(spec.contextWindow),
133
+ boost: spec.contextBoost ?? 0.35,
134
+ validate: spec.validate,
135
+ });
136
+ }
137
+
138
+ function scored(spec, base, text, start, end, value) {
139
+ let confidence = base;
140
+ if (
141
+ spec.context?.test(
142
+ `${text.slice(Math.max(0, start - spec.window.before), start)}\n${text.slice(end, end + spec.window.after)}`
143
+ )
144
+ ) {
145
+ // Presidio raises a context-supported score by 0.35 with a 0.4 floor;
146
+ // a negative boost models Google's "decrease likelihood" hotwords.
147
+ confidence =
148
+ spec.boost >= 0
149
+ ? Math.max(0.4, confidence + spec.boost)
150
+ : confidence + spec.boost;
151
+ }
152
+ if (spec.validate) {
153
+ const verdict = spec.validate(value);
154
+ if (verdict === false) {
155
+ return undefined;
156
+ }
157
+ if (typeof verdict === 'number' && score(verdict)) {
158
+ confidence = verdict;
159
+ }
160
+ }
161
+ return Math.min(1, Math.max(0, confidence));
162
+ }
163
+
164
+ function* matches(spec, text) {
165
+ for (const pattern of spec.patterns) {
166
+ pattern.regex.lastIndex = 0;
167
+ for (const match of text.matchAll(pattern.regex)) {
168
+ const value = match[pattern.group];
169
+ if (!value) {
170
+ continue;
171
+ }
172
+ const start = match.index + (pattern.group ? match[0].indexOf(value) : 0);
173
+ yield {
174
+ start,
175
+ end: start + value.length,
176
+ value,
177
+ base: pattern.confidence ?? spec.confidence,
178
+ };
179
+ }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Builds a synchronous Detector from declarative specs. Each spec matches
185
+ * `patterns` (RegExp, string sources or `{regex, confidence, group}`) and/or
186
+ * a whole-word `denyList`, raises or lowers confidence when a `context` word
187
+ * occurs within `contextWindow`, and lets `validate(value)` reject a match
188
+ * (false) or set its confidence (0–1). Patterns are trusted configuration:
189
+ * avoid catastrophic backtracking in caller-supplied expressions.
190
+ */
191
+ export function createPatternDetector(specs, { id = 'pattern-rules' } = {}) {
192
+ const list = Array.isArray(specs) ? specs : [specs];
193
+ if (!list.length || list.length > maxPatterns) {
194
+ throw failure('ERR_CONFIG');
195
+ }
196
+ const compiled = list.map(compileSpec);
197
+ return {
198
+ id,
199
+ detect(text) {
200
+ const findings = [];
201
+ for (const spec of compiled) {
202
+ for (const { start, end, value, base } of matches(spec, text)) {
203
+ const confidence = scored(spec, base, text, start, end, value);
204
+ if (confidence !== undefined) {
205
+ findings.push({
206
+ start,
207
+ end,
208
+ type: spec.type,
209
+ category: spec.category,
210
+ rule: spec.rule,
211
+ confidence,
212
+ });
213
+ }
214
+ }
215
+ }
216
+ return findings;
217
+ },
218
+ };
219
+ }
@@ -6,8 +6,9 @@ export const months =
6
6
  'January February March April May June July August September October November December Jan Feb Mar Apr Jun Jul Aug Sep Sept Oct Nov Dec января февраля марта апреля мая июня июля августа сентября октября ноября декабря январь февраль март апрель май июнь июль август сентябрь октябрь ноябрь декабрь'
7
7
  .split(' ')
8
8
  .join('|');
9
+ const ordinal = '(?:st|nd|rd|th)?';
9
10
  const dates = new RegExp(
10
- `(?<![\\p{L}\\p{N}_])(?:\\d{4}-\\d{1,2}-\\d{1,2}|\\d{1,2}[/.]\\d{1,2}[/.]\\d{4}|\\d{1,2}[ \\t]+(?:${months})[ \\t]+\\d{4}|(?:${months})[ \\t]+\\d{1,2},?[ \\t]+\\d{4})(?![\\p{L}\\p{N}_])`,
11
+ `(?<![\\p{L}\\p{N}_])(?:\\d{4}-\\d{1,2}-\\d{1,2}|\\d{1,2}[/.-]\\d{1,2}[/.-]\\d{4}|\\d{1,2}${ordinal}[ \\t]+(?:of[ \\t]+)?(?:${months})\\.?,?[ \\t]+\\d{4}|(?:${months})\\.?[ \\t]+\\d{1,2}${ordinal},?[ \\t]+\\d{4})(?![\\p{L}\\p{N}_])`,
11
12
  'giu'
12
13
  );
13
14
  const phoneContext =
@@ -30,7 +31,7 @@ export function detectPersonalContext(text, emit) {
30
31
  );
31
32
  collectMatches(
32
33
  text,
33
- /(?<![\p{L}\p{N}_])(?:phone|fax|ĐT|điện thoại)(?:[ \t]*[::][ \t]*|[ \t]+)(\+?\(?\d[\d() .-]{5,38}\d)(?![\p{L}\p{N}_])/giu,
34
+ /(?<![\p{L}\p{N}_])(?:(?:phone|fax|ĐT|điện thoại)(?:[ \t]*[::][ \t]*|[ \t]+)|(?:telephone|tel|mobile|mob|cell|desk|ph)\.?[ \t]*[::][ \t]*)(\+?\(?\d[\d() .-]{5,38}\d)(?![\p{L}\p{N}_])/giu,
34
35
  emit,
35
36
  phone,
36
37
  1,
@@ -0,0 +1,233 @@
1
+ import { collectMatches } from './detection.js';
2
+ import { credentialPlaceholder } from './credentials.js';
3
+
4
+ // Phrase-level evidence collected from competitor test suites (#128):
5
+ // self-introductions, letter salutations and sign-offs, mail display names,
6
+ // spelled-out e-mail addresses, post-office boxes, labelled messenger
7
+ // handles, username/password pairs and labelled card numbers.
8
+ const personal = (type, rule, confidence = 0.85) => ({
9
+ type,
10
+ rule,
11
+ category: 'personal',
12
+ confidence,
13
+ });
14
+ const word = "\\p{Lu}[\\p{Ll}\\p{M}'’]+(?:\\p{Lu}[\\p{Ll}\\p{M}]+)?";
15
+ const nameWords = `${word}(?:-${word})?(?:[ \\t]+(?:${word}(?:-${word})?|\\p{Lu}\\.)){0,2}`;
16
+ // Addressees and senders that are roles, not people.
17
+ const roleWord =
18
+ /^(?:Sir|Madam|Madame|Sirs|All|Everyone|Everybody|Team|Folks|Friends?|Colleagues?|Customer|Client|User|Users|Members?|Support|Admin|Team[ \t].*|Hiring[ \t]+Manager|Recruiter|There|Guys|Again|You|Me|Us|Them|God|Mom|Dad|Sorry|Thanks|Yes|No|Not|Here|Also|Just|Back|Done|OK|Okay|The|Our|Your|My|This|World)$/iu;
19
+ const person = (value) =>
20
+ !roleWord.test(value) && !roleWord.test(value.split(/[ \t]+/)[0]);
21
+
22
+ const dash = '(?:-{1,2}|—|–|\\.{2,3}|~)';
23
+ function detectNames(text, emit) {
24
+ // "My name is Bond", "I'm called Jane Doe"
25
+ collectMatches(
26
+ text,
27
+ new RegExp(
28
+ `(?<![\\p{L}\\p{N}_])(?:[Mm]y[ \\t]+name[ \\t]+is|[Nn]ame's|I[ \\t]+am[ \\t]+called|[Cc]all[ \\t]+me)[ \\t]+(${nameWords})(?![\\p{L}\\p{M}])`,
29
+ 'gu'
30
+ ),
31
+ emit,
32
+ personal('PERSON', 'self-introduction', 0.9),
33
+ 1,
34
+ person
35
+ );
36
+ // "Dear Clifford," and "Hi Jane Doe," at the start of a line.
37
+ collectMatches(
38
+ text,
39
+ new RegExp(
40
+ `(?:^|(?<=[\\r\\n]))[ \\t]*(?:Dear|Hi|Hello|Hey|Greetings)[ \\t]+(${nameWords})[ \\t]*[,!:]`,
41
+ 'gmu'
42
+ ),
43
+ emit,
44
+ personal('PERSON', 'salutation', 0.85),
45
+ 1,
46
+ person
47
+ );
48
+ // "Thanks -Jon", "All the best,\n\n--Meg C.", "Thank you ..Risemamy McCrubben"
49
+ collectMatches(
50
+ text,
51
+ new RegExp(
52
+ `(?<![\\p{L}\\p{N}_])(?:Thanks|Thank[ \\t]+you|Many[ \\t]+thanks|Regards|Best[ \\t]+regards|Kind[ \\t]+regards|Warm[ \\t]+regards|Best[ \\t]+wishes|All[ \\t]+the[ \\t]+best|Cheers|Sincerely|Yours[ \\t]+(?:truly|sincerely)|Best)(?:[ \\t]*[,!.]?[ \\t]*(?:\\r?\\n[ \\t]*){1,3}${dash}?|[ \\t]*[,!.][ \\t]*${dash}?|[ \\t]*${dash})[ \\t]*(${nameWords})[ \\t]*\\.?[ \\t]*(?=\\r?\\n|$)`,
53
+ 'gu'
54
+ ),
55
+ emit,
56
+ personal('PERSON', 'sign-off', 0.85),
57
+ 1,
58
+ person
59
+ );
60
+ // Mail display names: `Bob Stone <bob.stone@example.com>`.
61
+ collectMatches(
62
+ text,
63
+ new RegExp(
64
+ `(?<![\\p{L}\\p{N}_])"?(${word}(?:[ \\t]+(?:${word}|\\p{Lu}\\.)){1,3})"?[ \\t]*<[^\\s<>@]+@[^\\s<>@]+>`,
65
+ 'gu'
66
+ ),
67
+ emit,
68
+ personal('PERSON', 'mail-display-name', 0.9),
69
+ 1,
70
+ person
71
+ );
72
+ }
73
+
74
+ const freemail =
75
+ 'gmail|googlemail|yahoo|ymail|hotmail|outlook|live|msn|icloud|me|mac|aol|proton|protonmail|pm|gmx|mail|yandex|zoho|fastmail|hey|tutanota|qq|163|126|naver|web';
76
+ const at =
77
+ '(?:[ \\t]*[[({<][ \\t]*at[ \\t]*[\\])}>][ \\t]*|[ \\t]+at[ \\t]+|@)';
78
+ const dot =
79
+ '(?:[ \\t]*[[({<][ \\t]*dot[ \\t]*[\\])}>][ \\t]*|[ \\t]+dot[ \\t]+|\\.)';
80
+ const obfuscatedEmail = new RegExp(
81
+ `(?<![\\p{L}\\p{N}_.+-])[\\p{L}\\p{N}_+-]+(?:${dot}[\\p{L}\\p{N}_+-]+)*${at}[\\p{L}\\p{N}-]+(?:${dot}[\\p{L}\\p{N}-]+)*${dot}\\p{L}{2,24}(?![\\p{L}\\p{N}_-])`,
82
+ 'giu'
83
+ );
84
+ // Spelled-out separators are evidence; a plain " at " needs a well-known
85
+ // mailbox provider so that "meet at example.com" stays prose.
86
+ function spelledEmail(value) {
87
+ const bracketed = /[[({<][ \t]*(?:at|dot)[ \t]*[\])}>]/i.test(value);
88
+ const spelled = /[ \t]dot[ \t]/i.test(value);
89
+ const plainAt = new RegExp(
90
+ `[ \\t]at[ \\t]+(?:${freemail})\\.[a-z]{2,6}(?:\\.[a-z]{2})?$`,
91
+ 'i'
92
+ ).test(value);
93
+ return (
94
+ !/^[^@]+@[^@\s[({<]+$/.test(value) && (bracketed || spelled || plainAt)
95
+ );
96
+ }
97
+
98
+ function detectContactForms(text, emit) {
99
+ collectMatches(
100
+ text,
101
+ obfuscatedEmail,
102
+ emit,
103
+ personal('EMAIL', 'obfuscated-email', 0.85),
104
+ 0,
105
+ spelledEmail
106
+ );
107
+ collectMatches(
108
+ text,
109
+ /(?<![\p{L}\p{N}_])(?:P\.?[ \t]?O\.?[ \t]?Box|Post[ \t]+Office[ \t]+Box|Postfach|Apartado(?:[ \t]+de[ \t]+correos)?|Caixa[ \t]+Postal|Boîte[ \t]+postale|а\/я|абонентский[ \t]+ящик)[ \t]*(?:#[ \t]*)?\d{1,10}(?!\p{N})/giu,
110
+ emit,
111
+ personal('ADDRESS', 'po-box')
112
+ );
113
+ // `skype: dean.malmgren`, `contact me on skype (dean.malmgren)`.
114
+ collectMatches(
115
+ text,
116
+ /(?<![\p{L}\p{N}_])(?:skype|discord|telegram|signal|wechat|whatsapp|keybase|matrix|mastodon|twitter|github|gitlab|instagram|tiktok)(?:[ \t]+(?:id|name|handle|username|user))?[ \t]*(?:[:=][ \t]*|\([ \t]*)(@?[\p{L}\p{N}_][\p{L}\p{N}_.:-]{1,63}[\p{L}\p{N}_])(?=[ \t]*\)|[\s,;!?]|\.(?:\s|$)|$)/giu,
117
+ emit,
118
+ personal('USERNAME', 'messenger-label', 0.85),
119
+ 1
120
+ );
121
+ }
122
+
123
+ // "login snoop pw biggreenhat", "u: snoop\np: biggreenhat",
124
+ // "user: thislibrary\npass: …": a username label followed by a password
125
+ // label marks both values; a lone `user:` is a chat role, not an account.
126
+ // Labels with a colon may be short (`u:`/`p:`); bare labels must be the
127
+ // unambiguous `login`/`username` and `pw`/`pwd`/`password` words.
128
+ const pairSeparator = '[ \\t]*[,;]?[ \\t]*(?:\\r?\\n[ \\t]*)?';
129
+ const credentialPair = new RegExp(
130
+ [
131
+ `(?:login|user(?:name)?|u)[ \\t]*[:=][ \\t]*([^\\s:=,;]{2,64})${pairSeparator}(?:pass(?:word)?|passwd|pwd|pw|p)[ \\t]*[:=][ \\t]*(\\S{4,128})`,
132
+ `(?:login|username)[ \\t]+([^\\s:=,;]{2,64})${pairSeparator}(?:password|passwd|pwd|pw)[ \\t]+(\\S{4,128})`,
133
+ ]
134
+ .map((form) => `(?<![\\p{L}\\p{N}_])${form}`)
135
+ .join('|'),
136
+ 'giu'
137
+ );
138
+ const placeholder = /^\[[\p{L}\p{N}_ -]+\]$/u;
139
+ function detectCredentialPairs(text, emit) {
140
+ for (const match of text.matchAll(credentialPair)) {
141
+ const user = match[1] ?? match[3];
142
+ const secret = match[2] ?? match[4];
143
+ if (
144
+ placeholder.test(user) ||
145
+ placeholder.test(secret) ||
146
+ credentialPlaceholder(secret)
147
+ ) {
148
+ continue;
149
+ }
150
+ const userStart = match.index + match[0].indexOf(user);
151
+ const secretStart = match.index + match[0].lastIndexOf(secret);
152
+ emit({
153
+ start: userStart,
154
+ end: userStart + user.length,
155
+ ...personal('USERNAME', 'credential-pair', 0.9),
156
+ });
157
+ emit({
158
+ start: secretStart,
159
+ end: secretStart + secret.length,
160
+ type: 'SECRET',
161
+ category: 'credential',
162
+ rule: 'credential-pair',
163
+ confidence: 0.9,
164
+ });
165
+ }
166
+ }
167
+
168
+ // "my AMEX card: 1234 567890 12345": a labelled card keeps its label even when
169
+ // the number is a documentation placeholder that fails Luhn.
170
+ function detectLabelledCards(text, emit) {
171
+ collectMatches(
172
+ text,
173
+ /(?<![\p{L}\p{N}_])(?:card|visa|mastercard|amex|discover|diners|jcb|unionpay|maestro|карта)(?:[ \t]+(?:no\.?|number|num|#))?[ \t]*(?:[:#][ \t]*)?((?:\d[ -]?){12,18}\d)(?![\p{N}]|[ -]\d)/giu,
174
+ emit,
175
+ personal('CREDIT_CARD', 'card-context', 0.9),
176
+ 1
177
+ );
178
+ }
179
+
180
+ // "SSN 030 72 7381", "social security number: 003 06-8815": the label
181
+ // carries spaced and mixed separators that the bare dashed form omits.
182
+ function detectLabelledSsn(text, emit) {
183
+ collectMatches(
184
+ text,
185
+ /(?<![\p{L}\p{N}_])(?:SSN|SS#|social[ \t]+security(?:[ \t]+(?:number|no\.?|#))?)[ \t]*(?:(?:[:#]|is)[ \t]*)?(\d{3}[ .-]?\d{2}[ .-]?\d{4})(?![\p{N}]|[ .-]\d)/giu,
186
+ emit,
187
+ personal('SSN', 'ssn-label', 0.9),
188
+ 1,
189
+ (value) => !/^(?:000|666|9\d\d)|^\d{3}\D?00|0000$/.test(value)
190
+ );
191
+ }
192
+
193
+ const states =
194
+ 'AL|AK|AZ|AR|CA|CO|CT|DE|DC|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|MN|MS|MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD|TN|TX|UT|VT|VA|WA|WV|WI|WY|PR';
195
+ function detectPostalCodes(text, emit) {
196
+ // "ZIP 94105", "ZIPs 10001 and 30301", "zip code: 94105-1234".
197
+ for (const label of text.matchAll(
198
+ /(?<![\p{L}\p{N}_])(?:ZIP(?:[ \t]?codes?)?s?|postal[ \t]+codes?|PLZ|CEP)[ \t]*(?:[:#][ \t]*)?/giu
199
+ )) {
200
+ const start = label.index + label[0].length;
201
+ const list =
202
+ /^\d{5}(?:-\d{4})?(?:(?:[ \t]*,[ \t]*|[ \t]+(?:and|or|&)[ \t]+)\d{5}(?:-\d{4})?)*(?![\p{L}\p{N}_-])/u.exec(
203
+ text.slice(start, start + 200)
204
+ );
205
+ for (const code of list?.[0].matchAll(/\d{5}(?:-\d{4})?/g) ?? []) {
206
+ emit({
207
+ start: start + code.index,
208
+ end: start + code.index + code[0].length,
209
+ ...personal('POSTAL_CODE', 'postal-label', 0.9),
210
+ });
211
+ }
212
+ }
213
+ // "New York City, NY 10002": a state abbreviation anchors the ZIP code.
214
+ collectMatches(
215
+ text,
216
+ new RegExp(
217
+ `(?<=[\\p{L}.],)[ \\t]*(?:${states})[ \\t]+(\\d{5}(?:-\\d{4})?)(?![\\p{L}\\p{N}_-])`,
218
+ 'gu'
219
+ ),
220
+ emit,
221
+ personal('POSTAL_CODE', 'state-zip', 0.85),
222
+ 1
223
+ );
224
+ }
225
+
226
+ export function detectPersonalEvidence(text, emit) {
227
+ detectNames(text, emit);
228
+ detectContactForms(text, emit);
229
+ detectCredentialPairs(text, emit);
230
+ detectLabelledCards(text, emit);
231
+ detectLabelledSsn(text, emit);
232
+ detectPostalCodes(text, emit);
233
+ }
@@ -0,0 +1,39 @@
1
+ // Registers the personal-data detectors and fake generators. The main entry
2
+ // imports this module for its side effect; the `./credentials` entry does not,
3
+ // which keeps faker, libphonenumber and these detectors out of its start-up.
4
+ import { registerFeatures } from './features.js';
5
+ import { detectPersonal } from './personal.js';
6
+ import { detectCatalog } from './entities.js';
7
+ import { detectIdentity, fakeIdentityValue } from './identity.js';
8
+ import { detectNational } from './national.js';
9
+ import { detectPersonalContext } from './personal-context.js';
10
+ import { detectPersonalEvidence } from './personal-evidence.js';
11
+ import { detectDocumentEntities } from './document-entities.js';
12
+ import {
13
+ detectProseAndDocuments,
14
+ validateProperNames,
15
+ } from './proper-names.js';
16
+ import { detectStrict } from './strict.js';
17
+ import { realisticFake } from './fake.js';
18
+ import { prepareFakeText } from './fake-entities.js';
19
+ import { fakeDocumentSpans } from './specimens.js';
20
+
21
+ function detectPersonalData(text, emit, options) {
22
+ detectPersonal(text, emit);
23
+ detectProseAndDocuments(text, emit, options, detectDocumentEntities);
24
+ detectCatalog(text, emit);
25
+ detectIdentity(text, emit);
26
+ detectNational(text, emit);
27
+ detectPersonalContext(text, emit);
28
+ detectPersonalEvidence(text, emit);
29
+ detectStrict(text, emit, options);
30
+ }
31
+
32
+ registerFeatures({
33
+ detectPersonalData,
34
+ validateProperNames,
35
+ realisticFake,
36
+ prepareFakeText,
37
+ fakeDocumentSpans,
38
+ fakeIdentityValue,
39
+ });
package/src/personal.js CHANGED
@@ -3,6 +3,7 @@ import { firstNames } from './names.js';
3
3
  import { labels } from './rules.js';
4
4
  import { transliteratedGivenNames } from './transliteration.js';
5
5
  import { serviceAccounts } from './public.js';
6
+ import { isCommonProseWord } from './prose-words.js';
6
7
  const labelWords = new Set(Object.values(labels).flat());
7
8
 
8
9
  const details = (type, rule, confidence = 0.85) => ({
@@ -11,6 +12,12 @@ const details = (type, rule, confidence = 0.85) => ({
11
12
  category: 'personal',
12
13
  confidence,
13
14
  });
15
+ // "John C Smith", "John A. Smith": a Latin-script middle initial. A bare "I"
16
+ // is the pronoun ("Will I Go"), so it needs its period.
17
+ const initial = (language) =>
18
+ ['ar', 'he', 'hi', 'th', 'ja', 'zh', 'ko'].includes(language)
19
+ ? ''
20
+ : '(?:[ \\t]+(?:\\p{Lu}\\.|(?!I[ \\t])\\p{Lu}(?=[ \\t])))?';
14
21
  const names = Object.entries(firstNames).map(([language, list]) => {
15
22
  const words = list.split(' ').map(escapePattern).join('|');
16
23
  const surname = ['ar', 'he', 'hi', 'th'].includes(language)
@@ -18,7 +25,7 @@ const names = Object.entries(firstNames).map(([language, list]) => {
18
25
  : "[\\p{Lu}][\\p{L}\\p{M}’'-]{1,32}";
19
26
  const pattern = ['ja', 'zh', 'ko'].includes(language)
20
27
  ? `(?:${words})[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]{1,4}`
21
- : `(?:${words})[ \\t]+${surname}${language === 'vi' ? `(?:[ \\t]+${surname})?` : ''}`;
28
+ : `(?:${words})${initial(language)}[ \\t]+${surname}${language === 'vi' ? `(?:[ \\t]+${surname})?` : ''}`;
22
29
  return new RegExp(`(?<![\\p{L}\\p{M}])${pattern}(?![\\p{L}\\p{M}])`, 'gu');
23
30
  });
24
31
 
@@ -33,7 +40,7 @@ const ibanLengths = Object.fromEntries(
33
40
  const mentionedEmail =
34
41
  /^\/[\p{L}\p{N}_.!#$%&'*+?^`{|}~-]+@[\p{L}\p{N}][\p{L}\p{N}.-]{0,252}\.\p{L}{2,63}(?![\p{L}\p{N}_-])/u;
35
42
  export function iban(value) {
36
- const compact = value.replace(/\s/g, '').toUpperCase();
43
+ const compact = value.replace(/[\s-]/g, '').toUpperCase();
37
44
  if (
38
45
  !/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(compact) ||
39
46
  compact.length !== ibanLengths[compact.slice(0, 2)]
@@ -53,7 +60,7 @@ export function iban(value) {
53
60
 
54
61
  function detectIban(text, emit) {
55
62
  for (const match of text.matchAll(
56
- /(?<![A-Z0-9])[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){8,40}/g
63
+ /(?<![A-Z0-9])[A-Z]{2} ?\d{2}(?:[ -]?[A-Z0-9]){8,40}/g
57
64
  )) {
58
65
  const needed = ibanLengths[match[0].slice(0, 2)];
59
66
  if (!needed) {
@@ -62,7 +69,7 @@ function detectIban(text, emit) {
62
69
  let end = 0,
63
70
  count = 0;
64
71
  while (end < match[0].length && count < needed) {
65
- if (match[0][end] !== ' ') {
72
+ if (!/[ -]/.test(match[0][end])) {
66
73
  count++;
67
74
  }
68
75
  end++;
@@ -180,11 +187,46 @@ function detectDocumentNames(text, emit) {
180
187
  }
181
188
  const russianFull =
182
189
  /(?<![\p{L}])(?:[А-ЯЁ][а-яё]{2,30}[ \t]+){2}[А-ЯЁ][а-яё]{2,30}(?:вич|вна|ична)|(?<![\p{L}])(?:[А-ЯЁ][а-яё]{2,30}[ \t]+){2}(?:оглы|кызы)(?![\p{L}])/gu;
190
+ // "My ITIN", "Our BIN": a possessive word or a short all-capital acronym
191
+ // matches the Slavic surname shape but carries no name evidence.
192
+ const possessiveWord =
193
+ /^(?:my|our|your|his|her|their|its|the|this|that|мой|моя|наш|наша|ваш|ваша)$/iu;
194
+ function slavicPairEvidence(value) {
195
+ const words = value.split(/[ \t]+/);
196
+ return !words.some(
197
+ (word) =>
198
+ possessiveWord.test(word) ||
199
+ /^\p{Lu}{2,4}$/u.test(word) ||
200
+ isCommonProseWord(word)
201
+ );
202
+ }
203
+ // A rejected pair is retried from its second word, so `Кандидат Петров Иван`
204
+ // still finds `Петров Иван` after the cue word.
205
+ function detectSlavicPairs(text, emit) {
206
+ const pattern = new RegExp(slavicPairs.source, 'gu');
207
+ let match;
208
+ while ((match = pattern.exec(text))) {
209
+ const value = match[0];
210
+ if (
211
+ value !== 'REDACTED' &&
212
+ !placeWord.test(value.split(/[ \t]+/).at(-1)) &&
213
+ slavicPairEvidence(value)
214
+ ) {
215
+ emit({
216
+ start: match.index,
217
+ end: match.index + value.length,
218
+ ...details('PERSON', 'name-identity', 0.9),
219
+ });
220
+ } else {
221
+ pattern.lastIndex = match.index + value.search(/[ \t]/u) + 1;
222
+ }
223
+ }
224
+ }
183
225
  export function detectPersonal(text, emit) {
184
226
  detectDocumentNames(text, emit);
227
+ detectSlavicPairs(text, emit);
185
228
  for (const pattern of [
186
229
  latinPairs,
187
- slavicPairs,
188
230
  russianFull,
189
231
  /(?<![\p{L}])(?:[А-ЯЁ][а-яё]{2,30}[ \t]+)[А-ЯЁ][а-яё]{2,30}(?:вич|вна|ична)[ \t]+[А-ЯЁ][а-яё]{2,30}(?![\p{L}])/gu,
190
232
  ]) {
@@ -218,21 +260,35 @@ export function detectPersonal(text, emit) {
218
260
  );
219
261
  collectMatches(
220
262
  text,
221
- /(?<![\d(])(?:[78][ -]?)?\(\d{3}\)[ -]?\d{3}[ -]\d{2}[ -]?\d{2}(?!\d)|(?<!\d)(?:\d{3}[.-]){2}\d{4}(?!\d)/g,
263
+ /(?<![\d(])(?:[78][ -]?)?\(\d{3}\)[ -]?\d{3}[ -]\d{2}[ -]?\d{2}(?!\d)|(?<![\d-])(?:(?<![\p{L}_.])1[.-])?(?:\d{3}[.-]){2}\d{4}(?!\d|-[\p{L}\p{N}])/gu,
222
264
  emit,
223
265
  details('PHONE', 'national-phone')
224
266
  );
225
267
  collectMatches(
226
268
  text,
227
- /(?<![\p{L}\p{N}])\d{1,6}[A-Za-z]?[ \t]+(?:[\p{Lu}][\p{L}.'-]*[ \t]+){1,5}(?:Street|St|Road|Rd|Avenue|Ave|Lane|Ln|Drive|Dr|Boulevard|Blvd|Way|Straße|Strasse|Rue|Calle|Rua)\b(?:,[ \t]+(?:(?:[^ \t\r\n<>][^\r\n<>]{0,79}?[ \t]+)?(?:[A-Z]{1,2}\d[A-Z\d]?[ \t]+\d[A-Z]{2}|\d{5}(?:-\d{4})?)\b|(?:[\p{Lu}][\p{Ll}.'-]+[ \t]*){1,4}))?/gu,
269
+ /(?<![\p{L}\p{N}])\d{1,6}[A-Za-z]?[ \t]+(?:[\p{Lu}][\p{L}.'-]*[ \t]+){1,5}(?:Street|St|Road|Rd|Avenue|Ave|Lane|Ln|Drive|Dr|Boulevard|Blvd|Way|Terrace|Court|Ct|Place|Pl|Parkway|Pkwy|Circle|Highway|Hwy|Square|Crescent|Close|Straße|Strasse|Rue|Calle|Rua)\b(?:,?[ \t]+(?:Apt|Apartment|Suite|Ste|Unit|Floor|Fl|Room|Rm)\.?[ \t]*#?[A-Za-z0-9-]{1,6}\b|[ \t]+#[A-Za-z0-9-]{1,6}\b)?(?:,[ \t]+(?:(?:[^ \t\r\n<>][^\r\n<>]{0,79}?[ \t]+)?(?:[A-Z]{1,2}\d[A-Z\d]?[ \t]+\d[A-Z]{2}|\d{5}(?:-\d{4})?)\b|(?:[\p{Lu}][\p{Ll}.'-]+[ \t]*){1,4}))?/gu,
228
270
  emit,
229
271
  details('ADDRESS', 'postal-address')
230
272
  );
273
+ // "Mr. O'Brien", "Dr Jane Smith": a courtesy title is name evidence;
274
+ // after a number, "30 Ms Retry" is a millisecond unit.
275
+ collectMatches(
276
+ text,
277
+ /(?<![\p{L}\p{N}_])(?:Mr|Mrs|Ms|Mx|Miss|Dr|Prof)(?<!\p{N}[ \t]*\p{L}+)\.?[ \t]+(\p{Lu}(?:['’]\p{Lu})?[\p{Ll}\p{M}]+(?:-\p{Lu}[\p{Ll}\p{M}]+)?(?:[ \t]+\p{Lu}(?:['’]\p{Lu})?[\p{Ll}\p{M}]+(?:-\p{Lu}[\p{Ll}\p{M}]+)?)?)(?![\p{L}\p{M}])/gu,
278
+ emit,
279
+ details('PERSON', 'honorific-name'),
280
+ 1,
281
+ (value) => !placeWord.test(value.split(/[ \t]+/).at(-1))
282
+ );
231
283
  collectMatches(
232
284
  text,
233
- /(?<![\da-fA-F:.-])(?:[\da-fA-F]{2}[:-]){5}[\da-fA-F]{2}(?![\da-fA-F:.-])/g,
285
+ // Colon, dash or Cisco dotted notation with one separator; the broadcast
286
+ // and all-zero addresses identify no device.
287
+ /(?<![\da-fA-F:.-])(?:[\da-fA-F]{2}([:-])(?:[\da-fA-F]{2}\1){4}[\da-fA-F]{2}|[\da-fA-F]{4}\.[\da-fA-F]{4}\.[\da-fA-F]{4})(?![\da-fA-F:-]|\.[\da-fA-F])/g,
234
288
  emit,
235
- details('MAC_ADDRESS', 'mac')
289
+ details('MAC_ADDRESS', 'mac'),
290
+ 0,
291
+ (value) => !/^(?:[fF]{2}[:.-]?|0{2}[:.-]?)+$/.test(value.replace(/\./g, ''))
236
292
  );
237
293
  collectMatches(
238
294
  text,
@@ -249,7 +305,7 @@ export function detectPersonal(text, emit) {
249
305
  );
250
306
  collectMatches(
251
307
  text,
252
- /(?:\/Users\/|\/home\/|[A-Za-z]:\\Users\\)([^/\\\s"'<>[\]]+)/g,
308
+ /(?:\/Users\/|\/home\/|[A-Za-z]:\\(?:Users|Documents and Settings|(?:Windows|WINNT)\\Profiles)\\)([^/\\\s"'<>[\]]+)/g,
253
309
  emit,
254
310
  details('USERNAME', 'home-path', 0.95),
255
311
  1,