@crewhaus/tool-secure 0.7.0 → 0.8.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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +18 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.js +113 -37
- package/dist/lib/injection.d.ts +31 -0
- package/dist/lib/injection.js +176 -6
- package/dist/lib/ipv6-scan.d.ts +16 -0
- package/dist/lib/ipv6-scan.js +73 -0
- package/dist/lib/literal.d.ts +55 -0
- package/dist/lib/literal.js +146 -0
- package/dist/lib/pii.d.ts +19 -30
- package/dist/lib/pii.js +71 -8
- package/dist/lib/policy.d.ts +24 -2
- package/dist/lib/policy.js +176 -79
- package/dist/lib/secrets.d.ts +8 -0
- package/dist/lib/secrets.js +43 -5
- package/dist/lib/text.d.ts +61 -2
- package/dist/lib/text.js +174 -5
- package/dist/paths.d.ts +21 -2
- package/dist/paths.js +72 -13
- package/package.json +12 -6
package/dist/lib/pii.js
CHANGED
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
* | `dob.labelled` / `date.calendar` | a calendar date. Only a nearby "date of birth"-style label, or a caller-supplied `referenceDate` that makes the age plausible, raises it above `possible`. |
|
|
29
29
|
* | `address.us-street-suffix` / `address.us-city-state-zip` | a US-style street line or city/state/ZIP tail. US conventions only. |
|
|
30
30
|
*/
|
|
31
|
-
import {
|
|
31
|
+
import { ipv6Candidates } from "./ipv6-scan";
|
|
32
|
+
import { matchAll, matchAllSkipping, withPositions } from "./text";
|
|
32
33
|
export const PII_TYPES = [
|
|
33
34
|
"credit_card",
|
|
34
35
|
"date_of_birth",
|
|
@@ -306,12 +307,74 @@ function ipv4Scope(octets) {
|
|
|
306
307
|
// ---------------------------------------------------------------------------
|
|
307
308
|
// patterns
|
|
308
309
|
// ---------------------------------------------------------------------------
|
|
309
|
-
const EMAIL = /[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+/g;
|
|
310
|
+
export const EMAIL = /[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+/g;
|
|
311
|
+
/** A character an email's local part may hold, besides the dots between its runs. */
|
|
312
|
+
function isLocalPartUnit(c) {
|
|
313
|
+
return ((c >= 0x30 && c <= 0x39) ||
|
|
314
|
+
(c >= 0x41 && c <= 0x5a) ||
|
|
315
|
+
(c >= 0x61 && c <= 0x7a) ||
|
|
316
|
+
"!#$%&'*+/=?^_`{|}~-".includes(String.fromCharCode(c)));
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Where a failed EMAIL try at `at` stops binding: the end of the dotted run
|
|
320
|
+
* of local-part characters that starts there. A later start inside it reads
|
|
321
|
+
* the same run to the same end and the same text after it, so it fails too.
|
|
322
|
+
*/
|
|
323
|
+
function localPartEnd(text, at) {
|
|
324
|
+
let i = at;
|
|
325
|
+
for (;;) {
|
|
326
|
+
while (i < text.length && isLocalPartUnit(text.charCodeAt(i)))
|
|
327
|
+
i++;
|
|
328
|
+
if (text.charCodeAt(i) !== 0x2e || !isLocalPartUnit(text.charCodeAt(i + 1)))
|
|
329
|
+
return i;
|
|
330
|
+
i++;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** Where ADDRESS_CITY_STATE_ZIP may begin: an ASCII capital at a word boundary. */
|
|
334
|
+
function startsCapitalWord(text, at) {
|
|
335
|
+
const c = text.charCodeAt(at);
|
|
336
|
+
if (c < 0x41 || c > 0x5a)
|
|
337
|
+
return false;
|
|
338
|
+
if (at === 0)
|
|
339
|
+
return true;
|
|
340
|
+
const p = text.charCodeAt(at - 1);
|
|
341
|
+
const word = (p >= 0x30 && p <= 0x39) || (p >= 0x41 && p <= 0x5a) || (p >= 0x61 && p <= 0x7a);
|
|
342
|
+
return !word && p !== 0x5f;
|
|
343
|
+
}
|
|
344
|
+
/** The end of the `[A-Za-z.'-]` run at `at`: a failed address try binds to it. */
|
|
345
|
+
function addressWordEnd(text, at) {
|
|
346
|
+
let i = at;
|
|
347
|
+
for (; i < text.length; i++) {
|
|
348
|
+
const c = text.charCodeAt(i);
|
|
349
|
+
const ok = (c >= 0x41 && c <= 0x5a) ||
|
|
350
|
+
(c >= 0x61 && c <= 0x7a) ||
|
|
351
|
+
c === 0x2e ||
|
|
352
|
+
c === 0x27 ||
|
|
353
|
+
c === 0x2d;
|
|
354
|
+
if (!ok)
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
return i;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* EMAIL's matches. It opens with an unbounded local-part run, so it is tried
|
|
361
|
+
* only where one starts, and a failed try skips the rest of that run (see
|
|
362
|
+
* matchAllSkipping): 100K of base64 cost seconds as a global search.
|
|
363
|
+
*/
|
|
364
|
+
export function emailMatches(text) {
|
|
365
|
+
return matchAllSkipping(text, EMAIL, (t, at) => isLocalPartUnit(t.charCodeAt(at)), localPartEnd);
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* ADDRESS_CITY_STATE_ZIP's matches, tried where a capitalised word starts; a
|
|
369
|
+
* failed try skips the rest of its `[A-Za-z.'-]` run.
|
|
370
|
+
*/
|
|
371
|
+
export function cityStateZipMatches(text) {
|
|
372
|
+
return matchAllSkipping(text, ADDRESS_CITY_STATE_ZIP, startsCapitalWord, addressWordEnd);
|
|
373
|
+
}
|
|
310
374
|
const SSN = /\b(\d{3})-(\d{2})-(\d{4})\b/g;
|
|
311
375
|
const IBAN_CANDIDATE = /\b[A-Z]{2}\d{2}[ ]?(?:[A-Z0-9]{4}[ ]?){2,7}[A-Z0-9]{1,4}\b/g;
|
|
312
376
|
const CARD_RUN = /\b\d(?:[ -]?\d){11,}/g;
|
|
313
377
|
const IPV4 = /\b\d{1,3}(?:\.\d{1,3}){3}\b/g;
|
|
314
|
-
const IPV6_CANDIDATE = /\b(?=[0-9A-Fa-f:]*::|(?:[0-9A-Fa-f]{1,4}:){7})[0-9A-Fa-f:]{2,39}(?:\.\d{1,3}){0,3}/g;
|
|
315
378
|
const PHONE_E164_RUN = /\+\d(?:[\s().-]?\d)+/g;
|
|
316
379
|
const DATE_ISO = /\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/g;
|
|
317
380
|
const DATE_US = /\b(0?[1-9]|1[0-2])\/(0?[1-9]|[12]\d|3[01])\/((?:19|20)\d{2})\b/g;
|
|
@@ -320,7 +383,7 @@ const DATE_LONG = /\b(January|February|March|April|May|June|July|August|Septembe
|
|
|
320
383
|
const DOB_LABEL = /(date of birth|d\.?o\.?b\.?|birth ?date|birthday|born(?:\s+on)?)\s*[:\-]?\s*$/i;
|
|
321
384
|
const STREET_SUFFIX = "(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive|Dr|Court|Ct|Way|Terrace|Ter|Place|Pl|Circle|Cir|Parkway|Pkwy|Highway|Hwy|Square|Sq|Trail|Trl)";
|
|
322
385
|
const ADDRESS_STREET = new RegExp(`\\b\\d{1,6}\\s+(?:[A-Za-z0-9.'#-]+\\s+){0,4}${STREET_SUFFIX}\\b\\.?(?:\\s+(?:Apt|Suite|Ste|Unit|#)\\s*[A-Za-z0-9-]+)?`, "g");
|
|
323
|
-
const ADDRESS_CITY_STATE_ZIP = /\b[A-Z][A-Za-z.'-]+(?:\s+[A-Z][A-Za-z.'-]+){0,3},\s*(A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|P[AR]|RI|S[CD]|T[NX]|UT|V[AIT]|W[AIVY])\s+\d{5}(?:-\d{4})?\b/g;
|
|
386
|
+
export const ADDRESS_CITY_STATE_ZIP = /\b[A-Z][A-Za-z.'-]+(?:\s+[A-Z][A-Za-z.'-]+){0,3},\s*(A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|P[AR]|RI|S[CD]|T[NX]|UT|V[AIT]|W[AIVY])\s+\d{5}(?:-\d{4})?\b/g;
|
|
324
387
|
const NATIONAL_PHONE = {
|
|
325
388
|
// NANP: area and exchange codes both start 2-9.
|
|
326
389
|
US: /(?:\(\d{3}\)|\b[2-9]\d{2})[\s.-]?[2-9]\d{2}[\s.-]?\d{4}\b/g,
|
|
@@ -503,7 +566,7 @@ export function scanPii(text, options = {}) {
|
|
|
503
566
|
reference = parsed;
|
|
504
567
|
}
|
|
505
568
|
if (selected(options, "email")) {
|
|
506
|
-
for (const { index, match } of
|
|
569
|
+
for (const { index, match } of emailMatches(text)) {
|
|
507
570
|
raw.push({
|
|
508
571
|
type: "email",
|
|
509
572
|
rule: "email.addr-spec-subset",
|
|
@@ -601,8 +664,8 @@ export function scanPii(text, options = {}) {
|
|
|
601
664
|
detail: { version: 4, scope },
|
|
602
665
|
});
|
|
603
666
|
}
|
|
604
|
-
for (const { index,
|
|
605
|
-
const candidate =
|
|
667
|
+
for (const { index, value } of ipv6Candidates(text)) {
|
|
668
|
+
const candidate = value.replace(/[:.]+$/, "");
|
|
606
669
|
if (!candidate.includes(":"))
|
|
607
670
|
continue;
|
|
608
671
|
const groups = parseIpv6(candidate);
|
|
@@ -720,7 +783,7 @@ export function scanPii(text, options = {}) {
|
|
|
720
783
|
detail: { convention: "US" },
|
|
721
784
|
});
|
|
722
785
|
}
|
|
723
|
-
for (const { index, match } of
|
|
786
|
+
for (const { index, match } of cityStateZipMatches(text)) {
|
|
724
787
|
raw.push({
|
|
725
788
|
type: "postal_address",
|
|
726
789
|
rule: "address.us-city-state-zip",
|
package/dist/lib/policy.d.ts
CHANGED
|
@@ -20,6 +20,11 @@ export type PolicyOutcome = {
|
|
|
20
20
|
readonly message: string;
|
|
21
21
|
readonly matches: ReadonlyArray<PolicyMatch>;
|
|
22
22
|
readonly matchCount: number;
|
|
23
|
+
/**
|
|
24
|
+
* Set when the pattern could not be run to the end of the text, or more
|
|
25
|
+
* matches exist than were counted: `matchCount` is then "at least".
|
|
26
|
+
*/
|
|
27
|
+
readonly matchCountIsLowerBound?: true;
|
|
23
28
|
};
|
|
24
29
|
export type PolicyResult = {
|
|
25
30
|
/**
|
|
@@ -38,5 +43,22 @@ export declare const MAX_MATCHES_PER_RULE = 20;
|
|
|
38
43
|
export declare class PolicyError extends Error {
|
|
39
44
|
readonly name = "PolicyError";
|
|
40
45
|
}
|
|
41
|
-
/**
|
|
42
|
-
export declare
|
|
46
|
+
/** Wall-clock budget for all of one check's patterns, shared in rule order. */
|
|
47
|
+
export declare const POLICY_PATTERN_DEADLINE_MS = 10000;
|
|
48
|
+
/** Where a check's patterns run: the call's abort signal, and whose runaway workers they count as. */
|
|
49
|
+
export type PolicyRunContext = {
|
|
50
|
+
readonly signal?: AbortSignal;
|
|
51
|
+
readonly runawayKey?: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Evaluate every rule against the text. One pass per rule, no early exit.
|
|
55
|
+
*
|
|
56
|
+
* Phrase rules are literals, matched on this thread by a search that passes
|
|
57
|
+
* each character of the text once, whatever the phrase ({@link LiteralSearch};
|
|
58
|
+
* 0.7.0's case-insensitive regex cost the text's length times the phrase's).
|
|
59
|
+
* Pattern rules run in the regex worker, one after another in one session,
|
|
60
|
+
* sharing {@link POLICY_PATTERN_DEADLINE_MS}.
|
|
61
|
+
*/
|
|
62
|
+
export declare function evaluatePolicy(text: string, rules: ReadonlyArray<PolicyRule>, ctx?: PolicyRunContext, options?: {
|
|
63
|
+
readonly deadlineMs?: number;
|
|
64
|
+
}): Promise<PolicyResult>;
|
package/dist/lib/policy.js
CHANGED
|
@@ -17,19 +17,24 @@
|
|
|
17
17
|
* alternatives — refusing the whole evaluation, or silently treating the rule
|
|
18
18
|
* as no-match — either block work over a typo or quietly turn a policy off.
|
|
19
19
|
*
|
|
20
|
-
* ##
|
|
20
|
+
* ## Patterns run in the regex worker, and an unanswered one is an error
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
22
|
+
* The rules arrive in the tool's input, so a model (or text it read) writes
|
|
23
|
+
* them. 0.7.0 compiled each `*_pattern` and ran it on this thread over text
|
|
24
|
+
* of up to 2,000,000 characters: `(a+)+!$|guaranteed returns` held the
|
|
25
|
+
* process 0.75 s and then answered "forbidden text is absent" — JavaScriptCore
|
|
26
|
+
* gives up on a runaway match and reports it as no match — so a gate passed
|
|
27
|
+
* text it had not checked (C073). Patterns now run in
|
|
28
|
+
* `@crewhaus/tool-safety`'s regex worker under a deadline. A pattern the
|
|
29
|
+
* screen refuses (a shape that backtracks exponentially) is an `error`, like
|
|
30
|
+
* one that does not compile. A run that cannot finish is an `error` too,
|
|
31
|
+
* "not evaluated", unless the matches it found before stopping already
|
|
32
|
+
* decide the rule (a forbidden pattern that matched has failed); a count
|
|
33
|
+
* from a run that stopped early is a lower bound, and says so.
|
|
31
34
|
*/
|
|
32
|
-
import {
|
|
35
|
+
import { describeRegexOutcome, openRegexSession, } from "@crewhaus/tool-safety/regex";
|
|
36
|
+
import { LiteralSearch } from "./literal";
|
|
37
|
+
import { compareStrings } from "./text";
|
|
33
38
|
import { lineStarts, locate } from "./text";
|
|
34
39
|
export const POLICY_RULE_KINDS = [
|
|
35
40
|
"forbidden_pattern",
|
|
@@ -46,11 +51,59 @@ const EXCERPT_CHARS = 120;
|
|
|
46
51
|
export class PolicyError extends Error {
|
|
47
52
|
name = "PolicyError";
|
|
48
53
|
}
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
/** Wall-clock budget for all of one check's patterns, shared in rule order. */
|
|
55
|
+
export const POLICY_PATTERN_DEADLINE_MS = 10_000;
|
|
56
|
+
/** The pattern screen's limits for a rule. */
|
|
57
|
+
const PATTERN_LIMITS = { maxPatternChars: 10_000 };
|
|
58
|
+
/** Matches counted per pattern rule; past it the count is a lower bound. */
|
|
59
|
+
const MAX_COUNTED_MATCHES = 10_000;
|
|
60
|
+
/**
|
|
61
|
+
* Every match of a `*_pattern` rule's source, run in the regex worker: the
|
|
62
|
+
* matches, or why the pattern could not be run at all (`refused`).
|
|
63
|
+
*/
|
|
64
|
+
async function findPattern(session, text, source, flags, deadlineMs, ctx) {
|
|
65
|
+
const outcome = await session.run({
|
|
66
|
+
op: "matchAll",
|
|
67
|
+
pattern: source,
|
|
68
|
+
flags,
|
|
69
|
+
input: text,
|
|
70
|
+
maxMatches: MAX_COUNTED_MATCHES,
|
|
71
|
+
deadlineMs,
|
|
72
|
+
maxInputChars: Math.max(1, text.length),
|
|
73
|
+
limits: PATTERN_LIMITS,
|
|
74
|
+
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
|
75
|
+
...(ctx.runawayKey === undefined ? {} : { runawayKey: ctx.runawayKey }),
|
|
76
|
+
});
|
|
77
|
+
if (outcome.status === "rejected")
|
|
78
|
+
return { refused: describeRegexOutcome(outcome) };
|
|
79
|
+
if (outcome.status === "ok") {
|
|
80
|
+
const all = outcome.result.matches;
|
|
81
|
+
return {
|
|
82
|
+
matches: all.slice(0, MAX_MATCHES_PER_RULE),
|
|
83
|
+
count: all.length,
|
|
84
|
+
lowerBound: outcome.result.truncated,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const partial = outcome.status === "gave-up" || outcome.status === "timeout"
|
|
88
|
+
? (outcome.partial?.matches ?? [])
|
|
89
|
+
: [];
|
|
90
|
+
return {
|
|
91
|
+
matches: partial.slice(0, MAX_MATCHES_PER_RULE),
|
|
92
|
+
count: partial.length,
|
|
93
|
+
stopped: describeRegexOutcome(outcome),
|
|
94
|
+
lowerBound: true,
|
|
95
|
+
};
|
|
51
96
|
}
|
|
52
|
-
/**
|
|
53
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Evaluate every rule against the text. One pass per rule, no early exit.
|
|
99
|
+
*
|
|
100
|
+
* Phrase rules are literals, matched on this thread by a search that passes
|
|
101
|
+
* each character of the text once, whatever the phrase ({@link LiteralSearch};
|
|
102
|
+
* 0.7.0's case-insensitive regex cost the text's length times the phrase's).
|
|
103
|
+
* Pattern rules run in the regex worker, one after another in one session,
|
|
104
|
+
* sharing {@link POLICY_PATTERN_DEADLINE_MS}.
|
|
105
|
+
*/
|
|
106
|
+
export async function evaluatePolicy(text, rules, ctx = {}, options = {}) {
|
|
54
107
|
if (rules.length > MAX_POLICY_RULES) {
|
|
55
108
|
throw new PolicyError(`${rules.length} rules, over the ${MAX_POLICY_RULES} limit`);
|
|
56
109
|
}
|
|
@@ -63,72 +116,50 @@ export function evaluatePolicy(text, rules) {
|
|
|
63
116
|
}
|
|
64
117
|
const starts = lineStarts(text);
|
|
65
118
|
const outcomes = [];
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
119
|
+
const deadlineMs = options.deadlineMs ?? POLICY_PATTERN_DEADLINE_MS;
|
|
120
|
+
const started = performance.now();
|
|
121
|
+
let session;
|
|
122
|
+
let literals;
|
|
123
|
+
try {
|
|
124
|
+
for (const rule of rules) {
|
|
125
|
+
const isPattern = rule.kind.endsWith("_pattern");
|
|
126
|
+
let found;
|
|
127
|
+
if (isPattern) {
|
|
128
|
+
const left = Math.floor(deadlineMs - (performance.now() - started));
|
|
129
|
+
session ??= openRegexSession();
|
|
130
|
+
const run = left < 1
|
|
131
|
+
? {
|
|
132
|
+
matches: [],
|
|
133
|
+
count: 0,
|
|
134
|
+
stopped: `the check's ${deadlineMs} ms for running patterns ran out before this rule`,
|
|
135
|
+
lowerBound: true,
|
|
136
|
+
}
|
|
137
|
+
: await findPattern(session, text, rule.value, rule.caseSensitive === true ? "" : "i", left, ctx);
|
|
138
|
+
if ("refused" in run) {
|
|
139
|
+
outcomes.push({
|
|
140
|
+
id: rule.id,
|
|
141
|
+
kind: rule.kind,
|
|
142
|
+
status: "error",
|
|
143
|
+
message: `rule not evaluated: invalid regular expression /${rule.value}/ — ${run.refused}`,
|
|
144
|
+
matches: [],
|
|
145
|
+
matchCount: 0,
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
found = run;
|
|
91
150
|
}
|
|
151
|
+
else {
|
|
152
|
+
// A literal: linear to search, so it runs here, counted in full and
|
|
153
|
+
// located for the first few.
|
|
154
|
+
literals ??= new LiteralSearch(text);
|
|
155
|
+
const { matches, count } = literals.find(rule.value, rule.caseSensitive === true, MAX_MATCHES_PER_RULE);
|
|
156
|
+
found = { matches, count, lowerBound: false };
|
|
157
|
+
}
|
|
158
|
+
outcomes.push(judge(rule, found, starts));
|
|
92
159
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
outcomes.push({
|
|
97
|
-
id: rule.id,
|
|
98
|
-
kind: rule.kind,
|
|
99
|
-
status: present ? "pass" : "fail",
|
|
100
|
-
message: present
|
|
101
|
-
? `required text found ${matchCount} time(s)${describe}`
|
|
102
|
-
: `required text is absent${describe}`,
|
|
103
|
-
// A required rule that passed needs no locations; one that failed has none.
|
|
104
|
-
matches: [],
|
|
105
|
-
matchCount,
|
|
106
|
-
});
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (rule.kind === "review_pattern") {
|
|
110
|
-
outcomes.push({
|
|
111
|
-
id: rule.id,
|
|
112
|
-
kind: rule.kind,
|
|
113
|
-
status: present ? "review" : "pass",
|
|
114
|
-
message: present
|
|
115
|
-
? `${matchCount} passage(s) need a human decision${describe}`
|
|
116
|
-
: `nothing matched${describe}`,
|
|
117
|
-
matches,
|
|
118
|
-
matchCount,
|
|
119
|
-
});
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
outcomes.push({
|
|
123
|
-
id: rule.id,
|
|
124
|
-
kind: rule.kind,
|
|
125
|
-
status: present ? "fail" : "pass",
|
|
126
|
-
message: present
|
|
127
|
-
? `forbidden text found ${matchCount} time(s)${describe}`
|
|
128
|
-
: `forbidden text is absent${describe}`,
|
|
129
|
-
matches,
|
|
130
|
-
matchCount,
|
|
131
|
-
});
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
session?.close();
|
|
132
163
|
}
|
|
133
164
|
const counts = { pass: 0, fail: 0, review: 0, error: 0 };
|
|
134
165
|
for (const outcome of outcomes)
|
|
@@ -136,3 +167,69 @@ export function evaluatePolicy(text, rules) {
|
|
|
136
167
|
outcomes.sort((a, b) => compareStrings(a.id, b.id));
|
|
137
168
|
return { pass: counts.fail === 0 && counts.error === 0, outcomes, counts };
|
|
138
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* One rule's outcome from what its scan found. A scan that stopped early is
|
|
172
|
+
* a verdict only when what it found already decides the rule; otherwise the
|
|
173
|
+
* rule was not evaluated, and says why.
|
|
174
|
+
*/
|
|
175
|
+
function judge(rule, found, starts) {
|
|
176
|
+
const matches = [];
|
|
177
|
+
for (const { index, match } of found.matches) {
|
|
178
|
+
const { line, column } = locate(starts, index);
|
|
179
|
+
matches.push({ line, column, excerpt: match.slice(0, EXCERPT_CHARS) });
|
|
180
|
+
}
|
|
181
|
+
const matchCount = found.count;
|
|
182
|
+
const present = matchCount > 0;
|
|
183
|
+
const atLeast = found.lowerBound ? { matchCountIsLowerBound: true } : {};
|
|
184
|
+
const describe = rule.description === undefined ? "" : ` (${rule.description})`;
|
|
185
|
+
if (!present && found.stopped !== undefined) {
|
|
186
|
+
return {
|
|
187
|
+
id: rule.id,
|
|
188
|
+
kind: rule.kind,
|
|
189
|
+
status: "error",
|
|
190
|
+
message: `rule not evaluated: ${found.stopped} — nothing had matched before it stopped, which is not the same as nothing matching${describe}`,
|
|
191
|
+
matches: [],
|
|
192
|
+
matchCount: 0,
|
|
193
|
+
...atLeast,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const count = found.lowerBound ? `at least ${matchCount}` : `${matchCount}`;
|
|
197
|
+
if (rule.kind === "required_phrase" || rule.kind === "required_pattern") {
|
|
198
|
+
return {
|
|
199
|
+
id: rule.id,
|
|
200
|
+
kind: rule.kind,
|
|
201
|
+
status: present ? "pass" : "fail",
|
|
202
|
+
message: present
|
|
203
|
+
? `required text found ${count} time(s)${describe}`
|
|
204
|
+
: `required text is absent${describe}`,
|
|
205
|
+
// A required rule that passed needs no locations; one that failed has none.
|
|
206
|
+
matches: [],
|
|
207
|
+
matchCount,
|
|
208
|
+
...atLeast,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (rule.kind === "review_pattern") {
|
|
212
|
+
return {
|
|
213
|
+
id: rule.id,
|
|
214
|
+
kind: rule.kind,
|
|
215
|
+
status: present ? "review" : "pass",
|
|
216
|
+
message: present
|
|
217
|
+
? `${count} passage(s) need a human decision${describe}`
|
|
218
|
+
: `nothing matched${describe}`,
|
|
219
|
+
matches,
|
|
220
|
+
matchCount,
|
|
221
|
+
...atLeast,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
id: rule.id,
|
|
226
|
+
kind: rule.kind,
|
|
227
|
+
status: present ? "fail" : "pass",
|
|
228
|
+
message: present
|
|
229
|
+
? `forbidden text found ${count} time(s)${describe}`
|
|
230
|
+
: `forbidden text is absent${describe}`,
|
|
231
|
+
matches,
|
|
232
|
+
matchCount,
|
|
233
|
+
...atLeast,
|
|
234
|
+
};
|
|
235
|
+
}
|
package/dist/lib/secrets.d.ts
CHANGED
|
@@ -40,6 +40,14 @@ export type SecretRule = {
|
|
|
40
40
|
readonly pattern: RegExp;
|
|
41
41
|
/** Which capture group holds the secret itself. 0 = the whole match. */
|
|
42
42
|
readonly group?: number;
|
|
43
|
+
/**
|
|
44
|
+
* The pattern's matches when a plain global search would rescan a run;
|
|
45
|
+
* the same matches as `matchAll(text, pattern)`.
|
|
46
|
+
*/
|
|
47
|
+
readonly matches?: (text: string) => Iterable<{
|
|
48
|
+
index: number;
|
|
49
|
+
match: RegExpExecArray;
|
|
50
|
+
}>;
|
|
43
51
|
};
|
|
44
52
|
/**
|
|
45
53
|
* The rule table, in scan order. Ids are stable: an operator's allow-list of
|
package/dist/lib/secrets.js
CHANGED
|
@@ -30,7 +30,33 @@
|
|
|
30
30
|
* scanner that cries wolf on them gets switched off.
|
|
31
31
|
*/
|
|
32
32
|
import { classifyCharset, looksHighEntropy, roundBits, shannonEntropy, } from "./entropy";
|
|
33
|
-
import { groupSpan, maskValue, matchAll, sortFindings, withPositions } from "./text";
|
|
33
|
+
import { SpanIndex, groupSpan, maskValue, matchAll, matchAllSkipping, sortFindings, withPositions, } from "./text";
|
|
34
|
+
const JWT_COMPACT = /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
|
|
35
|
+
/** A code unit `\w` matches. */
|
|
36
|
+
function isWordUnit(c) {
|
|
37
|
+
return ((c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || c === 0x5f);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* JWT_COMPACT's matches. Its first segment reads to the end of a run of
|
|
41
|
+
* `[A-Za-z0-9_-]` and needs a `.` there, so a run with `eyJ` at many word
|
|
42
|
+
* boundaries (`eyJ-eyJ-…`) was read to its end again from every one:
|
|
43
|
+
* quadratic in the run, on the megabytes `SecretScan` and `RedactForExport`
|
|
44
|
+
* take. A later start in the same run meets the same end, with a shorter
|
|
45
|
+
* first segment and the same text after it, so a try that fails rules out
|
|
46
|
+
* the rest of the run.
|
|
47
|
+
*/
|
|
48
|
+
function jwtCompactMatches(text) {
|
|
49
|
+
return matchAllSkipping(text, JWT_COMPACT, (t, at) => t.charCodeAt(at) === 0x65 &&
|
|
50
|
+
t.charCodeAt(at + 1) === 0x79 &&
|
|
51
|
+
t.charCodeAt(at + 2) === 0x4a &&
|
|
52
|
+
(at === 0 || !isWordUnit(t.charCodeAt(at - 1))), (t, at) => {
|
|
53
|
+
let end = at;
|
|
54
|
+
while (end < t.length && (isWordUnit(t.charCodeAt(end)) || t.charCodeAt(end) === 0x2d)) {
|
|
55
|
+
end++;
|
|
56
|
+
}
|
|
57
|
+
return end;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
34
60
|
/**
|
|
35
61
|
* The rule table, in scan order. Ids are stable: an operator's allow-list of
|
|
36
62
|
* accepted findings keys on them.
|
|
@@ -161,7 +187,8 @@ export const SECRET_RULES = [
|
|
|
161
187
|
kind: "structural",
|
|
162
188
|
description: "JWT in compact serialization; a bearer token unless it is a public id token",
|
|
163
189
|
severity: "medium",
|
|
164
|
-
pattern:
|
|
190
|
+
pattern: JWT_COMPACT,
|
|
191
|
+
matches: jwtCompactMatches,
|
|
165
192
|
},
|
|
166
193
|
{
|
|
167
194
|
id: "url.credentials",
|
|
@@ -234,7 +261,7 @@ export function secretFindings(text, options = {}) {
|
|
|
234
261
|
const rules = selectRules(options);
|
|
235
262
|
const raw = [];
|
|
236
263
|
for (const rule of rules) {
|
|
237
|
-
for (const { match } of matchAll(text, rule.pattern)) {
|
|
264
|
+
for (const { match } of rule.matches?.(text) ?? matchAll(text, rule.pattern)) {
|
|
238
265
|
const group = rule.group ?? 0;
|
|
239
266
|
const value = match[group];
|
|
240
267
|
if (value === undefined || value.length === 0)
|
|
@@ -260,6 +287,7 @@ export function secretFindings(text, options = {}) {
|
|
|
260
287
|
}
|
|
261
288
|
if (options.highEntropy !== false) {
|
|
262
289
|
const requireContext = options.requireContext !== false;
|
|
290
|
+
const candidates = [];
|
|
263
291
|
for (const { index, match } of matchAll(text, HIGH_ENTROPY_CANDIDATE)) {
|
|
264
292
|
const value = match[0];
|
|
265
293
|
if (PLACEHOLDER.test(value))
|
|
@@ -272,8 +300,17 @@ export function secretFindings(text, options = {}) {
|
|
|
272
300
|
if (!CREDENTIAL_CONTEXT.test(before))
|
|
273
301
|
continue;
|
|
274
302
|
}
|
|
275
|
-
|
|
276
|
-
|
|
303
|
+
candidates.push({ index, value });
|
|
304
|
+
}
|
|
305
|
+
// A vendor rule already covering this span is the better finding. Each
|
|
306
|
+
// candidate is checked against every finding pushed before it, as it
|
|
307
|
+
// always was, but through a `SpanIndex`: checking them one by one was
|
|
308
|
+
// quadratic in the findings on SecretScan's megabytes of text.
|
|
309
|
+
const covered = new SpanIndex(raw.map((f) => f.start).concat(candidates.map((c) => c.index)));
|
|
310
|
+
for (const f of raw)
|
|
311
|
+
covered.add(f.start, f.end);
|
|
312
|
+
for (const { index, value } of candidates) {
|
|
313
|
+
if (covered.overlaps(index, index + value.length))
|
|
277
314
|
continue;
|
|
278
315
|
raw.push({
|
|
279
316
|
type: HIGH_ENTROPY_RULE,
|
|
@@ -284,6 +321,7 @@ export function secretFindings(text, options = {}) {
|
|
|
284
321
|
value,
|
|
285
322
|
detail: { severity: "low" },
|
|
286
323
|
});
|
|
324
|
+
covered.add(index, index + value.length);
|
|
287
325
|
}
|
|
288
326
|
}
|
|
289
327
|
return sortFindings(withPositions(text, raw));
|
package/dist/lib/text.d.ts
CHANGED
|
@@ -77,6 +77,37 @@ export declare function compareStrings(a: string, b: string): number;
|
|
|
77
77
|
* that contains a live credential. It is tested directly.
|
|
78
78
|
*/
|
|
79
79
|
export declare function maskValue(value: string, keep?: number): string;
|
|
80
|
+
/**
|
|
81
|
+
* Spans, asked one question: does `[start, end)` overlap any of them?
|
|
82
|
+
*
|
|
83
|
+
* Overlap is `start < k.end && k.start < end`, the test `dedupeOverlaps` and
|
|
84
|
+
* `secretFindings` make, answered for any numbers, empty spans included.
|
|
85
|
+
* Among the spans that start before `end`, one overlaps exactly when the
|
|
86
|
+
* largest of their ends is past `start`. So the index keeps that largest end
|
|
87
|
+
* in a Fenwick tree with one slot per distinct start, and a question or an
|
|
88
|
+
* addition visits O(log n) slots. Checking every span kept so far made
|
|
89
|
+
* those two passes quadratic in the findings, on text a model or a web page
|
|
90
|
+
* supplies.
|
|
91
|
+
*
|
|
92
|
+
* Every start a span will be added with is named up front, because the
|
|
93
|
+
* slots are the distinct starts in order. `steps` counts the slots and
|
|
94
|
+
* probes the calls visit, so a test can hold the index to its bound.
|
|
95
|
+
*/
|
|
96
|
+
export declare class SpanIndex {
|
|
97
|
+
/** Tree slots and binary-search probes visited so far. */
|
|
98
|
+
steps: number;
|
|
99
|
+
/** The distinct starts, ascending. */
|
|
100
|
+
private readonly starts;
|
|
101
|
+
/** 1-based Fenwick tree of the largest end per prefix of `starts`. */
|
|
102
|
+
private readonly maxEnd;
|
|
103
|
+
constructor(starts: ReadonlyArray<number>);
|
|
104
|
+
/** How many distinct starts are below `bound`; none are below NaN. */
|
|
105
|
+
private below;
|
|
106
|
+
/** Whether some span added so far has `k.start < end && start < k.end`. */
|
|
107
|
+
overlaps(start: number, end: number): boolean;
|
|
108
|
+
/** Add `[start, end)`; `start` must be one the index was built with. */
|
|
109
|
+
add(start: number, end: number): void;
|
|
110
|
+
}
|
|
80
111
|
/**
|
|
81
112
|
* Drop findings swallowed by a stronger one.
|
|
82
113
|
*
|
|
@@ -85,6 +116,10 @@ export declare function maskValue(value: string, keep?: number): string;
|
|
|
85
116
|
* survivor is chosen by confidence, then by length, then by earliest start,
|
|
86
117
|
* then by rule name — a total order, so the same input always yields the
|
|
87
118
|
* same set.
|
|
119
|
+
*
|
|
120
|
+
* Each candidate, in that order, is kept unless it overlaps one kept before
|
|
121
|
+
* it. A `SpanIndex` answers that, so the pass is O(k log k) in the findings
|
|
122
|
+
* rather than a check against every finding kept so far.
|
|
88
123
|
*/
|
|
89
124
|
export declare function dedupeOverlaps(findings: ReadonlyArray<Finding>): Finding[];
|
|
90
125
|
/** Reading order, with a deterministic tiebreak. */
|
|
@@ -94,8 +129,14 @@ export declare function withPositions(text: string, raw: ReadonlyArray<Omit<Find
|
|
|
94
129
|
/**
|
|
95
130
|
* Replace each span with the string `replace` returns for it.
|
|
96
131
|
*
|
|
97
|
-
* Spans must not overlap (`dedupeOverlaps` guarantees that)
|
|
98
|
-
*
|
|
132
|
+
* Spans must not overlap (`dedupeOverlaps` guarantees that). `replace` is
|
|
133
|
+
* called right to left, as it always has been, and the output is the text
|
|
134
|
+
* between the spans joined with their replacements: one copy of the text.
|
|
135
|
+
* Rebuilding the whole string for every span was O(spans × text), and a
|
|
136
|
+
* redaction pass over megabytes of findings-dense text has hundreds of
|
|
137
|
+
* thousands of spans. Spans that overlap, sit out of order or outside the
|
|
138
|
+
* text, or have fractional offsets keep the old right-to-left rebuild, which
|
|
139
|
+
* gives the same result on spans that do none of those things.
|
|
99
140
|
*/
|
|
100
141
|
export declare function applySpans(text: string, findings: ReadonlyArray<Finding>, replace: (finding: Finding) => string): string;
|
|
101
142
|
/** Tally findings by `type`, sorted by type so the record is stable. */
|
|
@@ -116,6 +157,24 @@ export declare function matchAll(text: string, pattern: RegExp): Generator<{
|
|
|
116
157
|
index: number;
|
|
117
158
|
match: RegExpExecArray;
|
|
118
159
|
}>;
|
|
160
|
+
/**
|
|
161
|
+
* `matchAll(text, pattern)`'s matches, in order, with the pattern tried
|
|
162
|
+
* (sticky) only where `canStart` says a match may begin, and, after a try
|
|
163
|
+
* that fails at `at`, not again before `failedTo(text, at)`.
|
|
164
|
+
*
|
|
165
|
+
* For a pattern that opens with an unbounded run (an email's local part, a
|
|
166
|
+
* word before a comma, the whitespace after a line start), a global search
|
|
167
|
+
* that fails at one start moves one character right and scans the same run
|
|
168
|
+
* again: quadratic in the run's length. Every later start inside such a run
|
|
169
|
+
* shares the run's end and reads the same text after it, so it fails too;
|
|
170
|
+
* `failedTo` names where that stops being true, and the matches are the
|
|
171
|
+
* global pattern's, start for start. After a match the next try is at its
|
|
172
|
+
* end (one further for an empty match), as `lastIndex` would be.
|
|
173
|
+
*/
|
|
174
|
+
export declare function matchAllSkipping(text: string, pattern: RegExp, canStart: (text: string, at: number) => boolean, failedTo: (text: string, at: number) => number): Generator<{
|
|
175
|
+
index: number;
|
|
176
|
+
match: RegExpExecArray;
|
|
177
|
+
}>;
|
|
119
178
|
/**
|
|
120
179
|
* The absolute `[start, end)` of one capture group of a match produced by
|
|
121
180
|
* `matchAll`, or `undefined` when the group did not participate.
|