redac---n 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +11 -10
  2. package/dist/calls.d.ts +8 -0
  3. package/dist/calls.js +60 -3
  4. package/dist/checksums.js +4 -3
  5. package/dist/commands.d.ts +5 -0
  6. package/dist/commands.js +93 -14
  7. package/dist/context.d.ts +5 -2
  8. package/dist/context.js +36 -9
  9. package/dist/encoded.js +13 -6
  10. package/dist/environment.d.ts +5 -3
  11. package/dist/environment.js +38 -14
  12. package/dist/formats.js +15 -0
  13. package/dist/index.js +10 -4
  14. package/dist/json.js +29 -5
  15. package/dist/known.d.ts +7 -7
  16. package/dist/known.js +33 -25
  17. package/dist/named.d.ts +6 -0
  18. package/dist/named.js +421 -125
  19. package/dist/names.d.ts +5 -1
  20. package/dist/names.js +143 -18
  21. package/dist/normalize.d.ts +4 -1
  22. package/dist/normalize.js +37 -11
  23. package/dist/pem.d.ts +2 -0
  24. package/dist/pem.js +12 -0
  25. package/dist/policy.js +12 -5
  26. package/dist/public.js +35 -13
  27. package/dist/secretfile.d.ts +5 -0
  28. package/dist/secretfile.js +49 -20
  29. package/dist/shell.d.ts +12 -3
  30. package/dist/shell.js +51 -7
  31. package/dist/spans.d.ts +4 -3
  32. package/dist/spans.js +79 -8
  33. package/dist/sql.d.ts +12 -8
  34. package/dist/sql.js +94 -16
  35. package/dist/stream.d.ts +2 -1
  36. package/dist/stream.js +15 -3
  37. package/dist/structure.js +210 -61
  38. package/dist/syntax.d.ts +5 -2
  39. package/dist/syntax.js +7 -4
  40. package/dist/transcript.js +116 -28
  41. package/dist/unnamed.js +128 -20
  42. package/dist/urls.d.ts +8 -3
  43. package/dist/urls.js +16 -7
  44. package/dist/values.d.ts +2 -1
  45. package/dist/values.js +70 -25
  46. package/docs/holdout.svg +8 -8
  47. package/docs/layers.md +1 -1
  48. package/package.json +1 -1
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * The secrets in an environment, as known values: variables whose name has a credential word (`*_API_KEY`, `*_TOKEN`,
3
- * `BW_SESSION`, read widely), a vendor's token under any name, and URL passwords inside any variable. Only values a
4
- * Redactor accepts as known values (the rule `addKnown` applies) are returned; a short or plain-word value is left out.
5
- * `new Redactor({ known: fromEnvironment(process.env) })`.
3
+ * `BW_SESSION`, read widely), a vendor's token under any name, and the passwords and keys inside any variable that
4
+ * the detectors read there (a URL's, a connection string's: `DATABASE_URL`, `password=` in a DSN). Not what the
5
+ * decision stage keeps on purpose: a key public by design, or under a browser's prefix (`NEXT_PUBLIC_STRIPE_KEY=pk_live_…`).
6
+ * Only values a Redactor accepts as known values (the rule `addKnown` applies) are returned; a short or plain-word value
7
+ * is left out. `new Redactor({ known: fromEnvironment(process.env) })`.
6
8
  */
