@link-foundation/sensitive-data-sanitizer 0.19.3 → 0.20.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 (46) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +8 -1
  3. package/bin/sensitive-data-sanitizer.js +49 -28
  4. package/docs/API.md +192 -1
  5. package/docs/COVERAGE.md +3 -3
  6. package/docs/RULES.md +217 -0
  7. package/package.json +1 -1
  8. package/src/allow-list.js +93 -0
  9. package/src/confidence.js +24 -9
  10. package/src/context.js +121 -25
  11. package/src/credentials.js +44 -0
  12. package/src/crypto-addresses.js +74 -0
  13. package/src/detection.js +21 -6
  14. package/src/document-person-cues.js +1 -1
  15. package/src/encrypt.js +95 -0
  16. package/src/entities.js +4 -3
  17. package/src/fake.js +29 -1
  18. package/src/files.js +57 -12
  19. package/src/gitleaks.js +82 -27
  20. package/src/identifier-detector.js +36 -0
  21. package/src/identity.js +14 -0
  22. package/src/index.d.ts +116 -1
  23. package/src/index.js +7 -0
  24. package/src/national-checks.js +209 -0
  25. package/src/national.js +310 -92
  26. package/src/pattern-detector.js +219 -0
  27. package/src/personal-context.js +3 -2
  28. package/src/personal-evidence.js +233 -0
  29. package/src/personal.js +42 -10
  30. package/src/projection.js +85 -26
  31. package/src/rule-catalog.js +424 -0
  32. package/src/sanitizer.js +48 -3
  33. package/src/sarif.js +127 -0
  34. package/src/token-checksum.js +49 -0
  35. package/src/transforms.js +108 -25
  36. package/src/vendor/betterleaks/LICENSE +21 -0
  37. package/src/vendor/betterleaks/provenance.json +11 -0
  38. package/src/vendor/betterleaks/rules-0.json +463 -0
  39. package/src/vendor/betterleaks/rules-1.json +454 -0
  40. package/src/vendor/betterleaks/rules-2.json +461 -0
  41. package/src/vendor/betterleaks/rules-3.json +455 -0
  42. package/src/vendor/betterleaks/rules-4.json +483 -0
  43. package/src/vendor/betterleaks/rules-5.json +35 -0
  44. package/src/vendor/entities/aws.json +1 -1
  45. package/src/vendor/entities/azure.json +6 -2
  46. package/src/vendor/entities/google.json +2 -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
+ }
package/src/personal.js CHANGED
@@ -11,6 +11,12 @@ const details = (type, rule, confidence = 0.85) => ({
11
11
  category: 'personal',
12
12
  confidence,
13
13
  });
