@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.
- package/CHANGELOG.md +12 -0
- package/README.md +22 -2
- package/bin/sensitive-data-sanitizer.js +50 -28
- package/docs/API.md +355 -16
- package/docs/COVERAGE.md +19 -9
- package/docs/RULES.md +218 -0
- package/examples/second-pass-placeholders.mjs +28 -0
- package/package.json +12 -3
- package/src/allow-list.js +93 -0
- package/src/confidence.js +24 -9
- package/src/context.js +287 -40
- package/src/credential-spans.js +180 -0
- package/src/credentials-entry.d.ts +23 -0
- package/src/credentials-entry.js +62 -0
- package/src/credentials.js +51 -0
- package/src/crypto-addresses.js +74 -0
- package/src/detection.js +87 -41
- package/src/document-person-cues.js +1 -1
- package/src/encoded.js +137 -51
- package/src/encrypt.js +95 -0
- package/src/engines.js +33 -10
- package/src/entities.js +4 -3
- package/src/exempt.js +72 -0
- package/src/fake-memory.js +32 -0
- package/src/fake.js +33 -29
- package/src/features.js +29 -0
- package/src/files.js +57 -12
- package/src/gitleaks.js +82 -27
- package/src/html-tags.js +8 -0
- package/src/identifier-detector.js +36 -0
- package/src/identity-types.js +63 -0
- package/src/identity.js +3 -47
- package/src/index.d.ts +157 -3
- package/src/index.js +13 -0
- package/src/known.js +33 -15
- package/src/national-checks.js +209 -0
- package/src/national.js +310 -92
- package/src/pattern-detector.js +219 -0
- package/src/personal-context.js +3 -2
- package/src/personal-evidence.js +233 -0
- package/src/personal-features.js +39 -0
- package/src/personal.js +66 -10
- package/src/placeholder-types.js +154 -4
- package/src/placeholders.js +127 -45
- package/src/projection.js +78 -26
- package/src/proper-names.js +48 -5
- package/src/prose-words.js +120 -0
- package/src/record-worker.js +1 -0
- package/src/rule-catalog.js +426 -0
- package/src/rules.js +8 -0
- package/src/sanitizer.js +133 -23
- package/src/sarif.js +127 -0
- package/src/stream-worker.js +1 -0
- package/src/strict-attributes.js +48 -4
- package/src/strict.js +1 -1
- package/src/structured.js +2 -0
- package/src/token-checksum.js +49 -0
- package/src/transforms.js +146 -33
- package/src/vendor/betterleaks/LICENSE +21 -0
- package/src/vendor/betterleaks/provenance.json +11 -0
- package/src/vendor/betterleaks/rules-0.json +463 -0
- package/src/vendor/betterleaks/rules-1.json +454 -0
- package/src/vendor/betterleaks/rules-2.json +461 -0
- package/src/vendor/betterleaks/rules-3.json +455 -0
- package/src/vendor/betterleaks/rules-4.json +483 -0
- package/src/vendor/betterleaks/rules-5.json +35 -0
- package/src/vendor/entities/aws.json +1 -1
- package/src/vendor/entities/azure.json +6 -2
- package/src/vendor/entities/google.json +2 -1
- package/src/vocabulary.js +27 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? [])]
|
|
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) => ({
|
|
697
|
+
return findings.map((f) => ({
|
|
698
|
+
...f,
|
|
699
|
+
kept: tokenKept.has(f) ? 'fake' : undefined,
|
|
700
|
+
}));
|
|
611
701
|
}
|
|
612
|
-
const documents = fakeDocumentSpans
|
|
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
|
|
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 =
|
|
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 (
|
|
909
|
-
|
|
910
|
-
|
|
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
|
+
}
|
package/src/stream-worker.js
CHANGED
package/src/strict-attributes.js
CHANGED
|
@@ -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]
|
|
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 [
|
|
60
|
-
|
|
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
|
+
}
|