7
9
  export declare function fromEnvironment(env: Record<string, string | undefined>): {
8
10
  value: string;
@@ -1,21 +1,38 @@
1
1
  import { findFormats } from "./formats.js";
2
2
  import { isReplaceable } from "./known.js";
3
3
  import { hasCredentialWord, isCredentialName, isSettingName, namesPasswordOrPin } from "./names.js";
4
- import { isLiteralPassword } from "./structure.js";
5
- import { urls } from "./urls.js";
4
+ import { findNamed } from "./named.js";
5
+ import { keepReason } from "./policy.js";
6
+ import { findStructure, isLiteralPassword } from "./structure.js";
6
7
  import { naturalness } from "./charlm.js";
7
8
  import { hasThreeClasses, isInert, isRandomToken } from "./values.js";
8
9
  /**
9
10
  * The secrets in an environment, as known values: variables whose name has a credential word (`*_API_KEY`, `*_TOKEN`,
10
- * `BW_SESSION`, read widely), a vendor's token under any name, and URL passwords inside any variable. Only values a
11
- * Redactor accepts as known values (the rule `addKnown` applies) are returned; a short or plain-word value is left out.
12
- * `new Redactor({ known: fromEnvironment(process.env) })`.
11
+ * `BW_SESSION`, read widely), a vendor's token under any name, and the passwords and keys inside any variable that
12
+ * the detectors read there (a URL's, a connection string's: `DATABASE_URL`, `password=` in a DSN). Not what the
13
+ * decision stage keeps on purpose: a key public by design, or under a browser's prefix (`NEXT_PUBLIC_STRIPE_KEY=pk_live_…`).
14
+ * Only values a Redactor accepts as known values (the rule `addKnown` applies) are returned; a short or plain-word value
15
+ * is left out. `new Redactor({ known: fromEnvironment(process.env) })`.
13
16
  */
14
17
  export function fromEnvironment(env) {
15
18
  const out = [];
19
+ const add = (value, label) => {
20
+ if (!out.some((o) => o.value === value))
21
+ out.push({ value, label });
22
+ };
16
23
  for (const [name, value] of Object.entries(env)) {
17
24
  if (!value || value.length < 6 || value.length > 16_384)
18
25
  continue;
26
+ // (Judged as the decision stage judges the same value on a `NAME=value` line, with the rule that read it.)
27
+ const line = `${name}=${value}`;
28
+ const kept = (f) => keepReason(line, {
29
+ ...f,
30
+ start: f.start + name.length + 1,
31
+ end: f.end + name.length + 1,
32
+ kind: "secret",
33
+ name,
34
+ confidence: 1,
35
+ }) !== undefined;
19
36
  // (Read widely: the caller's own environment, where a credential missed goes to a provider. A credential word anywhere
20
37
  // in the name or a session's at its end, `BW_SESSION`, `TF_TOKEN_app_terraform_io`, `ACTIONS_ID_TOKEN_REQUEST_TOKEN`,
21
38
  // unless the name ends as a setting's or a generator didn't make the value: `TOKEN_ENDPOINT`, `DESKTOP_SESSION=ubuntu-wayland`.
@@ -24,17 +41,24 @@ export function fromEnvironment(env) {
24
41
  ((namesPasswordOrPin(name) && !isInert(value)) ||
25
42
  ((hasCredentialWord(name) || /(?:^|_)(?:SESSION|PEPPER)$/i.test(name)) && isGenerated(value)));
26
43
  const secretName = isCredentialName(name) || /WEBHOOK/i.test(name) || widened;
27
- if (secretName && !isInert(value))
28
- out.push({ value, label: `$${name}` });
29
- // (A vendor's token under any name: `CI_DEPLOY=ghp_…`, a Slack webhook URL.)
44
+ if (secretName && !isInert(value) && !kept({ start: 0, end: value.length, rule: "named:assign" }))
45
+ add(value, `$${name}`);
46
+ // (A vendor's token under any name, `CI_DEPLOY=ghp_…`, a Slack webhook URL; one public by design is kept, a
47
+ // publishable key or a browser's Google key.)
30
48
  else
31
49
  for (const f of findFormats(value))
32
- if (!out.some((o) => o.value === value.slice(f.start, f.end)))
33
- out.push({ value: value.slice(f.start, f.end), label: `${f.kind} in $${name}` });
34
- // (A literal password, as the URL readers take one: not `${DB_PASSWORD}`, `<password>`, `%PASSWORD%`.)
35
- for (const url of value.includes("://") ? urls(value) : [])
36
- if (url.password && isLiteralPassword(url.password.value))
37
- out.push({ value: decodeSafe(url.password.value), label: `password in $${name}` });
50
+ if (!kept(f))
51
+ add(value.slice(f.start, f.end), `${f.kind} in $${name}`);
52
+ // (What the structure and name readers find inside the value: a URL's password, a connection string's, a DSN's
53
+ // `password=`. A literal one, as the URL readers take it: not `${DB_PASSWORD}`, `<password>`, `%PASSWORD%`.)
54
+ if (!/[=:]/.test(value))
55
+ continue;
56
+ for (const f of [...findStructure(value), ...findNamed(value)]) {
57
+ const secret = value.slice(f.start, f.end);
58
+ if (f.confidence < 0.9 || !isLiteralPassword(secret) || kept(f))
59
+ continue;
60
+ add(/^structure:url/.test(f.rule) ? decodeSafe(secret) : secret, `${f.kind} in $${name}`);
61
+ }
38
62
  }
39
63
  // (Only what a Redactor takes as a known value, so the caller isn't told a `JWT_SECRET=devsecret` is protected when it isn't.)
40
64
  return out.filter((o) => isReplaceable(o.value.trim()));
package/dist/formats.js CHANGED
@@ -163,6 +163,9 @@ export const CORE_FORMATS = [
163
163
  /\b(LLM(?:\|\d{15,16}\||_\d{15,16}_)[A-Za-z0-9_-]{20,255})(?![A-Za-z0-9_-])/,
164
164
  ["llm|", "llm_"],
165
165
  ],
166
+ // Baseten's keys since October 2026: `b10_`, the key's visible ID, a dot and the secret. (Older keys have no prefix; the
167
+ // random layer hides their secret half.)
168
+ ["Baseten API key", /\b(b10_[A-Za-z0-9]{8}\.[A-Za-z0-9]{32})\b/, ["b10_"]],
166
169
  ["Tavily API key", /\b(tvly-(?:dev-|prod-)?[A-Za-z0-9]{24,48})\b/, ["tvly-"]],
167
170
  ["Fireworks API key", /\b(fw_[A-Za-z0-9]{24,48})\b/, ["fw_"]],
168
171
  ["NVIDIA API key", /\b(nvapi-[A-Za-z0-9_-]{60,72})/, ["nvapi-"]],
@@ -249,6 +252,18 @@ export const CORE_FORMATS = [
249
252
  ["aeaaqwvhqxbps2v5"],
250
253
  ],
251
254
  ["Amazon Bedrock API key", /\b(bedrock-api-key-[A-Za-z0-9+/_-]{40,2000}={0,2})/, ["bedrock-api-key-"]],
255
+ // The long-term key: `ABSK` and the base64 of `BedrockAPIKey-<id>-at-<account>:<secret>`.
256
+ ["Amazon Bedrock API key (long-term)", /\b(ABSKQmVkcm9ja0FQSUtleS[A-Za-z0-9+/]{40,2000}={0,2})/, ["absk"]],
257
+ // Cloudflare Tunnel's token: the base64 of `{"a":<account>,"t":<tunnel>,"s":<secret>}`.
258
+ [
259
+ "Cloudflare Tunnel token",
260
+ /(?<![A-Za-z0-9+/])(eyJhIjoi[A-Za-z0-9+/]{90,600}={0,2})(?![A-Za-z0-9+/=])/,
261
+ ["eyjhijoi"],
262
+ ],
263
+ ["Inngest signing key", /\b(signkey-(?:prod|test|branch)-[0-9a-f]{64})\b/, ["signkey-"]],
264
+ ["Firecrawl API key", /(?<![\w-])(fc-[0-9a-f]{32})\b/, ["fc-"]],
265
+ ["Context7 API key", /\b(ctx7sk-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/, ["ctx7sk-"]],
266
+ ["Axiom token", /\b(xa[ap]t-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/, ["xaat-", "xapt-"]],
252
267
  [
253
268
  "Heroku API key",
254
269
  /\b(HRKU-(?:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}|AA[0-9A-Za-z_-]{58}))\b/,
package/dist/index.js CHANGED
@@ -226,7 +226,7 @@ export class Redactor {
226
226
  ...f,
227
227
  start: n.map[f.start],
228
228
  end: f.end > f.start ? n.ends[f.end - 1] : n.map[f.start],
229
- value: f.value ?? n.text.slice(f.start, f.end),
229
+ value: secretValue(n.text, f),
230
230
  }));
231
231
  // An escaped value in markup (`<password>Vq7m&lt;Zp2</password>`, `value="a&quot;b"`): decoded, its `<` or `"`
232
232
  // ends what the XML and attribute readers read, so the names are also read where it's written. (Escaped code in
@@ -282,8 +282,14 @@ export class Redactor {
282
282
  !(f.name !== undefined && f.start === l.map[0] && !l.decoded.includes("\n")));
283
283
  };
284
284
  const decoded = merge(extra);
285
+ // (Where both passes read a value from the same place, the decoded one says where it ends: read raw, Go's
286
+ // `client_secret=…\u0026grant_type=…` runs on through the escaped `&`.)
287
+ const decodedStarts = new Set(extra.map((f) => f.start));
285
288
  return merge([
286
- ...found.filter((f) => isVouched(f.rule) || layerOf(f.rule) === "repeat" || !insideLiteral(f) || overlaps(decoded, f.start, f.end)),
289
+ ...found.filter((f) => isVouched(f.rule) ||
290
+ layerOf(f.rule) === "repeat" ||
291
+ !insideLiteral(f) ||
292
+ (overlaps(decoded, f.start, f.end) && !decodedStarts.has(f.start))),
287
293
  ...extra,
288
294
  ]);
289
295
  }
@@ -297,7 +303,7 @@ export class Redactor {
297
303
  const out = [];
298
304
  for (const { decoded, map } of decodings) {
299
305
  for (const f of this.detectNormalized(decoded, { ...options, hint: {} }, depth + 1))
300
- out.push({ ...f, start: map[f.start], end: map[f.end], value: f.value ?? decoded.slice(f.start, f.end) });
306
+ out.push({ ...f, start: map[f.start], end: map[f.end], value: secretValue(decoded, f) });
301
307
  }
302
308
  return out;
303
309
  }
@@ -376,7 +382,7 @@ export class Redactor {
376
382
  : this.detectNormalized(decoded, { guessing: "off", encoded: encoded + 1 }, 0).some((f) => !isGuess(f.rule));
377
383
  found.push(...findEncoded(text, inner), ...findHexDumps(text, inner));
378
384
  }
379
- found = merge(wrapped(text, found));
385
+ found = merge(wrapped(text, found), text);
380
386
  if (on.has("random")) {
381
387
  // (Guessing as asked, but in a secret file, the hint's or one written inside the text, always: there the model
382
388
  // isn't guessing whether secrets are present, it tells the file's generated values from its settings.)
package/dist/json.js CHANGED
@@ -8,7 +8,16 @@
8
8
  * (transcript.ts): the shell command a tool runs, the path it reads or writes, and the text it writes there.
9
9
  */
10
10
  export const TOOL_COMMAND_KEYS = ["command", "cmd", "script", "shell_command", "run"];
11
- export const TOOL_PATH_KEYS = ["file_path", "path", "filePath", "filename", "file", "target_file", "notebook_path"];
11
+ export const TOOL_PATH_KEYS = [
12
+ "file_path",
13
+ "path",
14
+ "filePath",
15
+ "absolute_path",
16
+ "filename",
17
+ "file",
18
+ "target_file",
19
+ "notebook_path",
20
+ ];
12
21
  export const TOOL_CONTENT_KEYS = [
13
22
  "content",
14
23
  "contents",
@@ -27,13 +36,19 @@ export const TOOL_CONTENT_KEYS = [
27
36
  "code",
28
37
  "edits",
29
38
  ];
39
+ /**
40
+ * A key that names a command line by its last word, in any case and joining (`CommandLine`, Windsurf's run_command
41
+ * input; `commandLine`, `bash_command`, `COMMAND_LINE`, `shellCmd`; vercel.json's `buildCommand`), or one of the tool
42
+ * keys.
43
+ */
44
+ const COMMAND_KEY = String.raw `(?:(?:[A-Za-z\d]*[a-z\d](?=[A-Z])|[\w-]*[_-])?(?:command|Command|COMMAND|cmd|Cmd|CMD)(?:[_-]?(?:line|Line|LINE))?|${TOOL_COMMAND_KEYS.join("|")})`;
30
45
  /** The keys tools take text under: an editor's `new_string`, a writer's `content`, a shell's `command`, a tool's `input`. */
31
- const TOOL_TEXT_KEY = String.raw `(?:${[...TOOL_CONTENT_KEYS, ...TOOL_COMMAND_KEYS, "input"].join("|")})`;
46
+ const TOOL_TEXT_KEY = String.raw `(?:${[...TOOL_CONTENT_KEYS, COMMAND_KEY, "input"].join("|")})`;
32
47
  const TOOL_TEXT_ARGUMENT = new RegExp(String.raw `"${TOOL_TEXT_KEY}"[ \t]*:[ \t]*"`);
33
48
  /** A literal's opening quote right after a tool's text key (`"new_string": "`, escaped any number of times). */
34
49
  const AFTER_TOOL_TEXT_KEY = new RegExp(String.raw `(?<![\w$-])${TOOL_TEXT_KEY}\\*"[ \t]*:[ \t]*$`);
35
50
  /** A literal's opening quote right after a shell command's key (`"command": "`, `"cmd": "`): a command line, read as one. */
36
- const AFTER_COMMAND_KEY = new RegExp(String.raw `(?<![\w$-])(?:${TOOL_COMMAND_KEYS.join("|")})\\*"[ \t]*:[ \t]*$`);
51
+ const AFTER_COMMAND_KEY = new RegExp(String.raw `(?<![\w$-])${COMMAND_KEY}\\*"[ \t]*:[ \t]*$`);
37
52
  /**
38
53
  * Whether a text may hold JSON string literals worth decoding: escapes a document's literals carry (a line break,
39
54
  * a tab, a quote, a slash, a Unicode escape), or a tool's text argument, decoded even without escapes (a one-line
@@ -173,6 +188,11 @@ export function jsonStringLiterals(text) {
173
188
  decoded.length >= 20 &&
174
189
  (isOutputText(decoded) ||
175
190
  decoded.includes('"') ||
191
+ // (A character escaped as a code or a doubled backslash, Go's `\u0026` for `&`, Python's `\u00e9`, a path's
192
+ // `\\`: read raw, `-pQm7v\u0026Tx2…` or `postgres://app:Qm7v\u0026Tx2…@db` isn't the value the program saw.
193
+ // Only where JSON's syntax stands around it, as for the slash: a shell's `"s/\\(token=\\).*/\\1…/"` is no JSON.)
194
+ (/\\(?:u[0-9a-fA-F]{4}|\\)/.test(text.slice(map[0], map[map.length - 1])) &&
195
+ isJsonPlaced(text, open, map[map.length - 1])) ||
176
196
  // (An escaped slash, PHP's `"https:\/\/…"`, only where JSON's syntax stands around the literal: in a sed
177
197
  // script in a shell's string, `"s/^DB_PASSWORD=.*/DB_PASSWORD=a\/b/"`, it escapes the script's delimiter.)
178
198
  (decoded.includes("/") &&
@@ -338,8 +358,12 @@ export function* jsonDocuments(text) {
338
358
  yield doc.node;
339
359
  }
340
360
  }
341
- /** The `{…}` object around [start, end), braces balanced (braces inside its strings too), within `max` characters on each side; "" if none. */
342
- function enclosingObject(text, start, end, max = 2000) {
361
+ /**
362
+ * The `{…}` object around [start, end), braces balanced (braces inside its strings too), within `max` characters on
363
+ * each side; "" if none. (Enough for an RSA-8192 private JWK, about 6,500 characters, whose last members sit far from
364
+ * its `{`.)
365
+ */
366
+ function enclosingObject(text, start, end, max = 8000) {
343
367
  let depth = 0;
344
368
  let open = -1;
345
369
  for (let i = start - 1; i >= Math.max(0, start - max); i--) {
package/dist/known.d.ts CHANGED
@@ -7,10 +7,9 @@ export declare class KnownValues {
7
7
  private shortB64;
8
8
  /** Every short form in the order added; `word` if it's matched as a whole word (`short`), else anywhere (`shortB64`). */
9
9
  private shortForms;
10
- /** The short forms indexed: all up to `mainCount` in one index, the rest in a small one (see rebuild). */
11
- private main?;
12
- private mainCount;
13
- private recent?;
10
+ /** The short forms indexed, in indexes of decreasing size (see rebuild), and how many they hold. */
11
+ private levels;
12
+ private indexed;
14
13
  private labels;
15
14
  /** The value each form was made from: a form found is the value written another way, and tagged as the value. */
16
15
  private origins;
@@ -23,9 +22,10 @@ export declare class KnownValues {
23
22
  /** Adds a secret value. Values too short or too ordinary to replace everywhere are ignored. (One already held keeps its first label.) */
24
23
  add(value: string, label?: string): boolean;
25
24
  /**
26
- * Indexes the short forms added since the last find. They go in a small index of their own, so learning a value and
27
- * then finding (a session that learns as it goes) costs that small index, not all of them; once it holds more than
28
- * RECENT_FORMS, every form is indexed in one again.
25
+ * Indexes the short forms added since the last find. They go in an index of their own, merged with the indexes
26
+ * no bigger than it, so each holds at least twice what the next one holds: a session that learns a value and then
27
+ * finds, over and over, indexes each form a logarithmic number of times, not every form at every few values, and
28
+ * a find reads a logarithmic number of indexes.
29
29
  */
30
30
  private rebuild;
31
31
  find(text: string): Finding[];
package/dist/known.js CHANGED
@@ -52,13 +52,25 @@ function variants(value) {
52
52
  out.add(json.replace(/\//g, "\\/"));
53
53
  out.add(json.replace(/[&<>']/g, (c) => `\\u00${c.charCodeAt(0).toString(16).padStart(2, "0")}`));
54
54
  out.add(JSON.stringify(json).slice(1, -1)); // twice-escaped: JSON inside a JSON string
55
+ // Every character past ASCII as a code, as Python's json.dumps and PHP's json_encode (with `\/`) write it, and .NET's
56
+ // in capitals.
57
+ const ascii = json.replace(/[^\x00-\x7f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
58
+ out.add(ascii);
59
+ out.add(ascii.replace(/\//g, "\\/"));
60
+ out.add(ascii.replace(/\\u[0-9a-f]{4}/g, (m) => `\\u${m.slice(2).toUpperCase()}`));
55
61
  let uri = value;
56
62
  try {
57
63
  uri = encodeURIComponent(value);
58
64
  }
59
65
  catch { } // lone surrogates
60
66
  out.add(uri);
61
- out.add(uri.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`).replace(/%20/g, "+"));
67
+ // RFC 3986's unreserved characters only (Go's QueryEscape, Python's quote_plus with `+`, quote, which also leaves `/`),
68
+ // and a form body's (URLSearchParams, Java's URLEncoder: `*` left, `~` escaped, a space as `+`).
69
+ const strict = uri.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
70
+ out.add(strict);
71
+ out.add(strict.replace(/%20/g, "+"));
72
+ out.add(strict.replace(/%2F/g, "/"));
73
+ out.add(strict.replace(/%2A/g, "*").replace(/~/g, "%7E").replace(/%20/g, "+"));
62
74
  out.add(uri.toLowerCase() === uri ? uri : uri.replace(/%[0-9A-F]{2}/g, (m) => m.toLowerCase()));
63
75
  out.add(value.replace(/([^\w\-.,:/@+=%])/g, "\\$1")); // printf %q
64
76
  out.add(value.replace(/(["\\$`])/g, "\\$1")); // inside double quotes
@@ -178,8 +190,6 @@ class LiteralIndex {
178
190
  }
179
191
  }
180
192
  const TOKEN_CHAR = /[A-Za-z0-9+/_-]/;
181
- /** How many short forms are indexed apart from the rest before all are indexed in one again. */
182
- const RECENT_FORMS = 256;
183
193
  export class KnownValues {
184
194
  long = new LiteralIndex();
185
195
  ends = new LiteralIndex();
@@ -188,10 +198,9 @@ export class KnownValues {
188
198
  shortB64 = new Map();
189
199
  /** Every short form in the order added; `word` if it's matched as a whole word (`short`), else anywhere (`shortB64`). */
190
200
  shortForms = [];
191
- /** The short forms indexed: all up to `mainCount` in one index, the rest in a small one (see rebuild). */
192
- main;
193
- mainCount = 0;
194
- recent;
201
+ /** The short forms indexed, in indexes of decreasing size (see rebuild), and how many they hold. */
202
+ levels = [];
203
+ indexed = 0;
195
204
  labels = new Map();
196
205
  /** The value each form was made from: a form found is the value written another way, and tagged as the value. */
197
206
  origins = new Map();
@@ -248,25 +257,22 @@ export class KnownValues {
248
257
  return true;
249
258
  }
250
259
  /**
251
- * Indexes the short forms added since the last find. They go in a small index of their own, so learning a value and
252
- * then finding (a session that learns as it goes) costs that small index, not all of them; once it holds more than
253
- * RECENT_FORMS, every form is indexed in one again.
260
+ * Indexes the short forms added since the last find. They go in an index of their own, merged with the indexes
261
+ * no bigger than it, so each holds at least twice what the next one holds: a session that learns a value and then
262
+ * finds, over and over, indexes each form a logarithmic number of times, not every form at every few values, and
263
+ * a find reads a logarithmic number of indexes.
254
264
  */
255
265
  rebuild() {
256
- const indexed = this.mainCount + (this.recent?.forms.length ?? 0);
257
- if (indexed === this.shortForms.length)
266
+ if (this.indexed === this.shortForms.length)
258
267
  return;
259
- const index = (forms) => ({
268
+ let forms = this.shortForms.slice(this.indexed);
269
+ while (this.levels.length && this.levels[this.levels.length - 1].forms.length <= forms.length)
270
+ forms = [...this.levels.pop().forms, ...forms];
271
+ this.levels.push({
260
272
  set: new KeywordSet(forms.map((f) => f.form), true),
261
273
  forms,
262
274
  });
263
- if (this.shortForms.length - this.mainCount > RECENT_FORMS) {
264
- this.main = index(this.shortForms.slice());
265
- this.mainCount = this.shortForms.length;
266
- this.recent = undefined;
267
- }
268
- else
269
- this.recent = index(this.shortForms.slice(this.mainCount));
275
+ this.indexed = this.shortForms.length;
270
276
  }
271
277
  find(text) {
272
278
  this.rebuild();
@@ -281,9 +287,7 @@ export class KnownValues {
281
287
  value: this.origins.get(v),
282
288
  });
283
289
  // Short values whole only (or straight after a flag like `-p`); short base64 pieces anywhere.
284
- for (const index of [this.main, this.recent]) {
285
- if (!index)
286
- continue;
290
+ for (const index of this.levels) {
287
291
  for (const [at, k] of index.set.matches(text)) {
288
292
  const { form: v, word } = index.forms[k];
289
293
  const dash = v.includes("-");
@@ -312,11 +316,15 @@ export class KnownValues {
312
316
  fullEnd = Math.max(fullEnd, full[fi++].end);
313
317
  if (fullEnd >= at + W || at < lastEnd)
314
318
  continue;
319
+ // (Over the characters the value has: a token with no `/` cut short after a path, `…/repos/o/r/ghp_Zq8v…`,
320
+ // takes none of the path with it.)
321
+ const value = this.origins.get(v) ?? "";
322
+ const inValue = (c) => TOKEN_CHAR.test(c) && (/[A-Za-z0-9]/.test(c) || value.includes(c));
315
323
  let start = at;
316
324
  let end = at + W;
317
- while (start > 0 && TOKEN_CHAR.test(text[start - 1]))
325
+ while (start > 0 && inValue(text[start - 1]))
318
326
  start--;
319
- while (end < text.length && TOKEN_CHAR.test(text[end]))
327
+ while (end < text.length && inValue(text[end]))
320
328
  end++;
321
329
  lastEnd = end;
322
330
  out.push({
package/dist/named.d.ts CHANGED
@@ -12,6 +12,12 @@ export declare function isPairLabel(name: string, label: string): boolean;
12
12
  export declare function isNamedSecret(name: string, raw: string, literal?: boolean): boolean;
13
13
  /** Values under a credential name. (Values public by design, client-side Google keys and DocSearch keys among them, are policy.ts's.) */
14
14
  export declare function findNamed(text: string): Finding[];
15
+ /**
16
+ * The key of the object or mapping the entry at `at` sits in: before the nearest `{` it is inside (`mixpanel: {`,
17
+ * `"mixpanel": {`), or a YAML line indented less with nothing after its key (`mixpanel:`). Read within 2,000
18
+ * characters; undefined if none.
19
+ */
20
+ export declare function enclosingKey(text: string, at: number): string | undefined;
15
21
  /**
16
22
  * A word a sentence has and a passphrase doesn't: a function word, or a verb's or an adverb's form (`hashed`, `using`,
17
23
  * `effectively`) that isn't a word of BIP39's list, which passphrases are drawn from (`jelly`, `bring`, `speed`).