14
+ // "John C Smith", "John A. Smith": a Latin-script middle initial. A bare "I"
15
+ // is the pronoun ("Will I Go"), so it needs its period.
16
+ const initial = (language) =>
17
+ ['ar', 'he', 'hi', 'th', 'ja', 'zh', 'ko'].includes(language)
18
+ ? ''
19
+ : '(?:[ \\t]+(?:\\p{Lu}\\.|(?!I[ \\t])\\p{Lu}(?=[ \\t])))?';
14
20
  const names = Object.entries(firstNames).map(([language, list]) => {
15
21
  const words = list.split(' ').map(escapePattern).join('|');
16
22
  const surname = ['ar', 'he', 'hi', 'th'].includes(language)
@@ -18,7 +24,7 @@ const names = Object.entries(firstNames).map(([language, list]) => {
18
24
  : "[\\p{Lu}][\\p{L}\\p{M}’'-]{1,32}";
19
25
  const pattern = ['ja', 'zh', 'ko'].includes(language)
20
26
  ? `(?:${words})[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]{1,4}`
21
- : `(?:${words})[ \\t]+${surname}${language === 'vi' ? `(?:[ \\t]+${surname})?` : ''}`;
27
+ : `(?:${words})${initial(language)}[ \\t]+${surname}${language === 'vi' ? `(?:[ \\t]+${surname})?` : ''}`;
22
28
  return new RegExp(`(?<![\\p{L}\\p{M}])${pattern}(?![\\p{L}\\p{M}])`, 'gu');
23
29
  });
24
30
 
@@ -33,7 +39,7 @@ const ibanLengths = Object.fromEntries(
33
39
  const mentionedEmail =
34
40
  /^\/[\p{L}\p{N}_.!#$%&'*+?^`{|}~-]+@[\p{L}\p{N}][\p{L}\p{N}.-]{0,252}\.\p{L}{2,63}(?![\p{L}\p{N}_-])/u;
35
41
  export function iban(value) {
36
- const compact = value.replace(/\s/g, '').toUpperCase();
42
+ const compact = value.replace(/[\s-]/g, '').toUpperCase();
37
43
  if (
38
44
  !/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(compact) ||
39
45
  compact.length !== ibanLengths[compact.slice(0, 2)]
@@ -53,7 +59,7 @@ export function iban(value) {
53
59
 
54
60
  function detectIban(text, emit) {
55
61
  for (const match of text.matchAll(
56
- /(?<![A-Z0-9])[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){8,40}/g
62
+ /(?<![A-Z0-9])[A-Z]{2} ?\d{2}(?:[ -]?[A-Z0-9]){8,40}/g
57
63
  )) {
58
64
  const needed = ibanLengths[match[0].slice(0, 2)];
59
65
  if (!needed) {
@@ -62,7 +68,7 @@ function detectIban(text, emit) {
62
68
  let end = 0,
63
69
  count = 0;
64
70
  while (end < match[0].length && count < needed) {
65
- if (match[0][end] !== ' ') {
71
+ if (!/[ -]/.test(match[0][end])) {
66
72
  count++;
67
73
  }
68
74
  end++;
@@ -180,6 +186,16 @@ function detectDocumentNames(text, emit) {
180
186
  }
181
187
  const russianFull =
182
188
  /(?<![\p{L}])(?:[А-ЯЁ][а-яё]{2,30}[ \t]+){2}[А-ЯЁ][а-яё]{2,30}(?:вич|вна|ична)|(?<![\p{L}])(?:[А-ЯЁ][а-яё]{2,30}[ \t]+){2}(?:оглы|кызы)(?![\p{L}])/gu;
189
+ // "My ITIN", "Our BIN": a possessive word or a short all-capital acronym
190
+ // matches the Slavic surname shape but carries no name evidence.
191
+ const possessiveWord =
192
+ /^(?:my|our|your|his|her|their|its|the|this|that|мой|моя|наш|наша|ваш|ваша)$/iu;
193
+ function slavicPairEvidence(value) {
194
+ const words = value.split(/[ \t]+/);
195
+ return !words.some(
196
+ (word) => possessiveWord.test(word) || /^\p{Lu}{2,4}$/u.test(word)
197
+ );
198
+ }
183
199
  export function detectPersonal(text, emit) {
184
200
  detectDocumentNames(text, emit);
185
201
  for (const pattern of [
@@ -194,7 +210,9 @@ export function detectPersonal(text, emit) {
194
210
  emit,
195
211
  details('PERSON', 'name-identity', 0.9),
196
212
  0,
197
- (value) => !placeWord.test(value.split(/[ \t]+/).at(-1))
213
+ (value) =>
214
+ !placeWord.test(value.split(/[ \t]+/).at(-1)) &&
215
+ (pattern !== slavicPairs || slavicPairEvidence(value))
198
216
  );
199
217
  }
200
218
  for (const pattern of names) {
@@ -218,21 +236,35 @@ export function detectPersonal(text, emit) {
218
236
  );
219
237
  collectMatches(
220
238
  text,
221
- /(?<![\d(])(?:[78][ -]?)?\(\d{3}\)[ -]?\d{3}[ -]\d{2}[ -]?\d{2}(?!\d)|(?<!\d)(?:\d{3}[.-]){2}\d{4}(?!\d)/g,
239
+ /(?<![\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
240
  emit,
223
241
  details('PHONE', 'national-phone')
224
242
  );
225
243
  collectMatches(
226
244
  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,
245
+ /(?<![\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
246
  emit,
229
247
  details('ADDRESS', 'postal-address')
230
248
  );
249
+ // "Mr. O'Brien", "Dr Jane Smith": a courtesy title is name evidence;
250
+ // after a number, "30 Ms Retry" is a millisecond unit.
231
251
  collectMatches(
232
252
  text,
233
- /(?<![\da-fA-F:.-])(?:[\da-fA-F]{2}[:-]){5}[\da-fA-F]{2}(?![\da-fA-F:.-])/g,
253
+ /(?<![\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,
234
254
  emit,
235
- details('MAC_ADDRESS', 'mac')
255
+ details('PERSON', 'honorific-name'),
256
+ 1,
257
+ (value) => !placeWord.test(value.split(/[ \t]+/).at(-1))
258
+ );
259
+ collectMatches(
260
+ text,
261
+ // Colon, dash or Cisco dotted notation with one separator; the broadcast
262
+ // and all-zero addresses identify no device.
263
+ /(?<![\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,
264
+ emit,
265
+ details('MAC_ADDRESS', 'mac'),
266
+ 0,
267
+ (value) => !/^(?:[fF]{2}[:.-]?|0{2}[:.-]?)+$/.test(value.replace(/\./g, ''))
236
268
  );
237
269
  collectMatches(
238
270
  text,
@@ -249,7 +281,7 @@ export function detectPersonal(text, emit) {
249
281
  );
250
282
  collectMatches(
251
283
  text,
252
- /(?:\/Users\/|\/home\/|[A-Za-z]:\\Users\\)([^/\\\s"'<>[\]]+)/g,
284
+ /(?:\/Users\/|\/home\/|[A-Za-z]:\\(?:Users|Documents and Settings|(?:Windows|WINNT)\\Profiles)\\)([^/\\\s"'<>[\]]+)/g,
253
285
  emit,
254
286
  details('USERNAME', 'home-path', 0.95),
255
287
  1,
package/src/projection.js CHANGED
@@ -21,12 +21,91 @@ function asciiDigits(value) {
21
21
  });
22
22
  }
23
23
 
24
+ // Invisible format characters and NUL (UTF-16 text read as bytes) never
25
+ // carry meaning inside a token, so they are removed from the detection view.
26
+ const invisible =
27
+ /^[\0\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]$/u;
28
+ // HTML formatting tags split tokens (`SECRET_</span><span>REST`). Inline tags
29
+ // are dropped and block tags become line breaks. Only presentation attributes
30
+ // are allowed, so other attribute values (URLs, data-*) stay in the view.
31
+ const inlineTags =
32
+ 'abbr|b|cite|code|del|em|font|i|ins|kbd|mark|q|s|samp|small|span|strong|sub|sup|tt|u|var';
33
+ const blockTags =
34
+ 'blockquote|br|dd|div|dt|h[1-6]|hr|li|ol|p|pre|table|tbody|td|th|thead|tr|ul';
35
+ const htmlTag = String.raw`<\/?(?:${inlineTags}|${blockTags})(?:\s+(?:class|dir|lang|style)="[^"<>=]*")*\s*\/?>`;
36
+ const inlineTag = new RegExp(String.raw`^<\/?(?:${inlineTags})[\s/>]`);
37
+ // TruffleHog-style escape runs: `U+0074 U+006F ...` and CSS `\74 \6f ...`.
38
+ // Runs need four or more units so prose such as "U+200B" stays untouched.
39
+ // The space after the last unit stays in the view as a word boundary.
40
+ const codeRun = String.raw`(?:U\+[\da-fA-F]{4,6} ?){3,}U\+[\da-fA-F]{4,6}`;
41
+ const cssRun = String.raw`(?:\\[\da-fA-F]{1,6} ){3,}\\[\da-fA-F]{1,6}`;
42
+ const projectionTrigger = new RegExp(
43
+ String.raw`\x1b\[|\0|\\u[\da-fA-F{]|\\x\{[\da-fA-F]|\\U[\da-fA-F]{8}|%u[\da-fA-F]{4}|U\+[\da-fA-F]{4}|\\[\da-fA-F]{1,6} \\|[\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]|${htmlTag}`,
44
+ 'u'
45
+ );
46
+ const projectionUnits = new RegExp(
47
+ String.raw`\x1b\[[0-?]*[ -/]*[@-~]|\\u[\da-fA-F]{4}|\\[ux]\{[\da-fA-F]{1,6}\}|\\U[\da-fA-F]{8}|%u[\da-fA-F]{4}|${codeRun}|${cssRun}|${htmlTag}|[\s\S]\p{M}*`,
48
+ 'gu'
49
+ );
50
+
51
+ function decodeEscape(raw) {
52
+ const escape = /^(?:\\[ux]\{?|\\U|%u)([\da-fA-F]+)\}?$/.exec(raw);
53
+ if (!escape) {
54
+ return raw;
55
+ }
56
+ const code = parseInt(escape[1], 16);
57
+ // `\uXXXX` keeps UTF-16 surrogate halves; wider forms are code points.
58
+ return raw.length === 6 && raw[0] === '\\'
59
+ ? String.fromCharCode(code)
60
+ : code <= 0x10ffff
61
+ ? String.fromCodePoint(code)
62
+ : raw;
63
+ }
64
+
65
+ // Every character decoded from an escape run maps to the whole run. Redacting
66
+ // only some units would leave a short run, such as a lone closing quote, that
67
+ // the next view no longer decodes, so a finding inside a run covers all of it.
68
+ function appendRun(writer, raw, offset) {
69
+ const unit = raw.startsWith('U+')
70
+ ? /U\+([\da-fA-F]{4,6}) ?/g
71
+ : /\\([\da-fA-F]{1,6}) ?/g;
72
+ const end = offset + raw.length;
73
+ for (const match of raw.matchAll(unit)) {
74
+ const code = parseInt(match[1], 16);
75
+ const value = code <= 0x10ffff ? String.fromCodePoint(code) : match[0];
76
+ if (!invisible.test(value)) {
77
+ writer.append(value, offset, end);
78
+ }
79
+ }
80
+ }
81
+
82
+ function projectUnit(writer, raw, offset) {
83
+ const end = offset + raw.length;
84
+ if (raw.startsWith('\x1b[') || invisible.test(raw)) {
85
+ return true;
86
+ }
87
+ if (raw[0] === '<' && raw.length > 2 && raw.endsWith('>')) {
88
+ if (!inlineTag.test(raw)) {
89
+ writer.append('\n', offset, end);
90
+ }
91
+ return true;
92
+ }
93
+ if (raw.length > 8 && /^(?:U\+|\\[\da-fA-F])/.test(raw)) {
94
+ appendRun(writer, raw, offset);
95
+ return true;
96
+ }
97
+ const decoded = decodeEscape(raw);
98
+ if (invisible.test(decoded)) {
99
+ return true;
100
+ }
101
+ const value = asciiDigits(decoded.normalize('NFKC'));
102
+ writer.append(value, offset, end);
103
+ return value !== raw;
104
+ }
105
+
24
106
  export function projectText(input) {
25
107
  if (
26
- // eslint-disable-next-line no-control-regex -- ANSI escape sequences are removed from the detection view.
27
- !/\x1b\[|\\u[\da-fA-F]{4}|[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/u.test(
28
- input
29
- ) &&
108
+ !projectionTrigger.test(input) &&
30
109
  // ASCII already has normalized letters and digits. Avoid constructing a
31
110
  // second multi-megabyte string just to check its decimal characters.
32
111
  // eslint-disable-next-line no-control-regex -- Check the ASCII range.
@@ -37,29 +116,9 @@ export function projectText(input) {
37
116
  return { text: input, changed: false };
38
117
  }
39
118
  const writer = projectionWriter(input.length);
40
- // eslint-disable-next-line no-control-regex -- Tokenize ANSI sequences with their original offsets.
41
- const units = /\x1b\[[0-?]*[ -/]*[@-~]|\\u[\da-fA-F]{4}|[\s\S]\p{M}*/gu;
42
119
  let changed = false;
43
- for (const match of input.matchAll(units)) {
44
- const raw = match[0];
45
- let value = raw;
46
- if (
47
- raw.startsWith('\x1b[') ||
48
- /^[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]$/u.test(raw)
49
- ) {
50
- changed = true;
51
- continue;
52
- }
53
- if (/^\\u[\da-fA-F]{4}$/.test(raw)) {
54
- value = String.fromCharCode(parseInt(raw.slice(2), 16));
55
- }
56
- if (/^[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]$/u.test(value)) {
57
- changed = true;
58
- continue;
59
- }
60
- value = asciiDigits(value.normalize('NFKC'));
61
- changed ||= value !== raw;
62
- writer.append(value, match.index, match.index + raw.length);
120
+ for (const match of input.matchAll(projectionUnits)) {
121
+ changed = projectUnit(writer, match[0], match.index) || changed;
63
122
  }
64
123
  return { ...writer.finish(), changed };
65
124
  }