redac---n 0.0.0-stage → 0.0.2

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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -2
  3. package/THIRD_PARTY_NOTICES.md +59 -0
  4. package/dist/aho.d.ts +25 -0
  5. package/dist/aho.js +116 -0
  6. package/dist/base64.d.ts +10 -0
  7. package/dist/base64.js +29 -0
  8. package/dist/bip39-data.d.ts +2 -0
  9. package/dist/bip39-data.js +2 -0
  10. package/dist/cache.d.ts +14 -0
  11. package/dist/cache.js +40 -0
  12. package/dist/calls.d.ts +64 -0
  13. package/dist/calls.js +269 -0
  14. package/dist/charlm-data.d.ts +2 -0
  15. package/dist/charlm-data.js +3 -0
  16. package/dist/charlm.d.ts +7 -0
  17. package/dist/charlm.js +65 -0
  18. package/dist/checksums.d.ts +10 -0
  19. package/dist/checksums.js +98 -0
  20. package/dist/commands.d.ts +72 -0
  21. package/dist/commands.js +787 -0
  22. package/dist/context.d.ts +98 -0
  23. package/dist/context.js +469 -0
  24. package/dist/encoded.d.ts +11 -0
  25. package/dist/encoded.js +336 -0
  26. package/dist/environment.d.ts +10 -0
  27. package/dist/environment.js +62 -0
  28. package/dist/formats-data.d.ts +2 -0
  29. package/dist/formats-data.js +405 -0
  30. package/dist/formats.d.ts +5 -0
  31. package/dist/formats.js +470 -0
  32. package/dist/hmac.d.ts +3 -0
  33. package/dist/hmac.js +87 -0
  34. package/dist/index.d.ts +101 -0
  35. package/dist/index.js +488 -0
  36. package/dist/json.d.ts +82 -0
  37. package/dist/json.js +392 -0
  38. package/dist/known.d.ts +34 -0
  39. package/dist/known.js +340 -0
  40. package/dist/lines.d.ts +28 -0
  41. package/dist/lines.js +69 -0
  42. package/dist/lookup.d.ts +5 -0
  43. package/dist/lookup.js +7 -0
  44. package/dist/model-data.d.ts +3 -0
  45. package/dist/model-data.js +4 -0
  46. package/dist/model.d.ts +2 -0
  47. package/dist/model.js +19 -0
  48. package/dist/named.d.ts +20 -0
  49. package/dist/named.js +3257 -0
  50. package/dist/names.d.ts +106 -0
  51. package/dist/names.js +1517 -0
  52. package/dist/normalize.d.ts +25 -0
  53. package/dist/normalize.js +529 -0
  54. package/dist/pem.d.ts +32 -0
  55. package/dist/pem.js +269 -0
  56. package/dist/policy.d.ts +3 -0
  57. package/dist/policy.js +470 -0
  58. package/dist/public.d.ts +44 -0
  59. package/dist/public.js +362 -0
  60. package/dist/publickeys.d.ts +41 -0
  61. package/dist/publickeys.js +232 -0
  62. package/dist/random.d.ts +29 -0
  63. package/dist/random.js +165 -0
  64. package/dist/repeats.d.ts +21 -0
  65. package/dist/repeats.js +80 -0
  66. package/dist/rules.d.ts +42 -0
  67. package/dist/rules.js +58 -0
  68. package/dist/runs.d.ts +10 -0
  69. package/dist/runs.js +36 -0
  70. package/dist/secretfile.d.ts +52 -0
  71. package/dist/secretfile.js +722 -0
  72. package/dist/shell.d.ts +127 -0
  73. package/dist/shell.js +703 -0
  74. package/dist/spans.d.ts +61 -0
  75. package/dist/spans.js +287 -0
  76. package/dist/sql.d.ts +20 -0
  77. package/dist/sql.js +66 -0
  78. package/dist/stream.d.ts +46 -0
  79. package/dist/stream.js +150 -0
  80. package/dist/structure.d.ts +24 -0
  81. package/dist/structure.js +1625 -0
  82. package/dist/syntax.d.ts +156 -0
  83. package/dist/syntax.js +446 -0
  84. package/dist/transcript.d.ts +30 -0
  85. package/dist/transcript.js +713 -0
  86. package/dist/types.d.ts +81 -0
  87. package/dist/types.js +1 -0
  88. package/dist/unnamed.d.ts +59 -0
  89. package/dist/unnamed.js +939 -0
  90. package/dist/urls.d.ts +70 -0
  91. package/dist/urls.js +303 -0
  92. package/dist/values.d.ts +178 -0
  93. package/dist/values.js +1621 -0
  94. package/dist/yaml.d.ts +40 -0
  95. package/dist/yaml.js +174 -0
  96. package/docs/creddata.svg +318 -0
  97. package/docs/guessing.md +139 -0
  98. package/docs/holdout.svg +85 -0
  99. package/docs/layers.md +80 -0
  100. package/docs/models.svg +81 -0
  101. package/docs/real-text.svg +112 -0
  102. package/package.json +57 -5
