@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/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 { matchAll, withPositions } from "./text";
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 matchAll(text, EMAIL)) {
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, match } of matchAll(text, IPV6_CANDIDATE)) {
605
- const candidate = match[0].replace(/[:.]+$/, "");
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 matchAll(text, ADDRESS_CITY_STATE_ZIP)) {
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",
@@ -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
- /** Evaluate every rule against the text. One pass per rule, no early exit. */
42
- export declare function evaluatePolicy(text: string, rules: ReadonlyArray<PolicyRule>): PolicyResult;
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>;
@@ -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
- * ## The regex is the operator's, and it runs untimed
20
+ * ## Patterns run in the regex worker, and an unanswered one is an error
21
21
  *
22
- * `*_pattern` rules compile caller-supplied regex sources and run them over
23
- * text up to the package's 2,000,000-character cap. JavaScript's engine
24
- * backtracks, so a pattern like `(a+)+$` can take time exponential in the
25
- * length of the text it fails on. There is no timeout here, because there is
26
- * no way to interrupt a regex mid-match in this runtime. The rules are
27
- * operator-written, not attacker-written, which is what makes this
28
- * acceptable — but an operator who pastes a pattern from untrusted content
29
- * has handed that content a stall. Write anchored patterns with no nested
30
- * unbounded quantifier.
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 { compareStrings, matchAll } from "./text";
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
- function escapeRegex(literal) {
50
- return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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
- /** Evaluate every rule against the text. One pass per rule, no early exit. */
53
- export function evaluatePolicy(text, rules) {
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
- for (const rule of rules) {
67
- const flags = rule.caseSensitive === true ? "g" : "gi";
68
- const isPattern = rule.kind.endsWith("_pattern");
69
- let re;
70
- try {
71
- re = new RegExp(isPattern ? rule.value : escapeRegex(rule.value), flags);
72
- }
73
- catch (err) {
74
- outcomes.push({
75
- id: rule.id,
76
- kind: rule.kind,
77
- status: "error",
78
- message: `rule not evaluated: invalid regular expression /${rule.value}/ — ${err.message}`,
79
- matches: [],
80
- matchCount: 0,
81
- });
82
- continue;
83
- }
84
- const matches = [];
85
- let matchCount = 0;
86
- for (const { index, match } of matchAll(text, re)) {
87
- matchCount += 1;
88
- if (matches.length < MAX_MATCHES_PER_RULE) {
89
- const { line, column } = locate(starts, index);
90
- matches.push({ line, column, excerpt: match[0].slice(0, EXCERPT_CHARS) });
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
- const present = matchCount > 0;
94
- const describe = rule.description === undefined ? "" : ` (${rule.description})`;
95
- if (rule.kind === "required_phrase" || rule.kind === "required_pattern") {
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
+ }
@@ -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
@@ -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: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
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
- // A vendor rule already covering this span is the better finding.
276
- if (raw.some((f) => index < f.end && f.start < index + value.length))
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));
@@ -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); they are
98
- * applied right to left so earlier offsets stay valid.
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.