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,61 @@
1
+ import type { Finding } from "./types.ts";
2
+ /**
3
+ * Whether a value is never a secret by its shape alone: an ARN, a host name, a file path, a URL with no password (an
4
+ * http(s) one only when it has no query and no segment long enough for a webhook's token). Learning one would hide it
5
+ * everywhere after, and a text that says `API_TOKEN=<an ARN>` can be written by anyone the tool output comes from.
6
+ * (Hex stays learnable: real keys are hex, and a commit SHA taught as one is over-redaction only.)
7
+ */
8
+ export declare function isNeverSecretShape(value: string): boolean;
9
+ /**
10
+ * The password of a finding that is a login: a username and a password in one value, `user:password` or
11
+ * `user/password`, under a name that says it holds one (names.ts namesLogin: `NEO4J_AUTH=neo4j/…`, Bundler's
12
+ * `BUNDLE_<HOST>: "user:…"`). Learned and repeated on its own too, as a client takes it (`cypher-shell -u neo4j -p
13
+ * <it>`). Not a `:` or `/` inside another secret (a signature's `abc:<id>`), nor a URL's (its password is read in the
14
+ * URL); before a `/`, only a user's lower-case name (`neo4j/…`, not a base64 key's `wJalrXUtnFEMI/K7MDENG…`).
15
+ */
16
+ export declare function loginPassword(f: Finding, value: string): string | undefined;
17
+ /**
18
+ * Whether a finding is sure enough, and long enough, to be learned as a known value and hidden wherever it appears: one
19
+ * a rule read by a name, a format, a structure or a file, not the unnamed layer's guess about one place; and whose
20
+ * `value` (secretValue) isn't of a shape no secret has (isNeverSecretShape).
21
+ */
22
+ export declare function isLearnable(f: Finding, value: string, minConfidence?: number): boolean;
23
+ /**
24
+ * The secret a finding covers, as the program reads it, so one secret written two ways gets one tag and is learned
25
+ * once: its recorded value (a known value whichever way it was written, a literal split in pieces joined, a JSON
26
+ * escape decoded); or the text it spans, read through a URL's percent-encoding (a userinfo's `Harbor2291%21x`, a
27
+ * query's `%26`, `+` a blank), a double-quoted string's escapes (`"Ab\"cd9"`, `\u0041`), or a shell's backslashes
28
+ * outside quotes (`Harbor2291\!x`). (Not a single-quoted string's: a shell's has no escapes, a SQL one doubles its
29
+ * quote, and `'a''b'` is `ab` to a shell.)
30
+ */
31
+ export declare function secretValue(text: string, f: {
32
+ start: number;
33
+ end: number;
34
+ value?: string;
35
+ }): string;
36
+ /**
37
+ * A finding that may have been cut by a read bound (MAY_BE_CUT) and stops inside a word runs to the word's end, a blank
38
+ * or a quote: a longer secret must not leave its tail. A shorter one ends where its reader meant it to (a URL's
39
+ * password at its `@`). (A finding whose recorded value differs from its span, decoded or joined, is left as it is.)
40
+ */
41
+ export declare function toWordEnd(text: string, f: Finding): Finding;
42
+ /**
43
+ * A finding trimmed to its value, of what is syntax beyond doubt: blanks, an `=` or `:` with a blank or a quote after it,
44
+ * and a pair of quotes around the value (`= "CANYxh7686!"`); a comma or a semicolon before the next `name=` of a list
45
+ * (`passwort=COBAlyrxgo%, knownHosts=…`). A password's own first or last character (`:Kx9…`, `Kx9…)`, `Kx9…;`) stays.
46
+ * A placeholder then replaces the value only, so the syntax around it stays; and the value is what's fingerprinted and
47
+ * learned. (A finding that records its value, decoded or joined, is that value already.)
48
+ */
49
+ export declare function trimmedToValue(text: string, f: Finding): Finding;
50
+ /** Findings merged into one span each, sorted; with `text`, kept off escape sequences. (The inputs aren't changed.) */
51
+ export declare function merge(findings: Finding[], text?: string): Finding[];
52
+ /** Whether [start, end) lies inside one of `sorted` (merged, sorted by start). */
53
+ export declare function covered(sorted: Finding[], start: number, end: number): boolean;
54
+ /** Whether [start, end) overlaps one of `sorted` (merged, sorted by start). */
55
+ export declare function overlaps(sorted: Finding[], start: number, end: number): boolean;
56
+ /**
57
+ * A token cut by a terminal or log viewer wrapping at a fixed width (`Authorization: Bearer eyJ…` at
58
+ * column 80): a finding that ends exactly at the end of a line of a common wrap width continues on
59
+ * the following lines while they are bare token characters, up to the first shorter line.
60
+ */
61
+ export declare function wrapped(text: string, found: Finding[]): Finding[];
package/dist/spans.js ADDED
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Spans of findings: merging overlapping ones, keeping them off escape sequences, extending a token a terminal
3
+ * wrapped, and what a finding's value is.
4
+ */
5
+ import { lastText, lineEnd, lineStart, lowerBound } from "./lines.js";
6
+ import { naturalness } from "./charlm.js";
7
+ import { backslashesBefore, escapeLength, UNQUOTED_READ } from "./syntax.js";
8
+ import { leadingUrl } from "./urls.js";
9
+ import { isPath } from "./values.js";
10
+ import { isReplaceable } from "./known.js";
11
+ import { namesLogin } from "./names.js";
12
+ import { continuesAcrossWraps, isGuess, isWholeLine, layerOf, PLACEHOLDER_RULE } from "./rules.js";
13
+ /** An Amazon Resource Name: `arn:aws:iam::483920176654:role/deploy`, in any partition. */
14
+ const ARN = /^arn:[a-z][a-z0-9-]*:[a-z0-9-]+:[a-z0-9-]*:\d*:[\w:/.@+=,*-]+$/;
15
+ /** A host name: dotted lowercase labels ending in a TLD, with a port maybe (`db-main.cluster-cx8k2.us-west-2.rds.amazonaws.com:5432`). */
16
+ const HOST_NAME = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}(?::\d{1,5})?$/;
17
+ /** A URL path segment this long may be a generated token (a webhook's), not a name. */
18
+ const TOKEN_SEGMENT = 20;
19
+ /**
20
+ * Whether a value is never a secret by its shape alone: an ARN, a host name, a file path, a URL with no password (an
21
+ * http(s) one only when it has no query and no segment long enough for a webhook's token). Learning one would hide it
22
+ * everywhere after, and a text that says `API_TOKEN=<an ARN>` can be written by anyone the tool output comes from.
23
+ * (Hex stays learnable: real keys are hex, and a commit SHA taught as one is over-redaction only.)
24
+ */
25
+ export function isNeverSecretShape(value) {
26
+ if (ARN.test(value) || HOST_NAME.test(value) || isPath(value))
27
+ return true;
28
+ const url = leadingUrl(value);
29
+ if (!url || url.end !== value.length || url.password)
30
+ return false;
31
+ return (!/^https?$/i.test(url.scheme) || (!/[?#]/.test(value) && url.segments.every((s) => s.end - s.start < TOKEN_SEGMENT)));
32
+ }
33
+ /**
34
+ * The password of a finding that is a login: a username and a password in one value, `user:password` or
35
+ * `user/password`, under a name that says it holds one (names.ts namesLogin: `NEO4J_AUTH=neo4j/…`, Bundler's
36
+ * `BUNDLE_<HOST>: "user:…"`). Learned and repeated on its own too, as a client takes it (`cypher-shell -u neo4j -p
37
+ * <it>`). Not a `:` or `/` inside another secret (a signature's `abc:<id>`), nor a URL's (its password is read in the
38
+ * URL); before a `/`, only a user's lower-case name (`neo4j/…`, not a base64 key's `wJalrXUtnFEMI/K7MDENG…`).
39
+ */
40
+ export function loginPassword(f, value) {
41
+ if (!f.name || !namesLogin(f.name) || value.includes("://"))
42
+ return undefined;
43
+ const pair = /^(?:[\w.@+-]{1,64}:|[a-z][a-z0-9._-]{0,31}\/)([^\s/:]\S*)$/.exec(value);
44
+ return pair && isReplaceable(pair[1]) ? pair[1] : undefined;
45
+ }
46
+ /**
47
+ * Whether a finding is sure enough, and long enough, to be learned as a known value and hidden wherever it appears: one
48
+ * a rule read by a name, a format, a structure or a file, not the unnamed layer's guess about one place; and whose
49
+ * `value` (secretValue) isn't of a shape no secret has (isNeverSecretShape).
50
+ */
51
+ export function isLearnable(f, value, minConfidence = 0.9) {
52
+ return (f.confidence >= minConfidence &&
53
+ f.end - f.start >= 8 &&
54
+ !isGuess(f.rule) &&
55
+ f.rule !== PLACEHOLDER_RULE &&
56
+ !isNeverSecretShape(value));
57
+ }
58
+ /**
59
+ * The secret a finding covers, as the program reads it, so one secret written two ways gets one tag and is learned
60
+ * once: its recorded value (a known value whichever way it was written, a literal split in pieces joined, a JSON
61
+ * escape decoded); or the text it spans, read through a URL's percent-encoding (a userinfo's `Harbor2291%21x`, a
62
+ * query's `%26`, `+` a blank), a double-quoted string's escapes (`"Ab\"cd9"`, `\u0041`), or a shell's backslashes
63
+ * outside quotes (`Harbor2291\!x`). (Not a single-quoted string's: a shell's has no escapes, a SQL one doubles its
64
+ * quote, and `'a''b'` is `ab` to a shell.)
65
+ */
66
+ export function secretValue(text, f) {
67
+ if (f.value !== undefined)
68
+ return f.value;
69
+ const raw = text.slice(f.start, f.end);
70
+ const before = text.slice(Math.max(0, f.start - 200), f.start);
71
+ if (/%[0-9A-Fa-f]{2}/.test(raw)) {
72
+ const query = /[?&][\w.[\]-]{1,60}=$/.test(before);
73
+ if (query || /:\/\/[^\s/@]*$/.test(before)) {
74
+ try {
75
+ return decodeURIComponent(query ? raw.replace(/\+/g, " ") : raw);
76
+ }
77
+ catch { } // an escape that isn't UTF-8: the text as it is
78
+ }
79
+ }
80
+ if (raw.includes("\\") && /"$/.test(before)) {
81
+ try {
82
+ return JSON.parse(`"${raw}"`);
83
+ }
84
+ catch { } // not JSON's escapes: the text as it is
85
+ }
86
+ if (/\\[^\w\s]/.test(raw) && !/["'`]$/.test(before))
87
+ return raw.replace(/\\([^\w\s])/g, "$1");
88
+ return raw;
89
+ }
90
+ /** The escape sequence covering position `i` (after its backslash), as [backslash, end), if `i` is inside one. */
91
+ function escapeAt(text, i) {
92
+ for (let b = i - 1; b >= Math.max(0, i - 6); b--) {
93
+ if (text[b] !== "\\" || backslashesBefore(text, b) % 2)
94
+ continue;
95
+ const end = b + escapeLength(text, b);
96
+ return end > i ? [b, end] : undefined;
97
+ }
98
+ return undefined;
99
+ }
100
+ /** An escape spelling a letter or a digit: `\x63`, `\u0063`. */
101
+ const SPELLED_CHAR = /\\(?:x|u00)(?:3[0-9]|[46][1-9a-fA-F]|[57][0-9aA])/y;
102
+ /** How many of them a token goes on through. */
103
+ const MAX_SPELLED = 64;
104
+ /**
105
+ * A span kept off escape sequences, so replacing it never breaks an escape or, inside JSON, the document. One that
106
+ * starts or ends inside an escape (`\n<token>`, `<token>\`, `\u0020`) was read through it by mistake, and leaves it
107
+ * out. In text encoded twice, `\\n` is the next level's escape, or an escaped backslash and a letter of the
108
+ * secret (`C:\\nKx82…`): there is no telling, so a span right after an escaped backslash takes the pair in, and an
109
+ * error over-redacts rather than leaks.
110
+ */
111
+ function onEscapes(text, f) {
112
+ let { start, end } = f;
113
+ const s = escapeAt(text, start);
114
+ if (s)
115
+ start = Math.min(end, s[1]);
116
+ else if (text[start - 1] === "\\" &&
117
+ text[start - 2] === "\\" &&
118
+ !escapeAt(text, start - 2) &&
119
+ /[nrtbfu"/]/.test(text[start] ?? ""))
120
+ start -= 2;
121
+ const e = escapeAt(text, end);
122
+ if (e)
123
+ end = Math.max(start, e[0]);
124
+ // A token goes on through an escape that spells one of its characters (`…zdp7d\x63` in a shell's `$'…'`).
125
+ for (let n = 0; n < MAX_SPELLED && end > start && /[\w-]/.test(text[end - 1]); n++) {
126
+ SPELLED_CHAR.lastIndex = end;
127
+ if (backslashesBefore(text, end) % 2 || !SPELLED_CHAR.test(text))
128
+ break;
129
+ end = SPELLED_CHAR.lastIndex;
130
+ }
131
+ return start === f.start && end === f.end ? f : { ...f, start, end, value: undefined };
132
+ }
133
+ /**
134
+ * A whole line taken as a secret (a one-line secret file, a command's printed line) that holds a confident
135
+ * finding of another rule (`export TOKEN="ghp_…"`, a URL with a token) gives way to it: the name and URL stay, and the
136
+ * placeholder's tag is the secret's own, the same as wherever else it appears.
137
+ */
138
+ function preferNarrower(findings) {
139
+ if (!findings.some((f) => isWholeLine(f.rule)))
140
+ return findings;
141
+ const narrow = findings
142
+ .filter((f) => !isWholeLine(f.rule) && f.confidence >= 0.9 && !isGuess(f.rule) && layerOf(f.rule) !== "repeat")
143
+ .sort((a, b) => a.start - b.start);
144
+ const starts = narrow.map((f) => f.start);
145
+ return findings.filter((w) => {
146
+ if (!isWholeLine(w.rule))
147
+ return true;
148
+ // (The narrow findings starting inside the line, by binary search; one that ends inside it too. One that is the
149
+ // whole line names it better: `cat .vault_pass` printing a GitHub token.)
150
+ for (let i = lowerBound(starts, w.start); i < narrow.length && narrow[i].start < w.end; i++) {
151
+ if (narrow[i].end <= w.end)
152
+ return false;
153
+ }
154
+ return true;
155
+ });
156
+ }
157
+ /** Overlapping findings as one span each, every field but the span the most confident one's. */
158
+ function union(findings) {
159
+ const sorted = [...findings].sort((a, b) => a.start - b.start || b.end - a.end);
160
+ const out = [];
161
+ for (const f of sorted) {
162
+ if (f.end <= f.start)
163
+ continue;
164
+ const last = out.at(-1);
165
+ if (!last || f.start >= last.end) {
166
+ out.push({ ...f });
167
+ continue;
168
+ }
169
+ const end = Math.max(last.end, f.end);
170
+ const winner = f.confidence > last.confidence ? f : last;
171
+ // (A value goes with its own span only: `last` may start earlier or `f` end later than the winner.)
172
+ const widened = last.start !== winner.start || end !== winner.end;
173
+ out[out.length - 1] = { ...winner, start: last.start, end, value: widened ? undefined : winner.value };
174
+ }
175
+ return out;
176
+ }
177
+ /** A character that may go on a value cut short: not a blank or a quote. */
178
+ const VALUE_CHAR = /[^\s"'`]/;
179
+ /**
180
+ * How long a finding is that a read bound may have cut: half of UNQUOTED_READ, as a reader's bound counts from where it
181
+ * started reading (a command's first word, a line's start), not from the value.
182
+ */
183
+ const MAY_BE_CUT = UNQUOTED_READ / 2;
184
+ /**
185
+ * A finding that may have been cut by a read bound (MAY_BE_CUT) and stops inside a word runs to the word's end, a blank
186
+ * or a quote: a longer secret must not leave its tail. A shorter one ends where its reader meant it to (a URL's
187
+ * password at its `@`). (A finding whose recorded value differs from its span, decoded or joined, is left as it is.)
188
+ */
189
+ export function toWordEnd(text, f) {
190
+ if (f.end - f.start < MAY_BE_CUT || f.value !== undefined || !VALUE_CHAR.test(text[f.end - 1]))
191
+ return f;
192
+ const end = wordEnds(text)[f.end];
193
+ return end === f.end ? f : { ...f, end };
194
+ }
195
+ /**
196
+ * For each position of a text, where the word (of VALUE_CHAR) it is in ends, an escape (`\n`, `\"`) ending one: built
197
+ * once a text, so many findings in one long word cost one scan.
198
+ */
199
+ const wordEnds = lastText((text) => {
200
+ const ends = new Int32Array(text.length + 1);
201
+ ends[text.length] = text.length;
202
+ for (let i = text.length - 1; i >= 0; i--)
203
+ ends[i] = VALUE_CHAR.test(text[i]) && !(text[i] === "\\" && /[nrt"]/.test(text[i + 1] ?? "")) ? ends[i + 1] : i;
204
+ return ends;
205
+ });
206
+ /**
207
+ * A finding trimmed to its value, of what is syntax beyond doubt: blanks, an `=` or `:` with a blank or a quote after it,
208
+ * and a pair of quotes around the value (`= "CANYxh7686!"`); a comma or a semicolon before the next `name=` of a list
209
+ * (`passwort=COBAlyrxgo%, knownHosts=…`). A password's own first or last character (`:Kx9…`, `Kx9…)`, `Kx9…;`) stays.
210
+ * A placeholder then replaces the value only, so the syntax around it stays; and the value is what's fingerprinted and
211
+ * learned. (A finding that records its value, decoded or joined, is that value already.)
212
+ */
213
+ export function trimmedToValue(text, f) {
214
+ if (f.value !== undefined)
215
+ return f;
216
+ let { start, end } = f;
217
+ const lead = /^[ \t]*(?:[=:](?:[ \t]+|(?=["'`])))?/.exec(text.slice(start, Math.min(end, start + 40)))[0].length;
218
+ start += lead;
219
+ const q = text[start];
220
+ if (/["'`]/.test(q ?? "") && end - start > 2 && text[end - 1] === q && !text.slice(start + 1, end - 1).includes(q)) {
221
+ start++;
222
+ end--;
223
+ }
224
+ if (end - start > 1 && /[,;]/.test(text[end - 1]) && /^[ \t]+[A-Za-z_][\w.-]*=/.test(text.slice(end, end + 80)))
225
+ end--;
226
+ return start === f.start && end === f.end ? f : { ...f, start, end };
227
+ }
228
+ /** Findings merged into one span each, sorted; with `text`, kept off escape sequences. (The inputs aren't changed.) */
229
+ export function merge(findings, text) {
230
+ return union(preferNarrower(text ? findings.map((f) => onEscapes(text, f)) : findings));
231
+ }
232
+ /** The first of `sorted` (merged, sorted by start) that ends after `start`, by binary search. */
233
+ function firstEndingAfter(sorted, start) {
234
+ let lo = 0;
235
+ let hi = sorted.length;
236
+ while (lo < hi) {
237
+ const mid = (lo + hi) >> 1;
238
+ if (sorted[mid].end <= start)
239
+ lo = mid + 1;
240
+ else
241
+ hi = mid;
242
+ }
243
+ return lo;
244
+ }
245
+ /** Whether [start, end) lies inside one of `sorted` (merged, sorted by start). */
246
+ export function covered(sorted, start, end) {
247
+ const f = sorted[firstEndingAfter(sorted, start)];
248
+ return !!f && f.start <= start && f.end >= end;
249
+ }
250
+ /** Whether [start, end) overlaps one of `sorted` (merged, sorted by start). */
251
+ export function overlaps(sorted, start, end) {
252
+ const f = sorted[firstEndingAfter(sorted, start)];
253
+ return !!f && f.start < end;
254
+ }
255
+ /** Terminal widths output is commonly wrapped at. */
256
+ const WRAP_WIDTHS = new Set([64, 76, 79, 80, 100, 120, 132]);
257
+ /**
258
+ * A token cut by a terminal or log viewer wrapping at a fixed width (`Authorization: Bearer eyJ…` at
259
+ * column 80): a finding that ends exactly at the end of a line of a common wrap width continues on
260
+ * the following lines while they are bare token characters, up to the first shorter line.
261
+ */
262
+ export function wrapped(text, found) {
263
+ return found.map((f) => {
264
+ if (!continuesAcrossWraps(f.rule))
265
+ return f;
266
+ let end = f.end;
267
+ const width = end - lineStart(text, end, 400);
268
+ if (!WRAP_WIDTHS.has(width))
269
+ return f;
270
+ for (let n = 0; n < 100 && (text[end] === "\n" || (text[end] === "\r" && text[end + 1] === "\n")); n++) {
271
+ const from = end + (text[end] === "\r" ? 2 : 1);
272
+ const to = lineEnd(text, from, width + 1);
273
+ const line = text.slice(from, to).replace(/\r$/, "");
274
+ if (line.length < 8 ||
275
+ line.length > width ||
276
+ !/^[A-Za-z0-9._~+/-]+={0,2}$/.test(line) ||
277
+ !/\d/.test(line) ||
278
+ !/[A-Za-z]/.test(line) ||
279
+ naturalness(line) > 0.5)
280
+ break;
281
+ end = from + line.length;
282
+ if (line.length < width)
283
+ break;
284
+ }
285
+ return end === f.end ? f : { ...f, end };
286
+ });
287
+ }
package/dist/sql.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * SQL `INSERT … VALUES` statements read into cells: each row's values under their table and column. One reader for
3
+ * every rule that asks what a value in a row is: the name layer hides a credential column's values (named.ts sqlRows),
4
+ * the random layer keeps the other columns' IDs and data (context.ts, `sqlCell`). A statement without a column list
5
+ * (`INSERT INTO t VALUES …`, as mysqldump writes by default) names no column, so it isn't read.
6
+ */
7
+ import { type Argument } from "./calls.ts";
8
+ export interface SqlCell {
9
+ /** The table, without its schema or quotes: `users` in `` `app`.`users` ``. */
10
+ table: string;
11
+ /** The column, without quotes: `password` in `"password"`, `` `password` ``, `[password]`. */
12
+ column: string;
13
+ /** The value as an argument of the row: a string literal's content is its `literal`. */
14
+ value: Argument;
15
+ }
16
+ /**
17
+ * The cells of every `INSERT` in `text`, row by row (up to 10,000 rows a statement); with `wants`, only of the
18
+ * statements whose table and columns it accepts (so a dump's other tables cost no reading).
19
+ */
20
+ export declare function insertCells(text: string, wants?: (table: string, columns: string[]) => boolean): Generator<SqlCell>;
package/dist/sql.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * SQL `INSERT … VALUES` statements read into cells: each row's values under their table and column. One reader for
3
+ * every rule that asks what a value in a row is: the name layer hides a credential column's values (named.ts sqlRows),
4
+ * the random layer keeps the other columns' IDs and data (context.ts, `sqlCell`). A statement without a column list
5
+ * (`INSERT INTO t VALUES …`, as mysqldump writes by default) names no column, so it isn't read.
6
+ */
7
+ import { callArguments, closingBracket } from "./calls.js";
8
+ import { delimitedCells } from "./syntax.js";
9
+ /** An identifier as SQL dialects quote it: `"name"`, `` `name` ``, `[name]`. */
10
+ const unquote = (name) => name.trim().replace(/^["`[]|["`\]]$/g, "");
11
+ /**
12
+ * `INSERT [INTO] <table> (<columns>) VALUES`, and the dialects' other forms (`INSERT IGNORE`, `INSERT OR REPLACE`,
13
+ * `REPLACE INTO`), the table quoted as any dialect quotes it (`"my table"`, `` `app`.`users` ``, `[dbo].[users]`).
14
+ */
15
+ const INSERT = /\b(?:INSERT(?:\s+IGNORE|\s+OR\s+(?:REPLACE|IGNORE|ABORT|FAIL|ROLLBACK))?|REPLACE)\s+(?:INTO\s+)?((?:"[^"\n]{1,100}"|`[^`\n]{1,100}`|\[[^\]\n]{1,100}\]|[\w.]){1,200})\s*\(([^()]{1,2000})\)\s*VALUES\s*/gi;
16
+ /** What may stand between two rows: blanks, comments and one comma. */
17
+ const BETWEEN_ROWS = /(?:\s|--[^\n]*|\/\*[\s\S]{0,2000}?\*\/)*,?(?:\s|--[^\n]*|\/\*[\s\S]{0,2000}?\*\/)*/y;
18
+ /**
19
+ * The cells of every `INSERT` in `text`, row by row (up to 10,000 rows a statement); with `wants`, only of the
20
+ * statements whose table and columns it accepts (so a dump's other tables cost no reading).
21
+ */
22
+ export function* insertCells(text, wants) {
23
+ if (!/\b(?:INSERT|REPLACE)\b/i.test(text))
24
+ return;
25
+ for (const m of text.matchAll(INSERT)) {
26
+ const table = unquote(m[1].split(".").at(-1));
27
+ const columns = delimitedCells(m[2], ",").map((c) => unquote(c.text));
28
+ if (wants && !wants(table, columns))
29
+ continue;
30
+ const at = m.index + m[0].length;
31
+ // (A statement a program holds in a string, `cur.execute("INSERT … VALUES ('…')")`: the string's brackets aren't
32
+ // read as brackets, so its line is read as the statement on its own.)
33
+ if (text[at] === "(" && closingBracket(text, at + 1) < 0) {
34
+ const end = text.indexOf("\n", at);
35
+ const line = text.slice(m.index, end < 0 ? undefined : end);
36
+ for (const cell of rowCells(line, at - m.index, table, columns))
37
+ yield { ...cell, value: shifted(cell.value, m.index) };
38
+ }
39
+ else
40
+ yield* rowCells(text, at, table, columns);
41
+ }
42
+ }
43
+ /** The cells of the rows that start at `at` (`(…), (…)`), each value under its column. */
44
+ function* rowCells(text, at, table, columns) {
45
+ for (let row = 0; row < 10_000 && text[at] === "("; row++) {
46
+ // (A row cut short, a dump's `head`, ends the rows. callArguments reads to the bracket closingBracket finds.)
47
+ const values = callArguments(text, at + 1);
48
+ if (!values)
49
+ break;
50
+ const close = closingBracket(text, at + 1);
51
+ for (const [k, value] of values.entries())
52
+ if (columns[k])
53
+ yield { table, column: columns[k], value };
54
+ BETWEEN_ROWS.lastIndex = close + 1;
55
+ at = close + 1 + BETWEEN_ROWS.exec(text)[0].length;
56
+ }
57
+ }
58
+ /** An argument read from a piece of a text, placed where the piece starts in it. */
59
+ function shifted(a, by) {
60
+ return {
61
+ ...a,
62
+ start: a.start + by,
63
+ end: a.end + by,
64
+ ...(a.literal ? { literal: { ...a.literal, start: a.literal.start + by, end: a.literal.end + by } } : {}),
65
+ };
66
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Redaction of text that arrives in chunks (a tool's output, a model's response): each chunk is held until it is
3
+ * far enough behind the newest one that no secret can still be forming across it, then released redacted.
4
+ */
5
+ import type { Redactor } from "./index.ts";
6
+ import type { DetectOptions } from "./types.ts";
7
+ export declare class RedactionStream {
8
+ /** How far behind the newest text a release point must be: 8 KB covers the longest single secrets (a private key). */
9
+ private readonly holdback;
10
+ /** Text not released yet. */
11
+ private buffer;
12
+ /** Text released already, its last CONTEXT characters. */
13
+ private context;
14
+ /**
15
+ * The lines that open the scopes still open in the released text (secretfile.ts openScopes, structure.ts
16
+ * openManifestData): kept in front of the context however far back they are, so the text after them is read as
17
+ * it would be in the whole text (that command's output, that heredoc's body, that Secret's data).
18
+ */
19
+ private scopes;
20
+ /** While text is held (MAX_HELD), how long the buffer grows before it's looked at again. */
21
+ private heldUntil;
22
+ /** Positions in the whole stream: where `buffer` starts, and where the last value hidden ends. */
23
+ private released;
24
+ private hiddenUntil;
25
+ /** Values hidden earlier in the stream, looked for again however far back they were named, as `redact()` sees the whole text. */
26
+ private remembered;
27
+ private readonly redactor;
28
+ private readonly options;
29
+ constructor(redactor: Redactor, options: DetectOptions & {
30
+ holdback?: number;
31
+ });
32
+ write(chunk: string): string;
33
+ end(): string;
34
+ /** What scanning sees in front of the buffer: the open scopes' lines that have scrolled out of it, and the released context. */
35
+ private head;
36
+ /**
37
+ * Where to release up to: a line break at least `holdback` from the end, so a line is judged whole (a JSONL record,
38
+ * a base64 blob); 0 to hold an unfinished line. Past MAX_HELD, the last blank or delimiter, or the holdback itself;
39
+ * never inside a surrogate pair.
40
+ */
41
+ private releasePoint;
42
+ private flush;
43
+ private remember;
44
+ /** Moves the first `cut` characters of the buffer into the released context. */
45
+ private advance;
46
+ }
package/dist/stream.js ADDED
@@ -0,0 +1,150 @@
1
+ import { occurrences, repeatable } from "./repeats.js";
2
+ import { openScopes } from "./secretfile.js";
3
+ import { openManifestData } from "./structure.js";
4
+ import { needsNormalizing, normalize } from "./normalize.js";
5
+ import { merge, secretValue } from "./spans.js";
6
+ /** Text already released kept as left context (the line naming a value, the `$ cat .env` above it), scanned again but never released again. */
7
+ const CONTEXT = 8_192;
8
+ /** Values hidden earlier in the stream that are looked for again, the most recent first. */
9
+ const REMEMBERED = 2_000;
10
+ /** How much text is held back while what it is waits on a line still to come (a manifest's `kind` after its `data:`). */
11
+ const MAX_HELD = 1_000_000;
12
+ export class RedactionStream {
13
+ /** How far behind the newest text a release point must be: 8 KB covers the longest single secrets (a private key). */
14
+ holdback;
15
+ /** Text not released yet. */
16
+ buffer = "";
17
+ /** Text released already, its last CONTEXT characters. */
18
+ context = "";
19
+ /**
20
+ * The lines that open the scopes still open in the released text (secretfile.ts openScopes, structure.ts
21
+ * openManifestData): kept in front of the context however far back they are, so the text after them is read as
22
+ * it would be in the whole text (that command's output, that heredoc's body, that Secret's data).
23
+ */
24
+ scopes = [];
25
+ /** While text is held (MAX_HELD), how long the buffer grows before it's looked at again. */
26
+ heldUntil = 0;
27
+ /** Positions in the whole stream: where `buffer` starts, and where the last value hidden ends. */
28
+ released = 0;
29
+ hiddenUntil = 0;
30
+ /** Values hidden earlier in the stream, looked for again however far back they were named, as `redact()` sees the whole text. */
31
+ remembered = new Map();
32
+ redactor;
33
+ options;
34
+ constructor(redactor, options) {
35
+ this.redactor = redactor;
36
+ this.options = { hint: options.hint, guessing: options.guessing };
37
+ this.holdback = Math.max(8_192, options.holdback ?? 32_768);
38
+ }
39
+ write(chunk) {
40
+ this.buffer += chunk;
41
+ // Scanned once the buffer has twice the holdback, so each character is scanned about twice.
42
+ return this.buffer.length < Math.max(this.holdback * 2, this.heldUntil) ? "" : this.flush(false);
43
+ }
44
+ end() {
45
+ return this.flush(true);
46
+ }
47
+ /** What scanning sees in front of the buffer: the open scopes' lines that have scrolled out of it, and the released context. */
48
+ head() {
49
+ // (Each scope line ends in its line break, so it matches only a whole line of the context, never the start of a longer one.)
50
+ return this.scopes.filter((line) => !`\n${this.context}`.includes(`\n${line}`)).join("") + this.context;
51
+ }
52
+ /**
53
+ * Where to release up to: a line break at least `holdback` from the end, so a line is judged whole (a JSONL record,
54
+ * a base64 blob); 0 to hold an unfinished line. Past MAX_HELD, the last blank or delimiter, or the holdback itself;
55
+ * never inside a surrogate pair.
56
+ */
57
+ releasePoint(all) {
58
+ const buffer = this.buffer;
59
+ if (all)
60
+ return buffer.length;
61
+ let cut = buffer.lastIndexOf("\n", buffer.length - this.holdback) + 1;
62
+ if (cut <= 0 && buffer.length < MAX_HELD)
63
+ return 0;
64
+ if (cut <= 0) {
65
+ const limit = buffer.length - this.holdback;
66
+ cut =
67
+ Math.max(buffer.lastIndexOf(" ", limit), buffer.lastIndexOf(",", limit), buffer.lastIndexOf(";", limit)) + 1;
68
+ if (cut <= 0)
69
+ cut = limit;
70
+ }
71
+ if (cut > 0 && isHighSurrogate(buffer.charCodeAt(cut - 1)))
72
+ cut--;
73
+ return cut;
74
+ }
75
+ flush(all) {
76
+ const head = this.head();
77
+ const left = head.length;
78
+ // (Up to the buffer's last line break, but at the end: no detector sees a line cut short that the whole text wouldn't.)
79
+ const lineEnd = all ? -1 : this.buffer.lastIndexOf("\n");
80
+ const whole = head + (lineEnd < 0 ? this.buffer : this.buffer.slice(0, lineEnd + 1));
81
+ // An unfinished line, and a Secret's data whose `kind` hasn't come yet, are held until they are whole.
82
+ let cut = this.releasePoint(all);
83
+ if (cut <= 0 || (!all && this.buffer.length < MAX_HELD && openManifestData(whole)?.pending)) {
84
+ this.heldUntil = this.buffer.length + this.holdback;
85
+ return "";
86
+ }
87
+ this.heldUntil = 0;
88
+ const detected = this.redactor.detect(whole, this.options);
89
+ const findings = (this.remembered.size ? merge([...detected, ...occurrences(whole, this.remembered, detected)], whole) : detected)
90
+ .filter((f) => f.end > left)
91
+ .map((f) => ({ ...f, start: f.start - left, end: f.end - left, value: secretValue(whole, f) }));
92
+ // A finding across the cut is released whole later, unless it started before this buffer (a blob longer than
93
+ // the holdback): that one is hidden now, and its continuation dropped as it arrives, while its start is still in
94
+ // the context read before the buffer (CONTEXT; a single secret longer than that, which no key is, shows its tail).
95
+ for (const f of findings)
96
+ if (f.start < cut && f.end > cut && f.start > 0)
97
+ cut = f.start;
98
+ let out = "";
99
+ let at = 0;
100
+ for (const f of findings) {
101
+ if (f.start >= cut)
102
+ break;
103
+ // (What replaces it may be wider than the finding: a JSON number's whole token, quoted.)
104
+ const r = this.redactor.replacement(whole, { ...f, start: f.start + left, end: f.end + left }, f.value);
105
+ const end = Math.max(f.end, r.end - left);
106
+ out += this.buffer.slice(at, Math.max(at, r.start - left));
107
+ // The rest of a value already replaced by a placeholder adds nothing; a value that began in text already
108
+ // released, found only now, is hidden from here on. (Judged in the whole scanned text, so a JSON key
109
+ // released before still counts.)
110
+ if (this.released + f.start >= this.hiddenUntil)
111
+ out += r.text;
112
+ this.hiddenUntil = Math.max(this.hiddenUntil, this.released + end);
113
+ at = Math.min(end, cut);
114
+ }
115
+ out += this.buffer.slice(at, cut);
116
+ this.remember(repeatable(whole, detected.filter((f) => f.start >= left && f.start - left < cut)));
117
+ this.advance(cut);
118
+ return out;
119
+ }
120
+ remember(values) {
121
+ for (const [form, r] of values) {
122
+ this.remembered.delete(form);
123
+ this.remembered.set(form, r);
124
+ if (this.remembered.size > REMEMBERED)
125
+ this.remembered.delete(this.remembered.keys().next().value);
126
+ }
127
+ }
128
+ /** Moves the first `cut` characters of the buffer into the released context. */
129
+ advance(cut) {
130
+ const out = this.buffer.slice(0, cut);
131
+ // (Read as detection reads it: normalized, colours and line continuations gone, a diff's markers too.)
132
+ const raw = this.head() + out;
133
+ const seen = needsNormalizing(raw) ? normalize(raw).text : raw;
134
+ this.scopes = [...openScopes(seen), ...(openManifestData(seen)?.lines ?? [])];
135
+ // The context starts at a line's start, as the whole text's lines do (a piece of a line would read as one).
136
+ const joined = this.context + out;
137
+ this.context = joined.slice(-CONTEXT);
138
+ if (joined.length > CONTEXT && joined[joined.length - CONTEXT - 1] !== "\n") {
139
+ const nl = this.context.indexOf("\n");
140
+ if (nl >= 0)
141
+ this.context = this.context.slice(nl + 1);
142
+ }
143
+ if (isLowSurrogate(this.context.charCodeAt(0)))
144
+ this.context = this.context.slice(1);
145
+ this.buffer = this.buffer.slice(cut);
146
+ this.released += cut;
147
+ }
148
+ }
149
+ const isHighSurrogate = (c) => c >= 0xd800 && c <= 0xdbff;
150
+ const isLowSurrogate = (c) => c >= 0xdc00 && c <= 0xdfff;
@@ -0,0 +1,24 @@
1
+ import type { Finding } from "./types.ts";
2
+ /**
3
+ * Whether a URL's password field (or a command line's password) holds a password rather than a variable, format
4
+ * specifier or placeholder. `quoted`: the value sat in single quotes, where a shell expands nothing (`-p'$mIWy…'`
5
+ * is a literal).
6
+ */
7
+ export declare function isLiteralPassword(value: string, quoted?: boolean): boolean;
8
+ /** HTTP Basic credentials whose password is a docs placeholder (`user:password`, `username:[password]`). */
9
+ export declare function isPlaceholderBasic(b64: string): boolean;
10
+ /** Whether a cookie holds something worth hiding: not an attribute, a flag, a number, or an analytics or preference cookie. */
11
+ export declare function isSessionCookie(name: string, value: string): boolean;
12
+ /**
13
+ * A manifest's `data:` or `stringData:` mapping still open at the end of a text (no line has come back to its column),
14
+ * by the lines that open it: its own and its `kind` (a Secret's, or a ConfigMap's), at its column. `pending`: its
15
+ * `kind` hasn't come yet (kubectl prints `data:` first), so what follows can't be judged until it does.
16
+ */
17
+ export declare function openManifestData(text: string): {
18
+ lines: string[];
19
+ pending: boolean;
20
+ } | undefined;
21
+ /** Everything in this module. */
22
+ export declare function findStructure(text: string): Finding[];
23
+ /** Whether a text is a published BIP-39 phrase (see isPublishedPhrase): 12 to 24 list words whose checksum verifies. */
24
+ export declare function isPublishedMnemonic(text: string): boolean;