@@ -0,0 +1,101 @@
1
+ import type { DetectOptions, Finding, Hint, RedactOptions } from "./types.ts";
2
+ import { type TranscriptFinding, type TranscriptOptions } from "./transcript.ts";
3
+ export { isSecretFile } from "./commands.ts";
4
+ export { fromEnvironment } from "./environment.ts";
5
+ export type { DetectOptions, Finding, Guessing, Hint, RedactOptions } from "./types.ts";
6
+ export type { TranscriptFinding, TranscriptOptions } from "./transcript.ts";
7
+ export interface RedactResult {
8
+ /** The text with every secret replaced by a placeholder. */
9
+ text: string;
10
+ /**
11
+ * What was replaced; each `value` is the secret as the program reads it (decoded where the text escaped it, as
12
+ * `detect` gives it, else the text its span covered): strip it before logging.
13
+ */
14
+ findings: (Finding & {
15
+ value: string;
16
+ })[];
17
+ }
18
+ export interface RedactorOptions extends RedactOptions {
19
+ /**
20
+ * The HMAC key of placeholders' tags. Random per Redactor by default, so tags differ between Redactors and across
21
+ * restarts; pass a fixed key to keep them stable (a provider's prompt cache). Keep it secret: with it, a tag can be
22
+ * checked against guesses.
23
+ */
24
+ fingerprintKey?: string | Uint8Array;
25
+ }
26
+ export declare class Redactor {
27
+ private cache;
28
+ private guessing;
29
+ private hashes;
30
+ private key;
31
+ private placeholder?;
32
+ private layers;
33
+ private without;
34
+ constructor(options?: RedactorOptions);
35
+ /**
36
+ * Adds a value that is secret wherever it appears, shown as `label` in its placeholders. False if it's too short or
37
+ * ordinary to hide everywhere (RedactOptions.known says which), true if it was added or was already held.
38
+ */
39
+ addKnown(value: string, label?: string): boolean;
40
+ /** A short keyed fingerprint, so the model can tell two placeholders hide the same value. */
41
+ fingerprint(value: string): string;
42
+ /** Every secret in `text`, without changing it. (What was found in a text before, with the same hint and guessing, under the same known values, is returned again.) */
43
+ detect(text: string, options?: DetectOptions): Finding[];
44
+ private detectUncached;
45
+ /**
46
+ * `text` with its placeholders read blank (so their kind words, `token`, `secret`, aren't context for what's next to
47
+ * them), and them added to `found`. Text of a placeholder's shape is one only if its kind holds nothing detection would
48
+ * hide: `[REDACTED sk_live_… #abcdef]` is a key in a placeholder's shape, and is scanned like any text.
49
+ */
50
+ private withoutPlaceholders;
51
+ private detectNormalized;
52
+ private detectText;
53
+ /**
54
+ * `found`, with what detection finds in each of a text's decoded string literals (in text order, apart), mapped
55
+ * back; inside a literal, the decoded pass decides.
56
+ */
57
+ private withLiterals;
58
+ /** `found`, with what detection finds in each decoded text, mapped back to the text it was decoded from. */
59
+ private withDecoded;
60
+ /** What detection finds in each decoded text, mapped back to the text it was decoded from. */
61
+ private decodedFindings;
62
+ private detectPlain;
63
+ /** The text that replaces a secret: `[REDACTED <kind> #<fingerprint>]` unless a placeholder function was given. */
64
+ placeholderFor(f: Finding, value: string): string;
65
+ /** `text` with every secret replaced by a placeholder. */
66
+ redact(text: string, options?: DetectOptions): RedactResult;
67
+ /**
68
+ * Learns the secrets in `text` as known values, so they're hidden wherever they turn up later, with
69
+ * or without context: read a `.env` once and its values are hidden in every later output. Returns how many it
70
+ * hadn't held. `minConfidence` (default 0.9) is the least confidence a finding needs to be learned.
71
+ */
72
+ learn(text: string, options?: DetectOptions & {
73
+ minConfidence?: number;
74
+ }): number;
75
+ /**
76
+ * A copy of any JSON value (a message list, a request body) with every secret in every string and key
77
+ * replaced, as `JSON.stringify` would send it. Tool results are judged with their tool call's command or path,
78
+ * and a secret found anywhere is hidden everywhere in it. Options: `learn` (default true: remember what it finds
79
+ * for later calls too), `thinking` (default "redact").
80
+ */
81
+ redactValue<T>(value: T, options?: TranscriptOptions): {
82
+ value: T;
83
+ findings: TranscriptFinding[];
84
+ };
85
+ /**
86
+ * A streaming redactor for text that arrives in chunks (a tool's output). Each `write` returns redacted text
87
+ * ready to pass on: text is released once it's far enough behind the newest chunk that no secret can still be
88
+ * forming across it, scanned in batches, so nothing comes out until about twice `holdback` characters have
89
+ * arrived (default 32,768, at least 8,192). `end()` flushes the rest.
90
+ */
91
+ stream(options?: DetectOptions & {
92
+ holdback?: number;
93
+ }): {
94
+ write(chunk: string): string;
95
+ end(): string;
96
+ };
97
+ }
98
+ /** Redacts with a one-off Redactor. Prefer a long-lived Redactor so known values and fingerprints carry over. */
99
+ export declare function redact(text: string, options?: RedactorOptions & {
100
+ hint?: Hint;
101
+ }): RedactResult;
package/dist/index.js ADDED
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Redacts secrets from text before it goes somewhere you don't trust, such as a model provider.
3
+ *
4
+ * import { Redactor } from "redac---n";
5
+ * const r = new Redactor({ known: [process.env.STRIPE_KEY] });
6
+ * r.redact(toolOutput, { hint: { command: "cat .env" } }).text
7
+ *
8
+ * Known values (secrets the caller holds, in every encoding output turns them into: known.ts) are found first; then
9
+ * the layers of LAYERS propose spans, and the decision stage (policy.ts) decides which stay:
10
+ * - formats: vendor token formats (formats.ts);
11
+ * - structure: private keys, URL passwords, Authorization headers, cookies, connection strings, CLI passwords (structure.ts);
12
+ * - names: values under credential names, `password=…`, `"apiKey": "…"`, `--token …` (named.ts);
13
+ * - context: where the text came from, a secret file or a command that prints one, and the secret files inside it, a
14
+ * pasted session's output, a heredoc or a diff that writes one (commands.ts, secretfile.ts);
15
+ * - encoded: base64 blobs and hex dumps, hidden whole if what they decode to holds a secret (encoded.ts);
16
+ * - random: tokens with no name or format, judged by randomness and context (unnamed.ts).
17
+ * Text is normalized first (normalize.ts); repeats of what was found are hidden too (repeats.ts).
18
+ */
19
+ import { isSecretFile, printsSecret, printsSecretFile, printsSecretFileNames } from "./commands.js";
20
+ import { findEncoded, findHexDumps } from "./encoded.js";
21
+ import { jsonLiteralSpans, jsonStringLiterals, mayHoldJsonText, pythonReprLiterals } from "./json.js";
22
+ import { repeats } from "./repeats.js";
23
+ import { RedactionStream } from "./stream.js";
24
+ import { covered, isLearnable, loginPassword, toWordEnd, trimmedToValue, merge, overlaps, secretValue, wrapped, } from "./spans.js";
25
+ import { isGuess, isVouched, layerOf, needsJudging, PLACEHOLDER_RULE } from "./rules.js";
26
+ import { lowerBound } from "./lines.js";
27
+ import { findEmbeddedOutputs, findInSecretFile, findPrintedSecret, firstPrompt, secretFileSections, } from "./secretfile.js";
28
+ import { hmacSha256, randomKey } from "./hmac.js";
29
+ import { needsNormalizing, normalize, withBlanks } from "./normalize.js";
30
+ import { findFormats } from "./formats.js";
31
+ import { KnownValues } from "./known.js";
32
+ import { findNamed } from "./named.js";
33
+ import { findStructure } from "./structure.js";
34
+ import { findUnnamed } from "./unnamed.js";
35
+ import { redactValue } from "./transcript.js";
36
+ import { keepReason } from "./policy.js";
37
+ import { percentEncoded } from "./urls.js";
38
+ import { FindingsCache } from "./cache.js";
39
+ export { isSecretFile } from "./commands.js";
40
+ export { fromEnvironment } from "./environment.js";
41
+ /**
42
+ * Findings kept off the quotes of a JSON text's string literals, so the JSON around them stays valid: one that runs
43
+ * from inside a literal out past its closing quote (a password read through the escaping, `Indigo2027$"}`) ends
44
+ * with the literal, and one that runs into a literal from outside starts with it. One that spans whole literals (a
45
+ * key written across `"…\n" +` lines) is the code's, and stays.
46
+ */
47
+ function withinLiterals(text, found) {
48
+ const spans = jsonLiteralSpans(text);
49
+ const starts = spans.map(([start]) => start);
50
+ const literalAt = (p) => {
51
+ const span = spans[lowerBound(starts, p + 1) - 1];
52
+ return span && p < span[1] ? span : undefined;
53
+ };
54
+ return found.flatMap((f) => {
55
+ const first = literalAt(f.start);
56
+ const last = literalAt(f.end - 1);
57
+ if (!first === !last)
58
+ return [f];
59
+ const [start, end] = first ? [f.start, first[1]] : [last[0], f.end];
60
+ return end > start ? [{ ...f, start, end, value: text.slice(start, end) }] : [];
61
+ });
62
+ }
63
+ /** A placeholder as placeholderFor writes one: `[REDACTED <kind> #<fingerprint>]`, its kind's characters and length (group 1). */
64
+ const PLACEHOLDER = /\[REDACTED ([\w .:/-]{1,60}) #[0-9a-f]{6}\]/g;
65
+ /** An entity that spells markup, which decoding turns into the markup itself. */
66
+ const MARKUP_ENTITY = /&(?:lt|gt|quot|apos|amp|#0*(?:60|62|34|39|38)|#x0*(?:3c|3e|22|27|26));/i;
67
+ /**
68
+ * @internal The stages around the layers, each of which can be left out to measure it (eval/layers.ts): normalization (colours,
69
+ * line prefixes, escapes, entities), the JSON-literal pass (decoded string literals scanned as text), hints (a command
70
+ * or path saying what the text is), the decision stage (policy.ts), repeats of what was found, and in the random layer
71
+ * its hard vetoes and its context (each token then scored on its own characters).
72
+ */
73
+ export const STAGES = [
74
+ "normalize",
75
+ "json-literals",
76
+ "percent-encoded",
77
+ "hints",
78
+ "policy",
79
+ "repeats",
80
+ "vetoes",
81
+ "statistical-vetoes",
82
+ "token-context",
83
+ ];
84
+ /** @internal Detection layers, in the order they run (see the module comment); each can be left out to measure the rest (eval/layers.ts). */
85
+ export const LAYERS = ["formats", "structure", "names", "context", "encoded", "random"];
86
+ /**
87
+ * The JSON number token around `[start, end)` when it stands as a value (after a key's `:`, or a `[` or `,` inside
88
+ * brackets), with its sign, fraction and exponent; and the quote at the level of escaping around it (`"`, `\"` in
89
+ * JSON inside a JSON string).
90
+ */
91
+ function jsonNumberAround(text, start, end) {
92
+ let s = start;
93
+ while (s > 0 && start - s < 64 && /[-\d.]/.test(text[s - 1]))
94
+ s--;
95
+ if (s > 0 && /[-\d.]/.test(text[s - 1]))
96
+ return undefined; // (a run longer than any number token: not one)
97
+ const e = end + /^\d*(?:\.\d+)?(?:[eE][-+]?\d+)?/.exec(text.slice(end, end + 40))[0].length;
98
+ if (!/^-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?$/.test(text.slice(s, e)) ||
99
+ !/^(?:[ \t\r\n]|\\[nrt])*(?:[,}\]]|$)/.test(text.slice(e, e + 20)))
100
+ return undefined;
101
+ const before = text.slice(Math.max(0, s - 400), s);
102
+ const key = /(\\*)"[ \t]*:[ \t]*$/.exec(before);
103
+ if (key)
104
+ return { start: s, end: e, quote: `${key[1]}"` };
105
+ // (In an array: the escaping of the quote nearest before it, if any.)
106
+ if (!/[[,](?:[ \t\r\n]|\\[nrt])*$/.test(before) || !/[[{]/.test(before))
107
+ return undefined;
108
+ return { start: s, end: e, quote: `${/(\\*)"[^"]*$/.exec(before)?.[1] ?? ""}"` };
109
+ }
110
+ export class Redactor {
111
+ /** @internal The values this Redactor hides wherever they appear: given (`known`, `addKnown`) or learned. */
112
+ known = new KnownValues();
113
+ cache = new FindingsCache();
114
+ guessing;
115
+ hashes;
116
+ key;
117
+ placeholder;
118
+ layers;
119
+ without;
120
+ constructor(options = {}) {
121
+ this.layers = new Set(options.layers ?? LAYERS);
122
+ this.without = new Set(options.without ?? []);
123
+ this.guessing = options.guessing ?? "confident";
124
+ this.hashes = options.hashes ?? "keep";
125
+ this.key =
126
+ typeof options.fingerprintKey === "string"
127
+ ? new TextEncoder().encode(options.fingerprintKey)
128
+ : (options.fingerprintKey ?? randomKey());
129
+ this.placeholder = options.placeholder;
130
+ for (const k of options.known ?? []) {
131
+ if (typeof k === "string")
132
+ this.known.add(k);
133
+ else if (k)
134
+ this.known.add(k.value, k.label);
135
+ }
136
+ }
137
+ /**
138
+ * Adds a value that is secret wherever it appears, shown as `label` in its placeholders. False if it's too short or
139
+ * ordinary to hide everywhere (RedactOptions.known says which), true if it was added or was already held.
140
+ */
141
+ addKnown(value, label) {
142
+ return this.known.add(value, label);
143
+ }
144
+ /** A short keyed fingerprint, so the model can tell two placeholders hide the same value. */
145
+ fingerprint(value) {
146
+ const mac = hmacSha256(this.key, new TextEncoder().encode(value));
147
+ return Array.from(mac.subarray(0, 3), (b) => b.toString(16).padStart(2, "0")).join("");
148
+ }
149
+ /** Every secret in `text`, without changing it. (What was found in a text before, with the same hint and guessing, under the same known values, is returned again.) */
150
+ detect(text, options = {}) {
151
+ const context = JSON.stringify([
152
+ options.guessing ?? this.guessing,
153
+ options.hint?.command,
154
+ options.hint?.path,
155
+ options.hint?.secretFile,
156
+ ]);
157
+ const version = this.known.version;
158
+ // (Copies out: a caller that changes a finding changes neither the cache nor what the next call returns.)
159
+ const cached = this.cache.get(version, text, context);
160
+ if (cached)
161
+ return cached.map((f) => ({ ...f }));
162
+ const found = this.detectUncached(text, options);
163
+ this.cache.set(version, text, context, found);
164
+ return found.map((f) => ({ ...f }));
165
+ }
166
+ detectUncached(text, options) {
167
+ // Colour codes, NULs (UTF-16 read as UTF-8), line continuations, HTML entities: detect on the text
168
+ // without them and map positions back.
169
+ // Placeholders of an earlier pass are findings of their own, replaced by themselves, and what overlaps them is
170
+ // dropped: redacting redacted text changes nothing. (Read blank, so their kind words, `token`, `secret`, aren't
171
+ // context for what's next to them.)
172
+ const placeholders = [];
173
+ const scan = text.includes("[REDACTED ") ? this.withoutPlaceholders(text, placeholders) : text;
174
+ try {
175
+ // (Findings run to their token's end can come to overlap: merged again.)
176
+ const found = merge(this.detectNormalized(scan, options, 0)
177
+ .map((f) => toWordEnd(scan, trimmedToValue(scan, f)))
178
+ .filter((f) => f.end > f.start));
179
+ return placeholders.length
180
+ ? [...found.filter((f) => !overlaps(placeholders, f.start, f.end)), ...placeholders].sort((a, b) => a.start - b.start)
181
+ : found;
182
+ }
183
+ catch {
184
+ // A detector that fails on some input mustn't let that input through: it is hidden whole (closed, not open), as
185
+ // one finding that is never learned as a secret.
186
+ return text
187
+ ? [{ start: 0, end: text.length, kind: "unreadable text", rule: "safety:detector-failed", confidence: 0 }]
188
+ : [];
189
+ }
190
+ }
191
+ /**
192
+ * `text` with its placeholders read blank (so their kind words, `token`, `secret`, aren't context for what's next to
193
+ * them), and them added to `found`. Text of a placeholder's shape is one only if its kind holds nothing detection would
194
+ * hide: `[REDACTED sk_live_… #abcdef]` is a key in a placeholder's shape, and is scanned like any text.
195
+ */
196
+ withoutPlaceholders(text, found) {
197
+ const own = new Map();
198
+ return text.replace(PLACEHOLDER, (m, kind, at) => {
199
+ let ok = own.get(kind);
200
+ if (ok === undefined)
201
+ own.set(kind, (ok = this.detect(kind).length === 0));
202
+ if (!ok)
203
+ return m;
204
+ found.push({ start: at, end: at + m.length, kind: "placeholder", rule: "placeholder", confidence: 1 });
205
+ return `[REDACTED${" ".repeat(m.length - 10)}]`;
206
+ });
207
+ }
208
+ detectNormalized(text, options, depth) {
209
+ if (needsNormalizing(text) && !this.without.has("normalize")) {
210
+ const n = normalize(text);
211
+ let found = this.detectText(n.text, options, depth);
212
+ // A removed colour code or line continuation can glue a fixed-length key to the next character
213
+ // (`ESC[32mAKIA…ESC[0mdefault`): formats also run with a blank where each separator was. A key found there
214
+ // replaces a span that runs across the separator into the next word.
215
+ if (n.boundaries.length) {
216
+ const separated = withBlanks(n.text, n.boundaries);
217
+ const keys = findFormats(separated.text)
218
+ .map((f) => ({ ...f, start: separated.map[f.start], end: separated.map[f.end - 1] + 1 }))
219
+ .filter((f) => this.judge(n.text, f));
220
+ const across = (f) => n.boundaries.some((b) => b > f.start && b < f.end);
221
+ found = found.filter((f) => !(across(f) && layerOf(f.rule) !== "format" && keys.some((k) => k.start < f.end && k.end > f.start)));
222
+ found = merge([...found, ...keys.filter((k) => !covered(found, k.start, k.end))]);
223
+ }
224
+ // (Each keeps its value as read here, where a joined literal is whole: `"xoxb-12" "34ab"` is `xoxb-1234ab`.)
225
+ const mapped = found.map((f) => ({
226
+ ...f,
227
+ start: n.map[f.start],
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),
230
+ }));
231
+ // An escaped value in markup (`<password>Vq7m&lt;Zp2</password>`, `value="a&quot;b"`): decoded, its `<` or `"`
232
+ // ends what the XML and attribute readers read, so the names are also read where it's written. (Escaped code in
233
+ // markup, `<pre>DB_PASSWORD=&quot;…&quot;</pre>`, needs the decoding: both readings stand.)
234
+ if (!this.layers.has("names") || !MARKUP_ENTITY.test(text))
235
+ return mapped;
236
+ const escaped = findNamed(text).filter((f) => MARKUP_ENTITY.test(text.slice(f.start, f.end)) && this.judge(text, f));
237
+ if (!escaped.length)
238
+ return mapped;
239
+ const read = merge(mapped);
240
+ return merge([...mapped, ...escaped.filter((f) => !overlaps(read, f.start, f.end))]);
241
+ }
242
+ return this.detectText(text, options, depth);
243
+ }
244
+ detectText(text, options, depth) {
245
+ const plain = text.includes('"')
246
+ ? withinLiterals(text, this.detectPlain(text, options))
247
+ : this.detectPlain(text, options);
248
+ // A Python repr's strings (`{'stdout': '…\n…'}`) and percent-encoded text (a URL in `redirect=`, JSON in a query):
249
+ // each decoded, scanned, and mapped back.
250
+ const repr = depth >= 3 ? plain : this.withLiterals(plain, [...pythonReprLiterals(text)], options, depth);
251
+ const found = depth >= 3 || this.without.has("percent-encoded")
252
+ ? repr
253
+ : this.withDecoded(repr, percentEncoded(text), options, depth);
254
+ if (depth >= 3 || !mayHoldJsonText(text) || this.without.has("json-literals"))
255
+ return found;
256
+ // JSON-escaped text (a serialized request, a log of JSON, a tool result inside a transcript): every
257
+ // string literal holding escapes is decoded, scanned as the text it encodes, and mapped back.
258
+ return this.withLiterals(found, jsonStringLiterals(text), options, depth);
259
+ }
260
+ /**
261
+ * `found`, with what detection finds in each of a text's decoded string literals (in text order, apart), mapped
262
+ * back; inside a literal, the decoded pass decides.
263
+ */
264
+ withLiterals(found, literals, options, depth) {
265
+ if (!literals.length)
266
+ return found;
267
+ // (Decoded, a literal may hold colour codes of its own: `\u001b[32m` in a JSON log of terminal output.)
268
+ const extra = this.decodedFindings(literals, options, depth);
269
+ // Inside a decoded literal the decoded pass decides: the raw pass reads the literal through its escapes (`\"`
270
+ // before a value, `\n` for a line break) and can miss what makes a value public (a label on the line above, a
271
+ // quote that closed a string, a name the escapes hide). A raw finding inside one stays only where the decoded
272
+ // pass found something too. (Not secret-file or secret-command findings, nor known values: the hint that makes
273
+ // them is the raw text's; nor another occurrence of a value found elsewhere in it, which no one literal holds.)
274
+ // (Literals come in text order and don't overlap; the decoded findings, merged, neither.)
275
+ // (Nor a finding that is a one-line literal's whole value, read under a name outside it, `"client_secret":"p\/ye…"`:
276
+ // the decoded text has no name to judge it by.)
277
+ const starts = literals.map((l) => l.map[0]);
278
+ const insideLiteral = (f) => {
279
+ const l = literals[lowerBound(starts, f.start + 1) - 1];
280
+ return (!!l &&
281
+ f.end <= l.map[l.map.length - 1] &&
282
+ !(f.name !== undefined && f.start === l.map[0] && !l.decoded.includes("\n")));
283
+ };
284
+ const decoded = merge(extra);
285
+ return merge([
286
+ ...found.filter((f) => isVouched(f.rule) || layerOf(f.rule) === "repeat" || !insideLiteral(f) || overlaps(decoded, f.start, f.end)),
287
+ ...extra,
288
+ ]);
289
+ }
290
+ /** `found`, with what detection finds in each decoded text, mapped back to the text it was decoded from. */
291
+ withDecoded(found, decodings, options, depth) {
292
+ const extra = this.decodedFindings(decodings, options, depth);
293
+ return extra.length ? merge([...found, ...extra]) : found;
294
+ }
295
+ /** What detection finds in each decoded text, mapped back to the text it was decoded from. */
296
+ decodedFindings(decodings, options, depth) {
297
+ const out = [];
298
+ for (const { decoded, map } of decodings) {
299
+ 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) });
301
+ }
302
+ return out;
303
+ }
304
+ detectPlain(text, options) {
305
+ const guessing = options.guessing ?? this.guessing;
306
+ // (A command up to 64 KB, read in linear time, and a longer one's both ends: a script written by a heredoc then
307
+ // `&& cat .env` is one command. A long path's end, its file's name.)
308
+ const command = options.hint?.command;
309
+ const hint = this.without.has("hints")
310
+ ? {}
311
+ : {
312
+ command: command && command.length > 65_536 ? `${command.slice(0, 32_768)}\n${command.slice(-32_768)}` : command,
313
+ path: options.hint?.path?.slice(-4096),
314
+ secretFile: options.hint?.secretFile,
315
+ };
316
+ // (Read whole, a file's text: a command's output only up to the next prompt in it.)
317
+ const wholeFile = hint.secretFile === true || (hint.path !== undefined && isSecretFile(hint.path));
318
+ // (A command that cuts a secret file to its names printed one whole where a `NAME=value` line is in its output.)
319
+ const secretFile = wholeFile ||
320
+ (hint.command !== undefined &&
321
+ (printsSecretFile(hint.command) ||
322
+ (printsSecretFileNames(hint.command) && /^[ \t]*(?:export[ \t]+)?[A-Za-z_][\w.]*=\S/m.test(text))));
323
+ const on = this.layers;
324
+ // The layers propose; policy.ts decides, once for all of them, what is public by design or never a secret.
325
+ // Every proposal on a value is judged under the name any layer read for it (a format match on
326
+ // `const NEXT_PUBLIC_MAPS_KEY: string = "AIza…"` under the name the name layer parsed), and without the
327
+ // backslash that, in text encoded twice, is half of the next level's escape.
328
+ const proposals = [
329
+ ...this.known.find(text),
330
+ ...(on.has("formats") ? findFormats(text) : []),
331
+ ...(on.has("structure") ? findStructure(text) : []),
332
+ ...(on.has("names") ? findNamed(text) : []),
333
+ ];
334
+ const named = proposals.filter((p) => p.name).sort((a, b) => a.start - b.start);
335
+ // (The nearest few named proposals starting before the span's end: spans that overlap rarely nest deeper.)
336
+ const namedStarts = named.map((p) => p.start);
337
+ const nameAt = (f) => {
338
+ const after = lowerBound(namedStarts, f.end);
339
+ for (let i = after - 1; i >= Math.max(0, after - 8); i--)
340
+ if (named[i].end > f.start)
341
+ return named[i].name;
342
+ return undefined;
343
+ };
344
+ const decide = (fs) => fs.filter((f) => {
345
+ let end = f.end;
346
+ while (end > f.start + 1 && text[end - 1] === "\\")
347
+ end--;
348
+ const name = f.name ?? nameAt(f);
349
+ return this.judge(text, { ...f, end, ...(name ? { name } : {}) });
350
+ });
351
+ let found = decide(proposals);
352
+ // Secret files' contents inside the text (a diff's hunks, a heredoc that writes one): hidden as in the file.
353
+ const secretSections = on.has("context") ? secretFileSections(text) : [];
354
+ if (on.has("context")) {
355
+ // A command's output runs to the next prompt in the text; what follows is another command's.
356
+ const own = hint.command ? text.slice(0, firstPrompt(text)) : text;
357
+ if (hint.command && printsSecret(hint.command))
358
+ found.push(...findPrintedSecret(own));
359
+ if (secretFile)
360
+ found.push(...findInSecretFile(wholeFile ? text : own, { path: hint.path }));
361
+ found.push(...findEmbeddedOutputs(text));
362
+ for (const { start: from, end: to, part } of secretSections)
363
+ for (const f of findInSecretFile(text.slice(from, to), { part }))
364
+ found.push({ ...f, start: f.start + from, end: f.end + from });
365
+ // (A secret file's values under no credential's name are judged like any proposal: see isOpaqueSetting.)
366
+ found = [...found.filter((f) => !needsJudging(f.rule)), ...decide(found.filter((f) => needsJudging(f.rule)))];
367
+ }
368
+ if (on.has("encoded")) {
369
+ // (Decoded text holds a secret when the pipeline finds one there, as it would in the clear: every layer but the
370
+ // random one, whose guess at a random-looking decoding would make every blob a secret (guessing is off, so
371
+ // neither a guess nor its repeats count); the decision stage; the text's own commands and files. Base64 of
372
+ // base64 is read three deep.)
373
+ const encoded = options.encoded ?? 0;
374
+ const inner = (decoded) => encoded >= 3
375
+ ? this.known.find(decoded).length > 0
376
+ : this.detectNormalized(decoded, { guessing: "off", encoded: encoded + 1 }, 0).some((f) => !isGuess(f.rule));
377
+ found.push(...findEncoded(text, inner), ...findHexDumps(text, inner));
378
+ }
379
+ found = merge(wrapped(text, found));
380
+ if (on.has("random")) {
381
+ // (Guessing as asked, but in a secret file, the hint's or one written inside the text, always: there the model
382
+ // isn't guessing whether secrets are present, it tells the file's generated values from its settings.)
383
+ const taken = (start, end) => overlaps(found, start, end);
384
+ if (guessing !== "off" || secretFile)
385
+ found = merge([...found, ...decide(findUnnamed(text, taken, guessing, secretFile, this.without))]);
386
+ // (A secret file written inside the text, or its diff hunks, is read as that file is.)
387
+ for (const { start: from, end: to } of secretSections) {
388
+ const inner = findUnnamed(text.slice(from, to), (s, e) => taken(s + from, e + from), guessing, true, this.without).map((f) => ({ ...f, start: f.start + from, end: f.end + from }));
389
+ if (inner.length)
390
+ found = merge([...found, ...decide(inner)]);
391
+ }
392
+ }
393
+ if (this.without.has("repeats"))
394
+ return merge(found, text);
395
+ // (Another occurrence of a guess is judged where it stands: `PublicKey = <it>` says what it is.)
396
+ const again = repeats(text, found);
397
+ return merge([...found, ...again.filter((f) => !needsJudging(f.rule)), ...decide(again.filter((f) => needsJudging(f.rule)))], text);
398
+ }
399
+ /** The text that replaces a secret: `[REDACTED <kind> #<fingerprint>]` unless a placeholder function was given. */
400
+ placeholderFor(f, value) {
401
+ if (this.placeholder)
402
+ return this.placeholder(f, value);
403
+ return `[REDACTED ${f.kind.replace(/[^\w .:/-]/g, "").slice(0, 60)} #${this.fingerprint(value)}]`;
404
+ }
405
+ /**
406
+ * @internal What replaces a finding in `text`, and the span it replaces: the placeholder; or, for a finding in a
407
+ * bare JSON number (`{"pin": 1234}`, `[9812773412, 5]`, `-98127.5`), the whole number token replaced by the
408
+ * placeholder in quotes, so the JSON stays valid.
409
+ */
410
+ replacement(text, f, value) {
411
+ if (f.rule === PLACEHOLDER_RULE)
412
+ return { start: f.start, end: f.end, text: text.slice(f.start, f.end) };
413
+ const ph = this.placeholderFor(f, value);
414
+ const number = jsonNumberAround(text, f.start, f.end);
415
+ return number && !/["\\]/.test(ph)
416
+ ? { start: number.start, end: number.end, text: `${number.quote}${ph}${number.quote}` }
417
+ : { start: f.start, end: f.end, text: ph };
418
+ }
419
+ /** `text` with every secret replaced by a placeholder. */
420
+ redact(text, options = {}) {
421
+ return this.apply(text, this.detect(text, options));
422
+ }
423
+ /** @internal Whether the decision stage keeps `f` hidden in `text`, with this redactor's options (`hashes`). */
424
+ judge(text, f) {
425
+ return this.without.has("policy") || keepReason(text, f, this.hashes) === undefined;
426
+ }
427
+ /** @internal `text` with `findings` (merged: sorted, apart) replaced by placeholders. */
428
+ apply(text, findings) {
429
+ if (!findings.length)
430
+ return { text, findings: [] };
431
+ let out = "";
432
+ let at = 0;
433
+ const result = [];
434
+ for (const f of findings) {
435
+ const value = secretValue(text, f);
436
+ const r = this.replacement(text, f, value);
437
+ // (A replacement that spans more than its finding can span the next one too: two findings in one JSON number are
438
+ // hidden by the one placeholder.)
439
+ if (r.end > at) {
440
+ out += text.slice(at, Math.max(at, r.start)) + r.text;
441
+ at = Math.max(r.end, f.end);
442
+ }
443
+ if (f.rule !== PLACEHOLDER_RULE)
444
+ result.push({ ...f, value });
445
+ }
446
+ return { text: out + text.slice(at), findings: result };
447
+ }
448
+ /**
449
+ * Learns the secrets in `text` as known values, so they're hidden wherever they turn up later, with
450
+ * or without context: read a `.env` once and its values are hidden in every later output. Returns how many it
451
+ * hadn't held. `minConfidence` (default 0.9) is the least confidence a finding needs to be learned.
452
+ */
453
+ learn(text, options = {}) {
454
+ const before = this.known.size;
455
+ for (const f of this.detect(text, options)) {
456
+ const value = secretValue(text, f);
457
+ if (!isLearnable(f, value, options.minConfidence))
458
+ continue;
459
+ this.known.add(value, f.kind);
460
+ const password = loginPassword(f, value);
461
+ if (password)
462
+ this.known.add(password, f.kind);
463
+ }
464
+ return this.known.size - before;
465
+ }
466
+ /**
467
+ * A copy of any JSON value (a message list, a request body) with every secret in every string and key
468
+ * replaced, as `JSON.stringify` would send it. Tool results are judged with their tool call's command or path,
469
+ * and a secret found anywhere is hidden everywhere in it. Options: `learn` (default true: remember what it finds
470
+ * for later calls too), `thinking` (default "redact").
471
+ */
472
+ redactValue(value, options = {}) {
473
+ return redactValue(this, value, options);
474
+ }
475
+ /**
476
+ * A streaming redactor for text that arrives in chunks (a tool's output). Each `write` returns redacted text
477
+ * ready to pass on: text is released once it's far enough behind the newest chunk that no secret can still be
478
+ * forming across it, scanned in batches, so nothing comes out until about twice `holdback` characters have
479
+ * arrived (default 32,768, at least 8,192). `end()` flushes the rest.
480
+ */
481
+ stream(options = {}) {
482
+ return new RedactionStream(this, options);
483
+ }
484
+ }
485
+ /** Redacts with a one-off Redactor. Prefer a long-lived Redactor so known values and fingerprints carry over. */
486
+ export function redact(text, options = {}) {
487
+ return new Redactor(options).redact(text, { hint: options.hint });
488
+ }