@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
package/src/sanitizer.js CHANGED
@@ -1,14 +1,12 @@
1
- import { generatedValues } from './fake.js';
1
+ import { generatedValues } from './fake-memory.js';
2
+ import { feature, withFeature } from './features.js';
2
3
  import { isCompiledKnown, projectedKnown } from './compiled-known.js';
3
4
  import { detectSync, validateDetectors } from './sync-detectors.js';
4
5
  import { validatePolicyControls, builtInEnabled } from './policy.js';
5
6
  import { compiledVocabulary } from './vocabulary.js';
6
- import { validateProperNames } from './proper-names.js';
7
- import { prepareFakeText } from './fake-entities.js';
8
7
  import { alignEscapes } from './escape-spans.js';
9
8
  import { actionable } from './confidence.js';
10
9
  import { propagate, confirmationOptions } from './propagation.js';
11
- import { fakeDocumentSpans } from './specimens.js';
12
10
  import {
13
11
  failure,
14
12
  internalFailure,
@@ -26,8 +24,12 @@ import {
26
24
  hasCustomTransforms,
27
25
  renderCallbackAsync,
28
26
  } from './transforms.js';
29
- import { fakeIdentityValue, isIdentityType } from './identity.js';
27
+ import { isIdentityType } from './identity-types.js';
30
28
  import { rejectRestoreMap } from './restore-map.js';
29
+ import { allowListed, validateAllowList } from './allow-list.js';
30
+ import { templatedCredential } from './credentials.js';
31
+ import { exemptFindings, validateExempt } from './exempt.js';
32
+ import { builtInRules } from './rule-catalog.js';
31
33
  import {
32
34
  initializePlaceholders,
33
35
  placeholderKnown,
@@ -36,6 +38,8 @@ import {
36
38
  hasPlaceholders,
37
39
  placeholderValues,
38
40
  placeholderNameFindings,
41
+ keptPlaceholderSpans,
42
+ keptPlaceholderFinding,
39
43
  coverAdjacentNames,
40
44
  nestedPlaceholderOptions,
41
45
  } from './placeholders.js';
@@ -99,6 +103,10 @@ const optionNames = new Set([
99
103
  'strict',
100
104
  'exactFields',
101
105
  'allowedFields',
106
+ 'minConfidenceByType',
107
+ 'allowList',
108
+ 'inlineAllow',
109
+ 'exempt',
102
110
  ]);
103
111
 
104
112
  export function applyProfile(options = {}) {
@@ -216,7 +224,9 @@ export function validateOptions(options = {}) {
216
224
  }
217
225
  }
218
226
  validateConfidence(options);
219
- validateProperNames(options);
227
+ if (options.properNames !== undefined || options.scripts !== undefined) {
228
+ feature('validateProperNames')(options);
229
+ }
220
230
  validatePolicy(options);
221
231
  if (
222
232
  options.verifyPublic !== undefined &&
@@ -224,7 +234,9 @@ export function validateOptions(options = {}) {
224
234
  ) {
225
235
  throw failure('ERR_CONFIG');
226
236
  }
237
+ validateExempt(options.exempt);
227
238
  validateCollections(options);
239
+ validateAllowList(options);
228
240
  validateDetectors(options.detectors);
229
241
  validatePolicyControls(options);
230
242
  compiledVocabulary(options.vocabulary);
@@ -255,6 +267,22 @@ function validateConfidence(options) {
255
267
  ) {
256
268
  throw failure('ERR_CONFIG');
257
269
  }
270
+ const byType = options.minConfidenceByType;
271
+ if (
272
+ byType !== undefined &&
273
+ (!byType ||
274
+ typeof byType !== 'object' ||
275
+ Array.isArray(byType) ||
276
+ !Object.entries(byType).every(
277
+ ([type, value]) =>
278
+ validType(type) &&
279
+ typeof value === 'number' &&
280
+ value >= 0 &&
281
+ value <= 1
282
+ ))
283
+ ) {
284
+ throw failure('ERR_CONFIG');
285
+ }
258
286
  }
259
287
 
260
288
  function validatePolicy(options) {
@@ -306,6 +334,9 @@ export function sanitizeFinding(finding, length) {
306
334
  rule: finding.rule,
307
335
  confidence: validConfidence(finding),
308
336
  likelihood: finding.likelihood ?? likelihood(validConfidence(finding)),
337
+ ...(finding.type === 'JSON_VALUE' && validType(finding.valueType)
338
+ ? { valueType: finding.valueType }
339
+ : {}),
309
340
  ...(finding.kept === 'fake' ? { kept: 'fake' } : {}),
310
341
  };
311
342
  }
@@ -426,7 +457,14 @@ function locations(text, findings) {
426
457
 
427
458
  export function inspect(text, options = {}) {
428
459
  try {
429
- return inspectText(text, options);
460
+ const exempt = options?.exempt;
461
+ if (!exempt) {
462
+ return inspectText(text, options);
463
+ }
464
+ // Nested views (structured values, decoded runs) are screened once, at
465
+ // the offsets of the text the caller sees.
466
+ const findings = inspectText(text, { ...options, exempt: undefined });
467
+ return exemptFindings(text, findings, exempt);
430
468
  } catch (error) {
431
469
  throw internalFailure(error);
432
470
  }
@@ -518,12 +556,34 @@ function inspectText(text, options = {}) {
518
556
  return reviewFindings(text, findings, options);
519
557
  }
520
558
 
559
+ // Allow-listed values and built-in credential matches whose value is only a
560
+ // template or mask (`${PASSWORD}`, `<token>`, `****`) are not findings.
561
+ function policyKept(text, finding, options) {
562
+ if (allowListed(text, finding, options)) {
563
+ return false;
564
+ }
565
+ if (builtInRule(finding) && templatedCredential(text, finding)) {
566
+ options.debug?.({ event: 'template-value', rule: finding.rule });
567
+ return false;
568
+ }
569
+ return true;
570
+ }
571
+
572
+ // Custom detectors keep their findings: they may flag placeholder shapes on
573
+ // purpose, and the residual check must still see them.
574
+ const builtInRuleIds = new Set(builtInRules);
575
+ const builtInRule = (finding) =>
576
+ builtInRuleIds.has(finding.rule) ||
577
+ finding.rule === 'secretlint' ||
578
+ finding.rule.startsWith('gitleaks/');
579
+
521
580
  // Reapply policy to the native/required-engine union without rescanning text.
522
581
  export function reviewFindings(text, findings, options) {
523
582
  findings = placeholderNameFindings(text, findings, options);
524
583
  if (findings.length > (options.maxFindings ?? 100000)) {
525
584
  throw failure('ERR_LIMIT');
526
585
  }
586
+ findings = findings.filter((f) => policyKept(text, f, options));
527
587
  const unique = new Map();
528
588
  const exempt = findings.filter((f) =>
529
589
  allowed(text, f, options.publicEntities ?? [])
@@ -565,9 +625,9 @@ function coveredByPublic(finding, exempt) {
565
625
  }
566
626
 
567
627
  export function inspectFindingsOnly(text, findings, options) {
568
- const all = [...findings, ...(options.findings ?? [])].map((f) =>
569
- sanitizeFinding(f, text.length)
570
- );
628
+ const all = [...findings, ...(options.findings ?? [])]
629
+ .map((f) => sanitizeFinding(f, text.length))
630
+ .filter((f) => policyKept(text, f, options));
571
631
  if (all.length > (options.maxFindings ?? 100000)) {
572
632
  throw failure('ERR_LIMIT');
573
633
  }
@@ -590,14 +650,41 @@ export function inspectFindingsOnly(text, findings, options) {
590
650
  );
591
651
  }
592
652
 
653
+ // A whole JSON value that is already a token of this run, `"Sensitive-1"`.
654
+ function generatedJsonValue(text, finding, options) {
655
+ const raw = text.slice(finding.start, finding.end);
656
+ return (
657
+ raw.startsWith('"') &&
658
+ placeholderValues(options).some(
659
+ (record) => JSON.stringify(record.value) === raw
660
+ )
661
+ );
662
+ }
593
663
  function selectedFindings(findings, credentialsOnly) {
594
664
  return credentialsOnly
595
665
  ? findings.filter((finding) => finding.category === 'credential')
596
666
  : findings;
597
667
  }
598
668
 
669
+ // Findings over the tokens of an earlier pass, recorded in `tokenKept`.
670
+ function withKeptPlaceholders(text, findings, options, tokenKept) {
671
+ const tokens = keptPlaceholderSpans(text, findings, options);
672
+ if (!tokens.length) {
673
+ return findings;
674
+ }
675
+ return findings.map((f) => {
676
+ const next = keptPlaceholderFinding(text, f, tokens);
677
+ if (next.kept === 'fake' && next !== f) {
678
+ tokenKept.add(next);
679
+ }
680
+ return next;
681
+ });
682
+ }
683
+
599
684
  export function markFake(text, findings, options) {
600
685
  findings = selectedFindings(findings, options.credentialsOnly);
686
+ const tokenKept = new Set();
687
+ findings = withKeptPlaceholders(text, findings, options, tokenKept);
601
688
  const generated = [
602
689
  ...generatedValues(options),
603
690
  ...placeholderValues(options),
@@ -607,9 +694,12 @@ export function markFake(text, findings, options) {
607
694
  !options.fakeValues?.length &&
608
695
  !generated.length
609
696
  ) {
610
- return findings.map((f) => ({ ...f, kept: undefined }));
697
+ return findings.map((f) => ({
698
+ ...f,
699
+ kept: tokenKept.has(f) ? 'fake' : undefined,
700
+ }));
611
701
  }
612
- const documents = fakeDocumentSpans(text, options);
702
+ const documents = withFeature('fakeDocumentSpans', [], text, options);
613
703
  const trusted = [];
614
704
  for (const record of generated) {
615
705
  for (const value of new Set([
@@ -644,7 +734,7 @@ export function markFake(text, findings, options) {
644
734
  const candidates = findings.filter(
645
735
  (f) =>
646
736
  f.category === 'personal' &&
647
- fakeIdentityValue(text.slice(f.start, f.end), f.type, options)
737
+ feature('fakeIdentityValue')(text.slice(f.start, f.end), f.type, options)
648
738
  );
649
739
  // Tolerant MRZ matches can cross a line boundary. An automatic specimen
650
740
  // exemption must never cover an overlapping real identity value. Exact
@@ -668,8 +758,13 @@ export function markFake(text, findings, options) {
668
758
  ),
669
759
  ];
670
760
  return findings.map((f) => {
761
+ if (tokenKept.has(f)) {
762
+ return f;
763
+ }
671
764
  if (f.type === 'JSON_VALUE') {
672
- return { ...f, kept: undefined };
765
+ return generatedJsonValue(text, f, options)
766
+ ? { ...f, kept: 'fake' }
767
+ : { ...f, kept: undefined };
673
768
  }
674
769
  if (
675
770
  trusted.some(
@@ -730,6 +825,7 @@ function validatedSpans(text, findings, options) {
730
825
  last.category = 'credential';
731
826
  }
732
827
  if (last.type === 'JSON_VALUE' || f.type === 'JSON_VALUE') {
828
+ last.valueType = jsonValueType(last, f);
733
829
  last.type = 'JSON_VALUE';
734
830
  } else if (credentialRun) {
735
831
  last.type = 'SECRET';
@@ -743,6 +839,15 @@ function validatedSpans(text, findings, options) {
743
839
  return spans;
744
840
  }
745
841
 
842
+ // Two whole values merged into one keep a value type only when they agree.
843
+ function jsonValueType(last, f) {
844
+ if (last.type !== 'JSON_VALUE') {
845
+ return f.valueType;
846
+ }
847
+ return f.type !== 'JSON_VALUE' || f.valueType === last.valueType
848
+ ? last.valueType
849
+ : undefined;
850
+ }
746
851
  function preferredType(current, candidate, options) {
747
852
  if (current === 'JSON_VALUE') {
748
853
  return false;
@@ -779,7 +884,8 @@ export function redactResult(
779
884
  ) {
780
885
  options = applyProfile(options);
781
886
  const spans = validatedSpans(text, findings, options);
782
- options = prepareFakeText(text, findings, initializePlaceholders(options));
887
+ options = initializePlaceholders(options);
888
+ options = withFeature('prepareFakeText', options, text, findings, options);
783
889
  preparePlaceholderText(text, findings, options);
784
890
  const parts = [];
785
891
  const faked = [];
@@ -905,22 +1011,26 @@ export async function redactResultAsync(
905
1011
  }
906
1012
  continue;
907
1013
  }
908
- if (!run) {
909
- if (options.preserveEncoding) {
910
- throw failure('ERR_ENCODING');
911
- }
912
- continue;
913
- }
914
- if (!options.preserveEncoding && run.encoding !== 'json-content') {
1014
+ if (
1015
+ !run ||
1016
+ (!options.preserveEncoding && run.encoding !== 'json-content')
1017
+ ) {
915
1018
  continue;
916
1019
  }
917
1020
  const result = await sanitizeDecoded(
918
1021
  run.text,
919
1022
  nestedPlaceholderOptions(confirmationOptions(text, findings, options))
920
1023
  );
1024
+ // Without a recognizable re-encoding, the encoded text itself is masked.
921
1025
  replacements.set(
922
1026
  `${span.start}:${span.end}`,
923
- encodeRun(run, result.text === run.text ? REDACTED : result.text)
1027
+ encodeRun(run, result.text === run.text ? REDACTED : result.text) ??
1028
+ renderReplacement(
1029
+ text,
1030
+ span,
1031
+ { ...options, preserveEncoding: false },
1032
+ () => ({ text: REDACTED })
1033
+ )
924
1034
  );
925
1035
  }
926
1036
  }
package/src/sarif.js ADDED
@@ -0,0 +1,127 @@
1
+ // SARIF 2.1.0 export for code-scanning dashboards (GitHub, GitLab, Azure
2
+ // DevOps), matching the reporting formats of Gitleaks/Betterleaks, TruffleHog,
3
+ // detect-secrets and Kingfisher. Only metadata is emitted: rule, type,
4
+ // category, confidence and location. Matched values are never included.
5
+ const schema = 'https://json.schemastore.org/sarif-2.1.0.json';
6
+ const levels = { credential: 'error', personal: 'warning' };
7
+
8
+ function artifactUri(path) {
9
+ if (path === '-' || path === undefined) {
10
+ return 'stdin';
11
+ }
12
+ return encodeURI(String(path).replaceAll('\\', '/'));
13
+ }
14
+
15
+ function region(finding) {
16
+ const result = {
17
+ charOffset: finding.start,
18
+ charLength: finding.end - finding.start,
19
+ };
20
+ if (Number.isSafeInteger(finding.line)) {
21
+ result.startLine = finding.line;
22
+ }
23
+ if (Number.isSafeInteger(finding.column)) {
24
+ result.startColumn = finding.column;
25
+ }
26
+ return result;
27
+ }
28
+
29
+ function sarifResult(finding, uri, ruleIndex) {
30
+ return {
31
+ ruleId: finding.rule,
32
+ ruleIndex,
33
+ level: levels[finding.category] ?? 'note',
34
+ message: {
35
+ text: `${finding.type} ${finding.category === 'credential' ? 'credential' : 'personal data'} detected`,
36
+ },
37
+ locations: [
38
+ {
39
+ physicalLocation: {
40
+ artifactLocation: { uri },
41
+ region: region(finding),
42
+ },
43
+ },
44
+ ],
45
+ properties: {
46
+ type: finding.type,
47
+ category: finding.category,
48
+ confidence: finding.confidence,
49
+ },
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Converts scan results to a SARIF 2.1.0 log. Accepts the CLI scan report
55
+ * (`{complete, results: [{path, findings}], skipped}`), an array of
56
+ * `{path, findings}` entries, or a findings array for one artifact. Values
57
+ * are never copied into the log; paths should already be sanitized.
58
+ */
59
+ function entriesOf(report, path) {
60
+ if (!Array.isArray(report)) {
61
+ return report?.results ?? [];
62
+ }
63
+ return report.every((item) => Array.isArray(item?.findings))
64
+ ? report
65
+ : [{ path, findings: report }];
66
+ }
67
+
68
+ export function toSarif(report, { version, path = '-' } = {}) {
69
+ const entries = entriesOf(report, path);
70
+ const rules = [],
71
+ indexes = new Map(),
72
+ results = [];
73
+ for (const entry of entries) {
74
+ const uri = artifactUri(entry.path);
75
+ for (const finding of entry.findings) {
76
+ if (!indexes.has(finding.rule)) {
77
+ indexes.set(finding.rule, rules.length);
78
+ rules.push({
79
+ id: finding.rule,
80
+ name: finding.rule,
81
+ shortDescription: { text: `${finding.type} (${finding.category})` },
82
+ defaultConfiguration: {
83
+ level: levels[finding.category] ?? 'note',
84
+ },
85
+ properties: { tags: [finding.category, finding.type] },
86
+ });
87
+ }
88
+ results.push(sarifResult(finding, uri, indexes.get(finding.rule)));
89
+ }
90
+ }
91
+ const skipped = report?.skipped ?? [];
92
+ return {
93
+ $schema: schema,
94
+ version: '2.1.0',
95
+ runs: [
96
+ {
97
+ tool: {
98
+ driver: {
99
+ name: 'sensitive-data-sanitizer',
100
+ informationUri:
101
+ 'https://github.com/link-foundation/sensitive-data-sanitizer',
102
+ ...(version ? { version } : {}),
103
+ rules,
104
+ },
105
+ },
106
+ invocations: [
107
+ {
108
+ executionSuccessful: report?.complete ?? true,
109
+ toolExecutionNotifications: skipped.map((item) => ({
110
+ level: 'warning',
111
+ message: { text: `Skipped: ${item.reason}` },
112
+ locations: [
113
+ {
114
+ physicalLocation: {
115
+ artifactLocation: { uri: artifactUri(item.path) },
116
+ },
117
+ },
118
+ ],
119
+ })),
120
+ },
121
+ ],
122
+ columnKind: 'utf16CodeUnits',
123
+ results,
124
+ },
125
+ ],
126
+ };
127
+ }
@@ -1,4 +1,5 @@
1
1
  import { parentPort, workerData } from 'node:worker_threads';
2
+ import './personal-features.js';
2
3
  import { sanitizeFileToFile } from './stream.js';
3
4
  import { compileKnown } from './compiled-known.js';
4
5
  try {
@@ -1,4 +1,5 @@
1
1
  import { collectMatches } from './detection.js';
2
+ import { compiledVocabulary } from './vocabulary.js';
2
3
 
3
4
  const attributes =
4
5
  /(?<![\p{L}\p{N}_])(?:женщин[аыуеой]{1,2}|мужчин[аыуеой]{1,2}|замужем|женат(?:а|ы)?|(?:один|одна|двое|трое|четверо|пятеро|\d{1,2})[ \t]+(?:детей|реб[её]нок|реб[её]нка)|русск(?:ая|ий|ого|ой|ие)|татар(?:ин|ка|ы)|православн(?:ый|ая|ого|ой)|мусульман(?:ин|ка)|инвалидност[ьи](?:[ \t]+\d[ \t]+группы)?|беременн(?:а|ая|ость)|судимость(?:[ \t]+погашена)?|член[ \t]+партии|woman|women|male|female|married|divorced|pregnant|pregnancy|disability|christian|muslim|jewish|orthodox|hispanic|criminal[ \t]+record|party[ \t]+member|(?:one|two|three|\d{1,2})[ \t]+children)(?![\p{L}\p{N}_])/giu;
@@ -10,10 +11,45 @@ const additionalAttributes =
10
11
  // unfamiliar diagnoses, nationalities and religions outside this lexicon.
11
12
  const categoryForms =
12
13
  /(?<![\p{L}\p{N}_])(?:(?:диабет|католик|католич|буддист|атеист|белорус|православн|беременн|судим)[\p{L}]{0,8}|татар(?:ин(?:а|у|ом|е)?|к[аиуе]|кой|ы|ам|ами|ах)?|мусульман(?:ин(?:а|у|ом|е)?|к[аиуе]|кой|е|ам|ами|ах)?|украин(?:ец|ца|цу|цем|це|цы|цев|цам|цами|цах|к[аиуе]|кой)|инвалид(?:а|у|ом|е|ы|ов|ам|ами|ах|к[аиу]|кой)?|армян(?:ин|ина|ину|ином|ине|е|ам|ами|ах|ка|ки|ке|ку|кой)|евре(?:й|я|ю|ем|и|ев|ям|ями|ях|йк[аиуе]|йкой)|казах(?:а|у|ом|е|и|ов|ам|ами|ах|к[аиуе]|кой)?|христиан(?:ин|ина|ину|ином|ине|е|ам|ами|ах|к[аиуе]|кой)|иуде(?:й|я|ю|ем|и|ев|ям|ями|ях|йк[аиуе]|йкой)|ислам[\p{L}]{0,5}|эпилепси[\p{L}]{1,5}|астм[\p{L}]{1,5}|онкологи[\p{L}]{1,5}|туберкул[её]з[\p{L}]{0,5}|психиатр[\p{L}]{0,6}|депресси[\p{L}]{1,5}|гепатит[\p{L}]{0,5}|гомосексуал[\p{L}]{0,7}|лесбиян[\p{L}]{1,5}|ге(?:й|я|ю|ем|и|ев|ям|ями|ях)|нетрадиционн[\p{L}]{2,3}[ \t]+ориентаци[\p{L}]{1,3}|отбыва(?:л[аи]?|ет|ют|вш[\p{L}]{2,3})[ \t]+наказани[\p{L}]{1,3}|ВИЧ-инфицирован[\p{L}]{0,4}|epilepsy|asthma|tuberculosis|hepatitis|depression|gay|lesbian|homosexual|bisexual|armenian)(?![\p{L}\p{N}_])/giu;
14
+ // Nationality nouns with their case endings and feminine forms. Exact endings
15
+ // keep country and region names (`Узбекистан`, `Чувашия`) out of the match.
16
+ const hardNoun = '(?:а|у|ом|е|ы|ов|ам|ами|ах)?';
17
+ const softNoun = '(?:а|у|ом|е|и|ей|ам|ами|ах)?';
18
+ const ecNoun = '(?:ец|ц(?:а|у|ем|е|ы|ев|ам|ами|ах))';
19
+ const femaleNoun = '(?:а|и|е|у|ой)';
20
+ const nationalities = [
21
+ `узбек${softNoun}|узбечк${femaleNoun}`,
22
+ `таджик${softNoun}|таджичк${femaleNoun}`,
23
+ `(?:киргиз|кыргыз)${hardNoun}|(?:киргиз|кыргыз)к${femaleNoun}`,
24
+ `туркмен${hardNoun}|туркменк${femaleNoun}`,
25
+ `чечен${ecNoun}|чеченк${femaleNoun}`,
26
+ `ингуш${softNoun}|ингушк${femaleNoun}`,
27
+ `осетин${hardNoun}|осетинк${femaleNoun}`,
28
+ `грузин${hardNoun}|грузинк${femaleNoun}`,
29
+ `азербайджан${ecNoun}|азербайджанк${femaleNoun}`,
30
+ `цыган${hardNoun}|цыганк${femaleNoun}`,
31
+ `коре(?:ец|йц(?:а|у|ем|е|ы|ев|ам|ами|ах))|кореянк${femaleNoun}`,
32
+ `кита(?:ец|йц(?:а|у|ем|е|ы|ев|ам|ами|ах))|китаянк${femaleNoun}`,
33
+ `башкир${hardNoun}|башкирк${femaleNoun}`,
34
+ `чуваш${softNoun}|чувашк${femaleNoun}`,
35
+ `якут${hardNoun}|якутк${femaleNoun}`,
36
+ `бурят${hardNoun}|бурятк${femaleNoun}`,
37
+ `калмык${softNoun}|калмычк${femaleNoun}`,
38
+ `лезгин${hardNoun}|лезгинк${femaleNoun}`,
39
+ `авар${ecNoun}|аварк${femaleNoun}`,
40
+ `дагестан${ecNoun}|дагестанк${femaleNoun}`,
41
+ `уйгур${hardNoun}|уйгурк${femaleNoun}`,
42
+ ].join('|');
43
+ const believerNoun = `(?:а|у|ом|е|ы|ов|ам|ами|ах|к${femaleNoun}|кой)?`;
44
+ const specialVocabulary = new RegExp(
45
+ `(?<![\\p{L}\\p{N}_])(?:${nationalities}|(?:протестант|баптист|адвентист|индуист|кришнаит|иеговист|пятидесятник)${believerNoun}|лютеран(?:ин(?:а|у|ом|е)?|к${femaleNoun}|кой|е|ам|ами|ах)|старообряд${ecNoun}|старообрядк${femaleNoun}|свидетел[\\p{L}]{1,3}[ \\t]+иеговы|инсульт[\\p{L}]{0,3}|инфаркт[\\p{L}]{0,3}|шизофрени[\\p{L}]{1,4}|аутизм[\\p{L}]{0,3}|аутист(?:ка|ки|ке|ку|кой|а|у|ом|ы|ов)?|слабослышащ[\\p{L}]{1,4}|слабовидящ[\\p{L}]{1,4}|незряч[\\p{L}]{1,4}|дцп|колясочни(?:к${hardNoun}|ц${femaleNoun})|(?:передвига[\\p{L}]{1,5}|инвалидн[\\p{L}]{2,4})[ \\t]+(?:на[ \\t]+)?коляск[\\p{L}]{1,3}|болезн[\\p{L}]{1,3}[ \\t]+(?:паркинсона|альцгеймера)|деменци[\\p{L}]{1,3}|алкоголизм[\\p{L}]{0,2}|наркозависим[\\p{L}]{0,4}|биполярн[\\p{L}]{2,4}[ \\t]+расстройств[\\p{L}]{0,3}|(?:би|транс|пан|а)сексуал[\\p{L}]{0,6}|трансгендер[\\p{L}]{0,6}|(?:сидел[аи]?|отсидел[аи]?|осужд[её]н[аы]?|привлекал(?:ся|ась|ись)|отбывал[аи]?[ \\t]+срок)[ \\t]+(?:за|по)[ \\t]+(?:краж|грабе|разбо|убийств|мошенничеств|наркотик|хулиганств|изнасиловани|взятк|стать)[\\p{L}]{0,4}(?:[ \\t]+\\d{1,3}(?:\\.\\d)?)?|(?:сидел[аи]?|отсидел[аи]?)[ \\t]+(?:в[ \\t]+)?(?:тюрьм[\\p{L}]{1,2}|колони[\\p{L}]{1,2}|срок)|бывш[\\p{L}]{2,3}[ \\t]+заключ[её]нн[\\p{L}]{2,3}|член[\\p{L}]{0,3}[ \\t]+профсоюз[\\p{L}]{0,3}|состо(?:ит|ял[аи]?|ю|ят)[ \\t]+в[ \\t]+профсоюзе|профсоюзн(?:ый|ая|ого|ой)[ \\t]+(?:активист|деятел|лидер)[\\p{L}]{0,3}|autism|autistic|schizophreni[ac]|transgender|transsexual|pansexual|asexual|wheelchair[ \\t]+users?|uses[ \\t]+a[ \\t]+wheelchair|in[ \\t]+a[ \\t]+wheelchair|(?:hearing|visually)[ \\t-]+impaired|had[ \\t]+a[ \\t]+(?:stroke|heart[ \\t]+attack)|dementia|alzheimer'?s|parkinson'?s|bipolar[ \\t]+disorder|alcoholism|trade[ \\t]+union[ \\t]+member(?:ship)?|member[ \\t]+of[ \\t]+(?:a|the)[ \\t]+(?:trade|labou?r)[ \\t]+union|convicted[ \\t]+(?:of|for)[ \\t]+[\\p{L}]{3,32}|served[ \\t]+(?:time[ \\t]+(?:in|for)|a[ \\t]+(?:prison[ \\t]+)?sentence)|protestant|lutheran|mormon|hindu|sikh|jehovah'?s[ \\t]+witness)(?![\\p{L}\\p{N}_])`,
46
+ 'giu'
47
+ );
13
48
  const categoryCue =
14
- /(?<![\p{L}\p{N}_])(?:по[ \t]+национальности|национальность|этничность|вероисповедание|религия|диагноз|исповеду(?:ет|ют|ю|ешь)|боле(?:ет|ют|ю|ешь)|страда(?:ет|ют|ю|ешь)[ \t]+от|на[ \t]+уч[её]те[ \t]+у|diagnosed[ \t]+with|suffers[ \t]+from|nationality|ethnicity|religion|diagnosis)(?:["']?[ \t]*[:=][ \t]*|[ \t]+)["']?([\p{L}][\p{L}\p{M}-]{0,63})(?![\p{L}\p{M}\p{N}_])/giu;
49
+ /(?<![\p{L}\p{N}_])(?:по[ \t]+(?:национальности|происхождению)|национальность|этничность|вероисповедание|религия|диагноз|исповеду(?:ет|ют|ю|ешь)|боле(?:ет|ют|ю|ешь)|страда(?:ет|ют|ю|ешь)[ \t]+от|на[ \t]+уч[её]те[ \t]+у|diagnosed[ \t]+with|suffers[ \t]+from|nationality|ethnicity|religion|diagnosis)(?:["']?[ \t]*[:=][ \t]*|[ \t]+)["']?([\p{L}][\p{L}\p{M}-]{0,63})(?![\p{L}\p{M}\p{N}_])/giu;
50
+ // Words that are also languages, scripts or styles need a personal context.
15
51
  const ambiguous =
16
- /(?<![\p{L}\p{N}_])(?:disabled|single|man|men|russian|asian)(?![\p{L}\p{N}_])/giu;
52
+ /(?<![\p{L}\p{N}_])(?:disabled|single|man|men|russian|asian|uzbek|tajik|chechen|azerbaijani|korean|chinese|georgian|kazakh|kyrgyz|turkmen|uyghur|roma|baptist|union[ \t]+member)(?![\p{L}\p{N}_])/giu;
17
53
 
18
54
  function personalContext(text, match) {
19
55
  const prefix = text.slice(Math.max(0, match.index - 128), match.index);
@@ -56,8 +92,16 @@ export function detectStrictAttributes(text, emit, options) {
56
92
  detected++;
57
93
  emit(finding);
58
94
  };
59
- for (const pattern of [attributes, additionalAttributes, categoryForms]) {
60
- collectMatches(text, pattern, report, details);
95
+ for (const pattern of [
96
+ attributes,
97
+ additionalAttributes,
98
+ categoryForms,
99
+ specialVocabulary,
100
+ compiledVocabulary(options.vocabulary)?.attributes,
101
+ ]) {
102
+ if (pattern) {
103
+ collectMatches(text, pattern, report, details);
104
+ }
61
105
  }
62
106
  collectMatches(text, categoryCue, report, details, 1);
63
107
  collectMatches(text, ambiguous, report, details, 0, (_value, match) => {
package/src/strict.js CHANGED
@@ -2,7 +2,7 @@ import { collectMatches } from './detection.js';
2
2
  import { months } from './personal-context.js';
3
3
  import { isDocumentTechnology } from './document-experience.js';
4
4
  import { placeholderValues } from './placeholders.js';
5
- import { generatedValues } from './fake.js';
5
+ import { generatedValues } from './fake-memory.js';
6
6
  import { detectStrictAttributes } from './strict-attributes.js';
7
7
 
8
8
  const boundary = '[\\p{L}\\p{N}_]';
package/src/structured.js CHANGED
@@ -171,6 +171,7 @@ export function structuredFieldFindings(batch, options) {
171
171
  start: entry.start + (denied ? 0 : 1),
172
172
  end: entry.start + entry.raw.length - (denied ? 0 : 1),
173
173
  type: denied ? 'JSON_VALUE' : type,
174
+ ...(denied && type ? { valueType: type } : {}),
174
175
  category: 'personal',
175
176
  rule: 'known-personal',
176
177
  confidence: 1,
@@ -516,6 +517,7 @@ function declaredValueFinding(text, token, options) {
516
517
  start,
517
518
  end,
518
519
  type: 'JSON_VALUE',
520
+ ...(type ? { valueType: type } : {}),
519
521
  category: 'personal',
520
522
  rule: 'known-personal',
521
523
  confidence: 1,
@@ -0,0 +1,49 @@
1
+ // Offline structural check for prefixed tokens that end in a checksum.
2
+ // GitHub (ghp_, gho_, ghu_, ghs_, ghr_) and npm (npm_) tokens append six
3
+ // base62 characters: CRC32 (IEEE) of the characters after the first `_`.
4
+ // A valid checksum raises confidence; an invalid one never removes a finding
5
+ // because truncated or retyped tokens still need redaction.
6
+ const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
7
+ const CHECKSUM_TOKEN = /^(?:gh[opsur]|npm)_([A-Za-z0-9]{30})([A-Za-z0-9]{6})$/;
8
+
9
+ let table;
10
+ function crcTable() {
11
+ table ??= Array.from({ length: 256 }, (_, n) => {
12
+ let c = n;
13
+ for (let k = 0; k < 8; k++) {
14
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
15
+ }
16
+ return c >>> 0;
17
+ });
18
+ return table;
19
+ }
20
+
21
+ export function crc32(text) {
22
+ let crc = 0xffffffff;
23
+ for (let i = 0; i < text.length; i++) {
24
+ crc = crcTable()[(crc ^ text.charCodeAt(i)) & 0xff] ^ (crc >>> 8);
25
+ }
26
+ return (crc ^ 0xffffffff) >>> 0;
27
+ }
28
+
29
+ export function base62(value, width = 6) {
30
+ let out = '';
31
+ for (let i = 0; i < width; i++) {
32
+ out = BASE62[value % 62] + out;
33
+ value = Math.floor(value / 62);
34
+ }
35
+ return out;
36
+ }
37
+
38
+ // Builds a checksum-valid token from a prefix (including `_`) and a
39
+ // 30-character body, so tests and examples need no stored token literals.
40
+ export function tokenWithChecksum(prefix, body) {
41
+ return `${prefix}${body}${base62(crc32(body))}`;
42
+ }
43
+
44
+ // true: checksum matches; false: format matches but checksum does not;
45
+ // undefined: the value is not a checksummed token format.
46
+ export function tokenChecksum(value) {
47
+ const match = CHECKSUM_TOKEN.exec(value);
48
+ return match ? base62(crc32(match[1])) === match[2] : undefined;
49
+ }