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,98 @@
1
+ import { type NameClass } from "./names.ts";
2
+ import { type SqlCell } from "./sql.ts";
3
+ /**
4
+ * A text read once for the context of its tokens: its logical lines (a line break ends one, and so does an escaped one,
5
+ * syntax.ts isEscapedBreak: escaped text has no telling a JSON string's `\n` from a path's `C:\nginx`), the extents
6
+ * of its public armored blocks, and the first lines of its comma-separated blocks as they're found.
7
+ */
8
+ export declare class Doc {
9
+ readonly text: string;
10
+ /** Where each line starts, and where it ends (before its break). */
11
+ private readonly starts;
12
+ private readonly ends;
13
+ /** Public armored blocks: where each BEGIN is, in order, and where its block ends. */
14
+ private armor?;
15
+ /** Whether the text has a JSON Web Key in it, so a token's enclosing object is worth reading. */
16
+ readonly hasJwk: boolean;
17
+ /** The first line of the comma-separated block each line walked is in, by line and cell count (`line:cells`). */
18
+ private readonly csvTops;
19
+ /** The cells of the text's SQL rows, by where their values start. */
20
+ private cells?;
21
+ private cellStarts?;
22
+ /** The last text read, so the rules that judge one text's spans one by one (policy.ts) read it once. */
23
+ static readonly of: (text: string) => Doc;
24
+ constructor(text: string);
25
+ private lineBreak;
26
+ /** The index of the line `at` is on. */
27
+ lineOf(at: number): number;
28
+ start(line: number): number;
29
+ end(line: number): number;
30
+ /** A line's text; empty for a line longer than LONG_LINE, which is no table row, blob line or sibling. */
31
+ line(i: number): string;
32
+ /**
33
+ * Whether `at` is inside a public armored block: a certificate, a public key, a PGP public block or signature, read
34
+ * as pem.ts reads a block (to its END, or where its material stops when output is cut short: `head -3 cert.pem`).
35
+ */
36
+ inPublicArmor(at: number): boolean;
37
+ /** The cell of an SQL `INSERT` row whose value holds `[start, end)`, read as sql.ts reads rows. */
38
+ sqlCell(start: number, end: number): SqlCell | undefined;
39
+ /**
40
+ * The first line of the comma-separated block that line `i` is in: the lines above it with as many cells, up to
41
+ * 2,000. Remembered for every line walked, so a block's rows cost one walk between them.
42
+ */
43
+ csvBlockTop(i: number, cells: number): number;
44
+ }
45
+ type Syntax = "assign" | "flag" | "header" | "query" | "path" | "call" | "xml" | "prose" | "bare" | "list" | "other";
46
+ export interface Context {
47
+ /** The name the token is assigned to, if any. */
48
+ name: string;
49
+ nameClass: NameClass;
50
+ syntax: Syntax;
51
+ /** The token is inside quotes. */
52
+ quoted: boolean;
53
+ /** A hash or content-address prefix right before: `sha256:`, `sha512-`, `h1:`, `@sha256:`, `md5=`. */
54
+ hashPrefix: boolean;
55
+ /** A filename extension or path continues after: `main.3f9a2c.js`, `abc123.png`. */
56
+ fileish: boolean;
57
+ /** Credential words before the token on its line or on the line above (weaker than a name). */
58
+ lineCredential: boolean;
59
+ /** Hash or ID words before the token on its line, on the line above, or just after the token. */
60
+ lineHash: boolean;
61
+ /**
62
+ * A hash or ID word heads the phrase the token belongs to: last before it (`commit <t>`, `sha256: <t>`, `Resolved
63
+ * <t>`, on the line above for a token that starts its line) or first after it (`<t> (sha256)`). Not one elsewhere
64
+ * on the line: "your new API key for the git server: <t>".
65
+ */
66
+ hashPhrase: boolean;
67
+ /** The previous URL path segment, for `/hooks/<token>`, `/webhook/<token>`. */
68
+ pathParent: string;
69
+ /** A phrase that hands over a value: "here's your key", "Token created:", "use this one". */
70
+ handover: boolean;
71
+ /** The token sits in a URL (host, path, query or fragment). */
72
+ inUrl: boolean;
73
+ /** The token is a segment of a filesystem path (`/tmp/tmpab12…`, `/var/lib/docker/overlay2/l/…`). */
74
+ fsPath: boolean;
75
+ /**
76
+ * The token is the whole value of a `name=value` or `name: value` line and a PEM block opens on the next line: the
77
+ * block's label (OpenSSL's test files, `PrivateKey=ALICE_cf_c2pnb163v1` above `-----BEGIN PRIVATE KEY-----`).
78
+ */
79
+ labelsBlock: boolean;
80
+ /** The token sits inside a PEM PUBLIC KEY or CERTIFICATE block, or an SSH public key line. */
81
+ armored: boolean;
82
+ /** The token fills its line, and a neighbouring line is base64 of similar length: a wrapped blob. */
83
+ blobLine: boolean;
84
+ /** The SQL row's cell the token is the value of, or in. */
85
+ sqlCell: SqlCell | undefined;
86
+ /** The token is a member of a JSON Web Key: its innermost object has a `kty`. */
87
+ jwk: boolean;
88
+ /** The token is an argument on a shell command line (`$ ./notify.sh prod <token>`, `+ curl … <token>`). */
89
+ shellArg: boolean;
90
+ /** The left context of the line, for features. */
91
+ left: string;
92
+ /** The rest of the line after the token. */
93
+ right: string;
94
+ /** A few lines around the token. */
95
+ near: string;
96
+ }
97
+ export declare function contextOf(doc: Doc, start: number, end: number): Context;
98
+ export {};
@@ -0,0 +1,469 @@
1
+ /**
2
+ * The context of a token: the name it's assigned to, the syntax it sits in, and the words around it.
3
+ * A random token under `api_key=`, in an `X-Api-Key:` header or after `Client(` is likely a key; the
4
+ * same token after `sha256:`, under `"integrity":` or after `commit ` is a hash.
5
+ */
6
+ import { lastText, lowerBound } from "./lines.js";
7
+ import { isGenericName, mentionsCredential, nameClass, phraseBefore, readPhrase } from "./names.js";
8
+ import { inJwk } from "./json.js";
9
+ import { publicBlocks } from "./publickeys.js";
10
+ import { insertCells } from "./sql.js";
11
+ import { backslashesBefore, delimitedCells, isEscapedBreak, PAIR_VALUE, PIPE_RULE, pipeCells, siblingName, STRING_PREFIX, } from "./syntax.js";
12
+ import { PARAM_NAME, PARAM_START, urlAround } from "./urls.js";
13
+ /** How far a line is read either side of a token, and the longest line read whole for its structure (tables, blobs). */
14
+ const LEFT = 400;
15
+ const RIGHT = 200;
16
+ const LONG_LINE = 4000;
17
+ /**
18
+ * A text read once for the context of its tokens: its logical lines (a line break ends one, and so does an escaped one,
19
+ * syntax.ts isEscapedBreak: escaped text has no telling a JSON string's `\n` from a path's `C:\nginx`), the extents
20
+ * of its public armored blocks, and the first lines of its comma-separated blocks as they're found.
21
+ */
22
+ export class Doc {
23
+ text;
24
+ /** Where each line starts, and where it ends (before its break). */
25
+ starts = [0];
26
+ ends = [];
27
+ /** Public armored blocks: where each BEGIN is, in order, and where its block ends. */
28
+ armor;
29
+ /** Whether the text has a JSON Web Key in it, so a token's enclosing object is worth reading. */
30
+ hasJwk;
31
+ /** The first line of the comma-separated block each line walked is in, by line and cell count (`line:cells`). */
32
+ csvTops = new Map();
33
+ /** The cells of the text's SQL rows, by where their values start. */
34
+ cells;
35
+ cellStarts;
36
+ /** The last text read, so the rules that judge one text's spans one by one (policy.ts) read it once. */
37
+ static of = lastText((text) => new Doc(text));
38
+ constructor(text) {
39
+ this.text = text;
40
+ this.hasJwk = /\bkty\b/.test(text);
41
+ for (let i = 0; i < text.length; i++) {
42
+ const c = text.charCodeAt(i);
43
+ // (A line ends before the `\r` of a CRLF, real or escaped.)
44
+ if (c === 10)
45
+ this.lineBreak(text.charCodeAt(i - 1) === 13 ? i - 1 : i, i + 1);
46
+ else if (c === 92) {
47
+ // (A run of backslashes: only its last can start a `\n`.)
48
+ let j = i;
49
+ while (text.charCodeAt(j + 1) === 92)
50
+ j++;
51
+ if (isEscapedBreak(text, j)) {
52
+ this.lineBreak(text.startsWith("\\r", j - 2) && backslashesBefore(text, j - 2) % 2 === 0 ? j - 2 : j, j + 2);
53
+ i = j + 1;
54
+ }
55
+ else
56
+ i = j;
57
+ }
58
+ }
59
+ this.ends.push(text.length);
60
+ }
61
+ lineBreak(end, next) {
62
+ this.ends.push(end);
63
+ this.starts.push(next);
64
+ }
65
+ /** The index of the line `at` is on. */
66
+ lineOf(at) {
67
+ return lowerBound(this.starts, at + 1) - 1;
68
+ }
69
+ start(line) {
70
+ return this.starts[line];
71
+ }
72
+ end(line) {
73
+ return this.ends[line];
74
+ }
75
+ /** A line's text; empty for a line longer than LONG_LINE, which is no table row, blob line or sibling. */
76
+ line(i) {
77
+ if (i < 0 || i >= this.starts.length || this.ends[i] - this.starts[i] > LONG_LINE)
78
+ return "";
79
+ return this.text.slice(this.starts[i], this.ends[i]);
80
+ }
81
+ /**
82
+ * Whether `at` is inside a public armored block: a certificate, a public key, a PGP public block or signature, read
83
+ * as pem.ts reads a block (to its END, or where its material stops when output is cut short: `head -3 cert.pem`).
84
+ */
85
+ inPublicArmor(at) {
86
+ if (!this.armor) {
87
+ const blocks = publicBlocks(this.text);
88
+ this.armor = { begins: blocks.map((b) => b.start), ends: blocks.map((b) => b.end) };
89
+ }
90
+ const k = lowerBound(this.armor.begins, at + 1) - 1;
91
+ return k >= 0 && at < this.armor.ends[k];
92
+ }
93
+ /** The cell of an SQL `INSERT` row whose value holds `[start, end)`, read as sql.ts reads rows. */
94
+ sqlCell(start, end) {
95
+ if (!this.cells) {
96
+ // (Values in the order they appear, so their starts are sorted.)
97
+ this.cells = [...insertCells(this.text)].sort((a, b) => a.value.start - b.value.start);
98
+ this.cellStarts = this.cells.map((c) => c.value.start);
99
+ }
100
+ const cell = this.cells[lowerBound(this.cellStarts, start + 1) - 1];
101
+ return cell && end <= cell.value.end ? cell : undefined;
102
+ }
103
+ /**
104
+ * The first line of the comma-separated block that line `i` is in: the lines above it with as many cells, up to
105
+ * 2,000. Remembered for every line walked, so a block's rows cost one walk between them.
106
+ */
107
+ csvBlockTop(i, cells) {
108
+ const walked = [];
109
+ let top = i;
110
+ for (let n = 0; n < 2000 && top > 0; n++) {
111
+ const known = this.csvTops.get(`${top}:${cells}`);
112
+ if (known !== undefined) {
113
+ top = known;
114
+ break;
115
+ }
116
+ walked.push(top);
117
+ if (delimitedCells(this.line(top - 1), ",").length !== cells)
118
+ break;
119
+ top--;
120
+ }
121
+ for (const line of walked)
122
+ this.csvTops.set(`${line}:${cells}`, top);
123
+ return top;
124
+ }
125
+ }
126
+ /**
127
+ * Text with the words of its identifiers apart (`SMOKE_TOKEN` → `SMOKE TOKEN`, `stagingAdminToken` → `staging Admin
128
+ * Token`, `IDToken` → `ID Token`), so the `\b` in the line patterns below finds a word inside a name.
129
+ */
130
+ function identifierWords(text) {
131
+ return text.replace(/_|(?<=[a-z\d])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g, " ");
132
+ }
133
+ /** Line patterns, tested on identifierWords. */
134
+ const LINE_CREDENTIAL = /(?:api[-_ ]?key|apikey|\bsecret|\btoken|passw(?:or)?d|passphrase|credential|bearer|auth(?:orization)?\b|private[-_ ]key|access[-_ ]key|client[-_ ]secret|signing[-_ ]key|webhook|sk[_ ]live|\bpat\b|\bpwd\b)/i;
135
+ const LINE_HASH = /(?:\bsha(?:1|224|256|384|512)?\b|\bmd5\b|\bhash(?:es)?\b|\bdigest\b|checksum|integrity|\betag\b|\brevision\b|\brev\b|fingerprint|\bnonce\b|cursor|resource ?version|\btrace[-_ ]?id\b|\bspan[-_ ]?id\b|request[-_ ]?id|correlation[-_ ]?id|\bblob\b|\btree\b|\bparent\b|\buuid\b|\bguid\b|object[-_ ]?id|\bcid\b|\bsum\b|\bh1:|\bgo\.sum\b|lockfile|resolved|\btx(?:id|hash)?\b|block[-_ ]?hash|\baddress\b|\bkid\b|thumbprint|\bpublic[-_ ]key\b|\bpubkey\b|\bcommit(?:ted)?\b|\bmerge\b|\bcherry-pick\b|\bgit\b)/i;
136
+ /** Git's `HEAD`, as git writes it: "head" is a word (`head -c 16 /dev/urandom`). */
137
+ const LINE_HASH_CASED = /\bHEAD\b/;
138
+ /** The line patterns' hash words as the last before a token, and as the first after it (each pattern with its own case). */
139
+ const HASH_HEADS = [LINE_HASH, LINE_HASH_CASED].map((re) => ({
140
+ before: new RegExp(String.raw `(?:${re.source})[ \t:="'()#-]*$`, re.flags),
141
+ after: new RegExp(String.raw `^[ \t:="'(),-]*(?:${re.source})`, re.flags),
142
+ }));
143
+ const HANDOVER = /(?:\bhere(?:'s| is| are| you go| ya go| it is)|\b(?:token|key|secret|password|credentials?)(?: \w+){0,2} (?:is|was|created|generated|issued|:)|use (?:this|that)(?: one)?|(?:new|your|my|the) (?:api[- ]?)?(?:key|token|secret|password|pat)\b|copy (?:it|this)|paste (?:it|this)|save (?:it|this)|won'?t be shown again|only (?:be )?(?:shown|displayed) once|try (?:this|with))/i;
144
+ /** A name: identifier characters, dots, dashes, starting a word. */
145
+ const NAME_TAIL = String.raw `(?<![\p{L}\p{N}_.$@-])([\p{L}_$@][\p{L}\p{N}_.$@-]{0,80}?)`;
146
+ /**
147
+ * `name = "`, `name: `, `cfg["api_key"] = "`, `self.secret_key: str = "`, `key => '`, `Authorization: Bearer `: a name,
148
+ * its closing quote or subscript bracket, a type annotation, the operator, a scheme or keyword word, and the value's
149
+ * opening quote or bracket.
150
+ */
151
+ const ASSIGN = new RegExp(String.raw `${NAME_TAIL}(?:["'\x60]?\]|["'\x60])?(?:[ \t]*:[ \t]*[&*]?(?:'\w+[ \t]+)?[A-Za-z_][\w.<>\[\]|?, ]{0,40}?(?=[ \t]*=[^=>]))?[ \t]{0,40}(?:=|:=|:|=>|->|<-|\?\?=|\|\|=)[ \t]{0,40}(?:[!&*]?\w{0,10}[ \t]{1,4})?(?:(?:${STRING_PREFIX})?["'\x60]|\[[ \t]*["']?|\{[ \t]*["']?)?$`, "u");
152
+ const FLAG = /(?:^|[\s"'])(--?[A-Za-z][\w-]{0,60})(?:[ \t]{1,4}|=)["']?$/;
153
+ const QUERY = new RegExp(String.raw `${PARAM_START}(${PARAM_NAME})=$`);
154
+ const XML_TEXT = /<([\w:.-]{1,80})(?:\s[^<>]{0,200})?>$/;
155
+ const XML_ATTR = /\b([\w:.-]{1,80})=["']$/;
156
+ const CALL = new RegExp(String.raw `(?<![\w$.])([A-Za-z_$][\w$]{0,60}(?:\.[A-Za-z_$][\w$]{0,60}){0,4})[ \t]{0,2}\((?:[^()]{0,200}?,)?[ \t]{0,4}(?:[A-Za-z_]\w{0,30}[ \t]{0,2}=[ \t]{0,2})?(?:(?:${STRING_PREFIX})?["'\x60])?$`);
157
+ const PROSE = /\b([A-Za-z][\w-]{0,30})(?:[ \t]{1,3}(?:is|was|=|:)[ \t]{0,3}|:[ \t]{1,3}|[ \t]{1,3}(?:of|for)[ \t][^:]{0,40}:[ \t]{0,3})["'`*]{0,2}$/;
158
+ const TABLE = /^[ \t│|]{0,10}([A-Za-z_][\w.-]{0,80})(?:[ \t]{2,80}|[ \t]{0,80}[│|][ \t]{0,10})$/;
159
+ const HASH_PREFIX = /(?:^|[\s;])bh=$|pin-sha256="?$|(?<![A-Za-z0-9])(?:sha(?:1|224|256|384|512)|md5|blake2b?|blake3|crc32|h1|integrity|xxh\d*)[:=\-][ \t]?$|@sha256:$|\bsha(?:1|256|512)sum\b.{0,40}$|\b(?:commit|tree|parent|object|blob|merge)[ \t]$|\bcherry picked from commit $|\bindex $|(?:^|\n)Index: [^\s=:]{1,100}$/i;
160
+ /** The text before the last `name` in `left` (none when the name came from elsewhere, a sibling or a header). */
161
+ function textBefore(left, name) {
162
+ const at = left.lastIndexOf(name);
163
+ return at < 0 ? "" : left.slice(0, at);
164
+ }
165
+ /**
166
+ * A line of wrapped base64, its material the only token on it: `MIIBIjANBgkq…`, `"…\n"` in a JSON string, `+…` in a
167
+ * diff, the first and last lines of an element's body (`<X509Certificate>MIIC…`, `…</X509Certificate>`).
168
+ */
169
+ const BLOB_LINE = /^(?:[ \t"'>+-]{0,20}|[ \t]*(<)[\w:.-]{1,40}(?:[ \t][^<>]{0,200})?>)([A-Za-z0-9+/=]{16,120})(?:\\n)?(?:[ \t"',+]{0,4}|(<)\/[\w:.-]{1,40}>[ \t]*)$/;
170
+ /** The line widths base64 encoders wrap at. */
171
+ const ENCODER_WIDTHS = new Set([60, 64, 70, 72, 76, 80, 100, 120]);
172
+ /** Whether the token fills line `i`, a line of wrapped base64, with another such line above or below. */
173
+ function isBlobLine(doc, start, end, i) {
174
+ const line = doc.line(i);
175
+ // (The material's length; -1 for a line that isn't one. An element on one line, `<key>…</key>`, holds a value.)
176
+ const width = (l) => {
177
+ const m = BLOB_LINE.exec(l);
178
+ return m && !(m[1] && m[3]) ? m[2].length : -1;
179
+ };
180
+ const [w, pw, nw] = [width(line), width(doc.line(i - 1)), width(doc.line(i + 1))];
181
+ if (end - start < 40 || w < 40)
182
+ return false;
183
+ // A body's lines share an encoder's width (64, 76…), or three in a row share one; only the last is shorter.
184
+ // Keys listed one per line (`kubectl get secret … | base64 -d` per key, a handover) have their own lengths.
185
+ // (Three in a row: this line and two neighbours above, below or around it.)
186
+ const run = (pw === w && nw === w) || (pw === w && width(doc.line(i - 2)) === w) || (nw === w && width(doc.line(i + 2)) === w);
187
+ if (!((ENCODER_WIDTHS.has(w) && (pw === w || nw === w)) || (pw > 0 && ENCODER_WIDTHS.has(pw) && w < pw) || run))
188
+ return false;
189
+ // Items of a list under a credential's name (`api_keys:` then `- …` lines, `"keys": [` then `"…",`) are keys, not a body.
190
+ if (/^[ \t]*(?:- |["'])/.test(line)) {
191
+ const parent = listName(doc, i);
192
+ if (parent && nameClass(parent) === "credential")
193
+ return false;
194
+ }
195
+ return true;
196
+ }
197
+ /**
198
+ * The name the list that line `i` is an item of sits under (`api_keys:` above `- …` lines, `"keys": [` above `"…",`
199
+ * lines), read over its other items, blank lines and comments; undefined if none. (A line that only names: `Your API
200
+ * key:` above a token hands it over, which the handover rules read.)
201
+ */
202
+ function listName(doc, i) {
203
+ for (let k = i - 1; k >= 0 && k >= i - 200; k--) {
204
+ const above = doc.line(k);
205
+ if (/^[ \t]*(?:- |["'][A-Za-z0-9+/=_-]{16,}["'],?[ \t]*$|#|\/\/|$)/.test(above))
206
+ continue;
207
+ return /^[ \t]*(?:- |[{[][ \t]*)?["']?([\w.-]{1,80})["']?[ \t]*[:=][ \t]*\[?[ \t]*$/.exec(above)?.[1];
208
+ }
209
+ return undefined;
210
+ }
211
+ /**
212
+ * The cell of `header`, a `|` table's header row, over the cell `start` sits in on line `i`: by place, as a Markdown
213
+ * table's cells needn't line up under their headers. None for a token with no bar before it on its line.
214
+ */
215
+ function headerOver(header, doc, start, i) {
216
+ const row = doc.line(i);
217
+ if (!/\|/.test(row.slice(0, start - doc.start(i))))
218
+ return undefined;
219
+ return pipeCells(header)[pipeCells(row, doc.start(i)).findIndex((c) => c.end >= start)]?.text;
220
+ }
221
+ /** The header of the `|` table column `start` sits in on line `i`: `| Test name | Commit |` then `|---|---|` above its row. */
222
+ function pipeColumnAbove(doc, start, i) {
223
+ if (!/\|/.test(doc.line(i).slice(0, start - doc.start(i))) || start - doc.start(i) > 600)
224
+ return undefined;
225
+ for (let k = i - 1; k > 0 && k >= i - 40; k--) {
226
+ const line = doc.line(k);
227
+ if (PIPE_RULE.test(line))
228
+ return headerOver(doc.line(k - 1), doc, start, i)?.replace(/[*`]/g, "");
229
+ if (!/\|/.test(line))
230
+ return undefined;
231
+ }
232
+ return undefined;
233
+ }
234
+ /** The header of the column `start` sits in, if the lines above line `i` are a whitespace-aligned or bar-separated table. */
235
+ function columnAbove(doc, start, end, i) {
236
+ const col = start - doc.start(i);
237
+ for (let k = i - 1; k >= 0 && k >= i - 25; k--) {
238
+ const line = doc.line(k);
239
+ if (!line.trim())
240
+ return undefined;
241
+ // A header: names (one or two words each) separated by two or more spaces.
242
+ if (/^[ \t]*[A-Z][A-Z0-9_]*(?: [A-Z][A-Z0-9_]*)?(?:[ \t]{2,}[A-Z][A-Z0-9_]*(?: [A-Z][A-Z0-9_]*)?)+[ \t]*$/.test(line)) {
243
+ for (const m of line.matchAll(/[A-Z][A-Z0-9_]*(?: [A-Z][A-Z0-9_]*)?/g))
244
+ if (m.index <= col && col < m.index + Math.max(m[0].length, 4))
245
+ return m[0];
246
+ return undefined;
247
+ }
248
+ // Lower-case headers of four or more columns (`curl …/_cat/indices?v`): values sit under their header.
249
+ if (/^[ \t]*[a-z][a-z0-9_.]*(?:[ \t]+[a-z][a-z0-9_.]*){3,}[ \t]*$/.test(line)) {
250
+ for (const m of line.matchAll(/[a-z][a-z0-9_.]*/g))
251
+ if (m.index <= col && col < m.index + Math.max(m[0].length, 4))
252
+ return m[0];
253
+ return undefined;
254
+ }
255
+ // A pandas or R frame: the header sits flush with its column's right (or left) edge, ` id score` over `0 q3J9…= 0.91`.
256
+ if (/^[ \t]*[A-Za-z_][\w.]*(?:[ \t]{2,}[A-Za-z_][\w.]*)+[ \t]*$/.test(line)) {
257
+ for (const m of line.matchAll(/[A-Za-z_][\w.]*/g))
258
+ if (m.index === col || m.index + m[0].length === end - doc.start(i))
259
+ return m[0];
260
+ return undefined;
261
+ }
262
+ // A bar-separated header with no rule under it (`aws … --output table`): `| ClientId | ClientName |`. (The
263
+ // table's first row: a row of words under another row is data.)
264
+ if (/^[ \t]*\|(?:[ \t]*[A-Za-z][\w.-]{0,60}[ \t]*\|){2,}[ \t]*$/.test(line) &&
265
+ !(k > 0 && /^[ \t]*\|.*\|[ \t]*$/.test(doc.line(k - 1)) && !/^[ \t]*[+|][-+=|: ]*$/.test(doc.line(k - 1))))
266
+ return headerOver(line, doc, start, i);
267
+ }
268
+ return undefined;
269
+ }
270
+ /** Whether `at` is inside a PEM block of public material, or on an SSH public key line. */
271
+ function armoredAt(doc, at, left) {
272
+ return (doc.inPublicArmor(at) ||
273
+ /(?:^|\s|["'])(?:ssh-(?:rsa|dss|ed25519|ed448)|ecdsa-sha2-nistp\d{3}|sk-(?:ssh|ecdsa)[\w@.-]*)[ \t]+$/.test(left.slice(-200)));
274
+ }
275
+ /** The nearest line above line `i` that isn't blank, over up to two blank lines: a label's line ("Your token:" then a blank line). */
276
+ function lineAbove(doc, i) {
277
+ for (let k = i - 1; k >= 0 && k >= i - 3; k--) {
278
+ const end = doc.end(k);
279
+ const line = doc.text.slice(Math.max(doc.start(k), end - LEFT), end);
280
+ if (line.trim())
281
+ return line;
282
+ }
283
+ return "";
284
+ }
285
+ /**
286
+ * The name and syntax the text left of a token gives it: `api_key="`, `--token `, `?key=`, `<secret>`,
287
+ * `Client(`, a table column, `the key is `.
288
+ */
289
+ function nameFromLeft(left, url) {
290
+ let m;
291
+ if (url !== undefined) {
292
+ if ((m = QUERY.exec(url)))
293
+ return { name: m[1], syntax: "query" };
294
+ if (/\/$/.test(url))
295
+ return { name: "", syntax: "path" };
296
+ // (Outside a URL, a form body: `#key=` is a comment's assignment.)
297
+ }
298
+ else if ((m = QUERY.exec(left)) && m[0][0] !== "#")
299
+ return { name: m[1], syntax: "query" };
300
+ const trimmed = left.trimStart();
301
+ if (trimmed === "" || /^[-*•>+][ \t]*["'`]?$/.test(trimmed) || /^["'`]$/.test(trimmed))
302
+ return { name: "", syntax: /^[-*•]/.test(trimmed) ? "list" : "bare" };
303
+ // `Authorization: Bearer <token>`: the scheme word sits between name and value.
304
+ if ((m = /^[ \t]{0,8}([A-Za-z][\w-]{1,60}):[ \t]{1,4}(?:[A-Za-z][\w-]{0,20}[ \t]{1,3})?$/.exec(left)))
305
+ return { name: m[1], syntax: /^[A-Z]/.test(m[1]) || /-/.test(m[1]) ? "header" : "assign" };
306
+ if ((m = FLAG.exec(left)))
307
+ return { name: m[1].replace(/^-+/, ""), syntax: "flag" };
308
+ if ((m = XML_TEXT.exec(left)))
309
+ return { name: m[1], syntax: "xml" };
310
+ if ((m = XML_ATTR.exec(left)) && /<[\w:.-]+\s[^<>]*$/.test(left))
311
+ return { name: m[1], syntax: "xml" };
312
+ if ((m = ASSIGN.exec(left)))
313
+ return { name: m[1].replace(/^[$@]/, ""), syntax: "assign" };
314
+ // A pair as a tuple or a list, its name first: `('oauth_signature', '…')`, `["api_key", "…"]`. (Not a call's
315
+ // arguments: `algoliasearch('<app id>', '…')`.)
316
+ if ((m = /(?<![\w$.\])][ \t]*)[([][ \t]*["']([\w.-]{1,60})["'][ \t]*,[ \t]*["'`]$/.exec(left)))
317
+ return { name: m[1], syntax: "assign" };
318
+ // (SQL's `VALUES (` is no call: an INSERT's cell is named by its column, Doc.sqlCell.)
319
+ if ((m = CALL.exec(left)) && !/^values$/i.test(m[1]))
320
+ return { name: m[1], syntax: "call" };
321
+ if ((m = TABLE.exec(left)))
322
+ return { name: m[1], syntax: "assign" };
323
+ if ((m = PROSE.exec(left)))
324
+ return { name: m[1], syntax: "prose" };
325
+ // "(token 075d…)", "the key abc…": a credential word right before the value.
326
+ if ((m = /\b((?:api[ _-]?)?(?:key|token|secret|password|passwd|credential)s?)[ \t]{0,3}[(:]?[ \t]{0,3}["'`]?$/i.exec(left)))
327
+ return { name: m[1].replace(/[ -]/g, "_"), syntax: "prose" };
328
+ if (/(?:^|[\s(])["'`]$|,[ \t]*["'`]?$|\[[ \t]*["'`]?$/.test(left))
329
+ return { name: "", syntax: "list" };
330
+ return { name: "", syntax: "other" };
331
+ }
332
+ export function contextOf(doc, start, end) {
333
+ const text = doc.text;
334
+ const i = doc.lineOf(start);
335
+ const ls = Math.max(doc.start(i), start - LEFT);
336
+ const le = Math.min(doc.end(i), end + RIGHT);
337
+ const left = text.slice(ls, start);
338
+ const right = text.slice(end, le);
339
+ // The line above: for `- name: X` / `value:` pairs, and handover lines ("Your token:\n<token>").
340
+ const above = lineAbove(doc, i);
341
+ const quoted = /["'`]$/.test(left) || /^["'`]/.test(right);
342
+ // The URL the token is in, from its scheme on.
343
+ const urlStart = left.includes("://") ? urlAround(text, start, end, ls) : -1;
344
+ const inUrl = urlStart >= 0;
345
+ // (Its longest prefix, a line break and `Index: <path>`, is 108 characters.)
346
+ const tail = left.slice(-120);
347
+ const hashPrefix = HASH_PREFIX.test(tail) ||
348
+ HASH_PREFIX.test(tail.replace(/[+/=]+$/, "")) ||
349
+ /\bbase64,$/.test(tail) ||
350
+ /digest=["'][^"'\n]{0,20}["'][^>\n]{0,40}>$/.test(tail);
351
+ const fileish = /^\.(?:[a-z][a-z0-9]{0,5})(?:[^\w]|$)/i.test(right) && !/^\.(?:com|org|net|io|dev|app|co|ai)\b/i.test(right);
352
+ let { name, syntax } = nameFromLeft(left, inUrl ? text.slice(urlStart, start) : undefined);
353
+ // `{"name": "api_key", "value": "<token>"}`, and `env:` → `- name: API_KEY` → `value: <token>` with the name on the
354
+ // line above: a sibling attribute names the value. (The line above only for a value attribute's line or a token alone
355
+ // on its own: `id: get_token` over `uses: actions/create-github-app-token@<sha>` names no pin.)
356
+ if (!name || PAIR_VALUE.test(name))
357
+ name = siblingName(left) ?? (name || !left.trim() ? siblingName(above, { alone: true }) : undefined) ?? name;
358
+ // An item of a list under a name (`api_keys:` then `- <key>` lines): the list's name.
359
+ if (!name && /^[ \t]*(?:[-*•][ \t]*["'`]?|["'`])$/.test(left))
360
+ name = listName(doc, i) ?? "";
361
+ const fsPath = !inUrl &&
362
+ (/(?:^|[\s"'`=:(,\[])(?:~|\.{1,2}|[A-Za-z]:)?(?:[\\/][\w.@+-]{1,80}){1,30}[\\/]$/.test(left) ||
363
+ (/[\\/]$/.test(left) && /^[\\/][\w.-]/.test(right)));
364
+ // A wrapped line of a long string value (RFC examples, wrapped JSON): `"n": "0vx7…` above, the rest of it here.
365
+ // It belongs to that name, so the name's rules apply (a JWK's public member, a JWE's ciphertext).
366
+ if (!name && /^[ \t]*\\?$/.test(left)) {
367
+ for (let k = i - 1; k >= 0 && k >= i - 40; k--) {
368
+ const prev = doc.line(k);
369
+ // (A line ending in `=` padding has ended its value: the next line holds another.)
370
+ if (/=[ \t]*\\?[ \t]*$/.test(prev))
371
+ break;
372
+ const open = /"([\w-]{1,40})"[ \t]*:[ \t]*"[A-Za-z0-9_\-+/=.]{16,}\\?[ \t]*$/.exec(prev);
373
+ if (open) {
374
+ name = open[1];
375
+ syntax = "assign";
376
+ break;
377
+ }
378
+ if (!/^[ \t]*[A-Za-z0-9_\-+/=.]{8,}\\?[ \t]*$/.test(prev))
379
+ break;
380
+ }
381
+ }
382
+ // Prose naming what an ID is: "Created document …", "job …", "Instance …", "run …".
383
+ if (!name) {
384
+ const noun = /\b(document|doc|job|task|run|build|instance|record|row|object|entity|item|order|post|comment|file|image|deployment|release|version|commit|request|trace|span|event|message|prediction|experiment|workflow|pipeline|execution|invocation|batch|upload|attachment|asset|node|pod|container|volume|snapshot|backup|ticket|issue|case|session|account|user|customer|project|workspace|org|organization|team|tenant|app|application|client|device|sign-in|signin)[ \t:#]{1,3}["'`]?$/i.exec(left)?.[1];
385
+ if (noun) {
386
+ name = `${noun} id`; // (a space: `session id` here is a label, not a session's ID field)
387
+ syntax = "prose";
388
+ }
389
+ }
390
+ // A cell under a column header: `NAME DIGEST CREATED AT` above `DB_PASSWORD e17e… 2 days ago`. A Markdown
391
+ // table's header (over its rule) first: the table's rows above the token's would read as a bar-separated header.
392
+ const column = pipeColumnAbove(doc, start, i) ||
393
+ (/[ \t]{2,}\S*$|^\S*$|[│|][ \t]*$/.test(left) && start - ls < 300 ? columnAbove(doc, start, end, i) : undefined);
394
+ if (column &&
395
+ /^(?:digest|hash|sha|sha256|checksum|etag|id|uuid|guid|version|revision|commit|fingerprint|client[ _-]?id|app[ _-]?id|created|updated|name|[a-z]{1,8}[ _-]?id|[a-z]{0,8}uuid)$/i.test(column))
396
+ name = column.replace(/\s+/g, "_");
397
+ // A CSV cell: its column's header, from the first line of the block (`$id,name` above `xb6bxg56lral1qy9,Alyssa`).
398
+ const row = text.slice(ls, le);
399
+ // (The cells of the whole line, not of the window around the token.)
400
+ if (!name &&
401
+ !column &&
402
+ /,/.test(row) &&
403
+ /(?:^|,)[ \t]*["']?$/.test(left) &&
404
+ /^["']?[ \t]*(?:,|$)/.test(right) &&
405
+ doc.line(i)) {
406
+ const cells = delimitedCells(doc.line(i), ",", doc.start(i));
407
+ const index = cells.findIndex((c) => c.end >= start);
408
+ const top = doc.csvBlockTop(i, cells.length);
409
+ const header = top < i
410
+ ? delimitedCells(doc.line(top), ",")[index]?.text.trim()
411
+ .replace(/^["'$]+|["']+$/g, "")
412
+ : undefined;
413
+ // (Not a run of digits, a first row of values read as the header; but a digest's name, `sha256`, `sha3_512`.)
414
+ if (header && /^[A-Za-z_][\w ]{0,40}$/.test(header) && (!/\d{3}/.test(header) || /^sha(?:3_)?\d{3}$/i.test(header)))
415
+ name = header.replace(/\s+/g, "_");
416
+ }
417
+ // A value in an SQL row: its column (`INSERT INTO uploads (id, name) VALUES ('<id>', …)`), over what the row's own
418
+ // text reads as a name (`VALUES (` as a call, the cell before as a pair). A token inside a value keeps a name the
419
+ // value gives it (`'{"api_key": "…"}'`).
420
+ const sqlCell = doc.sqlCell(start, end);
421
+ if (sqlCell) {
422
+ const value = sqlCell.value.literal ?? sqlCell.value;
423
+ if (!name || (start <= value.start && end >= value.end))
424
+ name = sqlCell.column;
425
+ }
426
+ // `checksum/secret:`, `annotations.checksum/config:`: the path before a name is part of it.
427
+ if (name) {
428
+ const path = /([\w.-]{1,60})\/$/.exec(textBefore(left, name))?.[1];
429
+ if (path)
430
+ name = `${path}/${name}`;
431
+ }
432
+ let cls = nameClass(name);
433
+ // "public key: …", "next page token: …", "the search-only key (…)", "cache warm key=": the words before a bare
434
+ // generic name can change what it means (names.ts readPhrase).
435
+ if (name && (isGenericName(name) || /^(?:id|hash)$/i.test(name))) {
436
+ const before = phraseBefore(textBefore(left, name), 4);
437
+ ({ name, class: cls } = readPhrase(before, name));
438
+ }
439
+ const pathParent = inUrl ? (/\/([^/\s?#]{1,60})\/$/.exec(left)?.[1] ?? "") : "";
440
+ const leftAndAbove = identifierWords(`${above}\n${left}`);
441
+ return {
442
+ name,
443
+ nameClass: cls,
444
+ syntax,
445
+ quoted,
446
+ hashPrefix,
447
+ fileish,
448
+ lineCredential: LINE_CREDENTIAL.test(leftAndAbove) || (name !== "" && mentionsCredential(name)),
449
+ lineHash: [leftAndAbove, identifierWords(right.slice(0, 60))].some((t) => LINE_HASH.test(t) || LINE_HASH_CASED.test(t)),
450
+ hashPhrase: HASH_HEADS.some(({ before, after }) => before.test(identifierWords((left.trim() ? left : above).slice(-80))) ||
451
+ after.test(identifierWords(right.slice(0, 40)))),
452
+ pathParent,
453
+ sqlCell,
454
+ handover: HANDOVER.test(identifierWords(left.slice(-200))) ||
455
+ (left.trim() === "" && HANDOVER.test(identifierWords(above.slice(-200)))),
456
+ inUrl,
457
+ fsPath,
458
+ armored: armoredAt(doc, start, left),
459
+ labelsBlock: /^[ \t]*[\w.-]{1,60}[ \t]*[=:][ \t]*["']?$/.test(left) &&
460
+ /^["']?[ \t]*\r?$/.test(text.slice(end, doc.end(i))) &&
461
+ /^\r?\n?-----BEGIN [A-Z0-9 ]{3,40}-----/.test(text.slice(doc.end(i), doc.end(i) + 60)),
462
+ blobLine: isBlobLine(doc, start, end, i),
463
+ jwk: doc.hasJwk && inJwk(text, start, end),
464
+ shellArg: /^[ \t]*(?:\d{4}-\d\d-\d\dT[\d:.]+Z?[ \t]+)?(?:[$#>+][ \t]+|(?:sudo[ \t]+)?(?:\.{0,2}\/[\w.-][\w./-]*|[a-z][\w-]*(?:\.(?:sh|py|js|rb|ts|pl))?)[ \t]+)(?:[^\s"'|;&]+[ \t]+){0,12}["']?$/.test(left),
465
+ left: left.slice(-120),
466
+ right: right.slice(0, 120),
467
+ near: text.slice(Math.max(0, start - 300), Math.min(text.length, end + 100)),
468
+ };
469
+ }
@@ -0,0 +1,11 @@
1
+ import type { Finding } from "./types.ts";
2
+ /**
3
+ * Base64 blobs whose decoded text holds a secret (`inner` decides), on one line or wrapped over several; and a line
4
+ * that is only base64 of one generated-looking token (`echo "$KEY" | base64`).
5
+ */
6
+ export declare function findEncoded(text: string, inner: (decoded: string) => boolean): Finding[];
7
+ /**
8
+ * Hex dumps (`xxd`, `xxd -p`, `hexdump -C`, `od -A x -t x1`): the bytes are decoded, and a dump whose
9
+ * contents hold a secret is redacted whole.
10
+ */
11
+ export declare function findHexDumps(text: string, inner: (decoded: string) => boolean): Finding[];