redac---n 0.0.4 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -10
- package/dist/calls.d.ts +8 -0
- package/dist/calls.js +60 -3
- package/dist/checksums.js +4 -3
- package/dist/commands.d.ts +5 -0
- package/dist/commands.js +93 -14
- package/dist/context.d.ts +5 -2
- package/dist/context.js +36 -9
- package/dist/encoded.js +13 -6
- package/dist/environment.d.ts +5 -3
- package/dist/environment.js +38 -14
- package/dist/formats.js +12 -0
- package/dist/index.js +10 -4
- package/dist/json.js +29 -5
- package/dist/known.d.ts +7 -7
- package/dist/known.js +33 -25
- package/dist/named.d.ts +6 -0
- package/dist/named.js +421 -125
- package/dist/names.d.ts +5 -1
- package/dist/names.js +143 -18
- package/dist/normalize.d.ts +4 -1
- package/dist/normalize.js +37 -11
- package/dist/pem.d.ts +2 -0
- package/dist/pem.js +12 -0
- package/dist/policy.js +12 -5
- package/dist/public.js +35 -13
- package/dist/secretfile.d.ts +5 -0
- package/dist/secretfile.js +49 -20
- package/dist/shell.d.ts +12 -3
- package/dist/shell.js +51 -7
- package/dist/spans.d.ts +4 -3
- package/dist/spans.js +79 -8
- package/dist/sql.d.ts +12 -8
- package/dist/sql.js +94 -16
- package/dist/stream.d.ts +2 -1
- package/dist/stream.js +15 -3
- package/dist/structure.js +210 -61
- package/dist/syntax.d.ts +5 -2
- package/dist/syntax.js +7 -4
- package/dist/transcript.js +116 -28
- package/dist/unnamed.js +128 -20
- package/dist/urls.d.ts +8 -3
- package/dist/urls.js +16 -7
- package/dist/values.d.ts +2 -1
- package/dist/values.js +70 -25
- package/docs/holdout.svg +8 -8
- package/docs/layers.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,15 +32,15 @@ came from, and see what would be sent. The library runs in the page, so nothing
|
|
|
32
32
|
|
|
33
33
|

|
|
34
34
|
|
|
35
|
-
On a held-out set of realistic agent output (1,112 cases, never used for tuning), redac---n catches **
|
|
36
|
-
secrets and destroys **
|
|
35
|
+
On a held-out set of realistic agent output (1,112 cases, never used for tuning), redac---n catches **92.6%** of
|
|
36
|
+
secrets and destroys **2.9%** of the values that look like secrets but aren't. The best combination of existing
|
|
37
37
|
scanners we found catches 70.6% and destroys 2.2%; all seven together catch 84.2% but destroy 28.6% of the
|
|
38
38
|
look-alikes.
|
|
39
39
|
|
|
40
40
|
| | secrets caught | look-alikes destroyed |
|
|
41
41
|
|---|---:|---:|
|
|
42
|
-
| **redac---n** (default) | **
|
|
43
|
-
| redac---n, `guessing: "off"` | 90.
|
|
42
|
+
| **redac---n** (default) | **92.6%** | **2.9%** |
|
|
43
|
+
| redac---n, `guessing: "off"` | 90.5% | 0.9% |
|
|
44
44
|
| gitleaks + Betterleaks + Kingfisher | 70.6% | 2.2% |
|
|
45
45
|
| all seven scanners combined | 84.2% | 28.6% |
|
|
46
46
|
| CredSweeper, the best single scanner | 70.1% | 15.2% |
|
|
@@ -81,7 +81,7 @@ and then became development data. Secrets caught / look-alikes destroyed at that
|
|
|
81
81
|
|
|
82
82
|
| set | redac---n | `guessing: "off"` | gitleaks + Betterleaks + Kingfisher | best single other scanner |
|
|
83
83
|
|---|---:|---:|---:|---:|
|
|
84
|
-
| Held-out 1: 1,112 cases over 104 kinds of output |
|
|
84
|
+
| Held-out 1: 1,112 cases over 104 kinds of output | 92.6 / 2.9 | 90.5 / 0.9 | 70.6 / 2.2 | CredSweeper 70.1 / 15.2 |
|
|
85
85
|
| Held-out 2: 848 cases from blind template writers | 86.8 / 5.7 | 78.3 / 3.7 | 60.3 / 5.1 | CredSweeper 65.7 / 20.1 |
|
|
86
86
|
| Held-out 3: 450 cases, mobile, ML, IoT, enterprise stacks, other CI vendors | 85.5 / 7.4 | 78.5 / 3.2 | 64.0 / 4.8 | CredSweeper 69.4 / 21.0 |
|
|
87
87
|
| Held-out 4: 478 everyday agent sessions, issue and PR threads | 90.4 / 1.9 | 86.7 / 0.7 | 66.2 / 3.6 | CredSweeper 74.1 / 20.4 |
|
|
@@ -89,6 +89,7 @@ and then became development data. Secrets caught / look-alikes destroyed at that
|
|
|
89
89
|
| Held-out 6: 112 cases from regional clouds and payment APIs | 93.2 / 9.3 | 78.9 / 5.8 | 61.7 / 3.1 | CredSweeper 73.7 / 18.1 |
|
|
90
90
|
| Held-out 7: 305 cases from databases, queues, observability, build tooling | 93.4 / 8.5 | 85.1 / 3.8 | 69.6 / 3.1 | CredSweeper 67.5 / 19.8 |
|
|
91
91
|
| Held-out 8: 261 cases from data and analytics tooling | 89.0 / 1.5 | 83.7 / 0.8 | 67.9 / 1.8 | CredSweeper 72.4 / 16.7 |
|
|
92
|
+
| Held-out 9: 300 cases from the AI and agent ecosystem, JVM and .NET business apps, and SaaS integrations | 94.0 / 7.9 | 89.8 / 4.0 | not run | not run |
|
|
92
93
|
| [SlotGuard](https://github.com/illinoisdata/SlotGuard): 200 agent sessions, 852 planted values (652 credentials, and 200 email addresses this library leaves alone) | 62.6 / 0.0 | 53.1 / 0.0 | 29.2 / 0.0 | CredSweeper 46.9 / 0.0 |
|
|
93
94
|
| [ProwlBench](https://github.com/Lercas/prowlbench): 7,491 secrets in code, tickets, logs and chat in eight languages; 7,375 "secret-free" snippets, some with values under credential names that this library hides by design | 89.4 / 12.5 | 78.6 / 7.8 | 59.1 / 8.7 | detect-secrets 63.8 / 19.1 |
|
|
94
95
|
|
|
@@ -96,9 +97,9 @@ Other measurements; where a set was first scored blind, that score comes first:
|
|
|
96
97
|
|
|
97
98
|
| set | result |
|
|
98
99
|
|---|---|
|
|
99
|
-
| Development sets (4,
|
|
100
|
-
| [CredData](https://github.com/Samsung/CredData) test split (real lines from public repositories) | 82.8% caught and 3.2% of non-secret lines hit, blind; now 83.
|
|
101
|
-
|
|
|
100
|
+
| Development sets (4,550 cases, 6,942 secrets, 18,932 look-alikes; held-out 2–9 among them) | 95.6% caught, 1.4% destroyed; 95.5% and 1.4% when each case is wrapped in an Anthropic or OpenAI transcript and passed to `redactValue` |
|
|
101
|
+
| [CredData](https://github.com/Samsung/CredData) test split (real lines from public repositories) | 82.8% caught and 3.2% of non-secret lines hit, blind; now 83.9% and 3.5%. On the dev split it was tuned on, 91.4% and 1.8%: the gap shows how much of the tuning doesn't carry over to new repositories |
|
|
102
|
+
| Six more ordinary-text sets (new repositories, trajectory shards, donated agent sessions from [Trace Commons](https://huggingface.co/datasets/trace-commons/agent-traces), agent pull requests from [AIDev](https://huggingface.co/datasets/hao-li/AIDev), repositories that handle credentials) | 5.5, 4.1, 5.6, 22.5, 17.0 and 4.8 redactions per million characters, blind (the last two hold AWS signatures in presigned URLs and recorded OAuth test runs with real tokens) |
|
|
102
103
|
| [GitLab's secret-detection rules](https://gitlab.com/gitlab-org/security-products/secret-detection/secret-detection-rules), each rule's examples | 81.4% of 2,038 caught and 23% of 168 negatives hit, blind; now 88.0% and 15% |
|
|
103
104
|
| Credential-handling code with no credentials ([ICSME 2026](https://doi.org/10.5281/zenodo.22942243), 5,903 snippets) | 4.6% of snippets hit, blind; now 2.0%, mostly passwords written into the code |
|
|
104
105
|
| [levainbot/fp-corpus](https://github.com/levainbot/fp-corpus): log and build output, 85 planted values | 79 caught and 3 of 116 clean sections hit, blind; now 81 (the four missed: an email address, two card numbers and one key) and 4 of 116 |
|
|
@@ -229,7 +230,7 @@ measures what each adds and what each costs.
|
|
|
229
230
|
|
|
230
231
|
1. **Values you gave it**, in every encoding output turns them into: JSON-, URL-, shell-, SQL- and HTML-escaped,
|
|
231
232
|
hex, base64 at any offset, and cut short.
|
|
232
|
-
2. **
|
|
233
|
+
2. **542 vendor token formats**: 143 maintained here and 399 from
|
|
233
234
|
[Betterleaks](https://github.com/betterleaks/betterleaks)' catalog (some vendors are in both).
|
|
234
235
|
3. **Structure:** private keys (by their DER, under any PEM label), URL passwords, Authorization headers, cookies,
|
|
235
236
|
connection strings, signed URLs, Kubernetes Secrets, CI masks, sign-in links, `.netrc`, and passwords given to
|
|
@@ -296,7 +297,7 @@ it as a `known` value: known values are hidden whatever they look like, as long
|
|
|
296
297
|
|
|
297
298
|
- **Behaviour tests** (`test/`, by topic): a few cases per rule or decision, cross-products of credential names,
|
|
298
299
|
syntaxes and value shapes, and checks that long and repetitive inputs take linear time.
|
|
299
|
-
- **The quality gate** (`pnpm check`) scores every development set and the
|
|
300
|
+
- **The quality gate** (`pnpm check`) scores every development set and the 138 regression sets
|
|
300
301
|
(`eval/corpora/regress/README.md`: probes of each rule's edges) against a committed baseline
|
|
301
302
|
(`eval/baseline.json.gz`), and fails on any case that newly leaks, is newly destroyed or is newly slow.
|
|
302
303
|
`scripts/snap.ts` does the same with a sample of ordinary text from the external corpora added.
|
package/dist/calls.d.ts
CHANGED
|
@@ -53,6 +53,14 @@ export interface LiteralHead {
|
|
|
53
53
|
export declare function literalHead(text: string, at: number, { keyword }?: {
|
|
54
54
|
keyword?: boolean | undefined;
|
|
55
55
|
}): LiteralHead | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* The literal a converter at `at` is given, the value it makes (CONVERTER), and where the converter's call closes.
|
|
58
|
+
* (Not a lookup's argument, a secret's name: Prefect's `Secret.load("db-password")`, `SecretStr.get(…)`; nor an
|
|
59
|
+
* encoding's name.)
|
|
60
|
+
*/
|
|
61
|
+
export declare function convertedLiteral(text: string, at: number): (Literal & {
|
|
62
|
+
close: number;
|
|
63
|
+
}) | undefined;
|
|
56
64
|
/** The string literal starting at `at` (after blanks), read as an argument would be: `"…"`, `b'…'`, `Some("…")`. */
|
|
57
65
|
export declare function literalAt(text: string, at: number): Literal | undefined;
|
|
58
66
|
export interface Call {
|
package/dist/calls.js
CHANGED
|
@@ -67,6 +67,9 @@ function matchBrackets(text, slashComments) {
|
|
|
67
67
|
else if (c === "'" && isContraction(text, i)) {
|
|
68
68
|
// (Prose's apostrophe, `don't connect(…) doesn't`, opens nothing.)
|
|
69
69
|
}
|
|
70
|
+
else if (c === "`" && text.startsWith("``", i + 1)) {
|
|
71
|
+
i = fenceEnd(text, i);
|
|
72
|
+
}
|
|
70
73
|
else if (c === '"' || c === "'" || c === "`") {
|
|
71
74
|
// (A string that doesn't close ends every call around it: none of them is closed.)
|
|
72
75
|
const stop = literalEnd(text, i, { max: MAX_CALL });
|
|
@@ -88,6 +91,16 @@ function matchBrackets(text, slashComments) {
|
|
|
88
91
|
}
|
|
89
92
|
return close;
|
|
90
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* The last backtick of the run of three or more that starts at `at`: a Markdown code fence (or inline code's), which opens
|
|
96
|
+
* no template literal. Read as one, a fence would hide everything up to the next one, the code it shows included.
|
|
97
|
+
*/
|
|
98
|
+
function fenceEnd(text, at) {
|
|
99
|
+
let i = at;
|
|
100
|
+
while (text[i + 1] === "`")
|
|
101
|
+
i++;
|
|
102
|
+
return i;
|
|
103
|
+
}
|
|
91
104
|
/** The arguments between `at` and the bracket at `close` that closes the call: separated by commas outside brackets and strings. */
|
|
92
105
|
function splitArguments(text, at, close, slashComments) {
|
|
93
106
|
const args = [];
|
|
@@ -222,10 +235,17 @@ export function literalEnd(text, at, { max = MAX_VALUE, shell = false } = {}) {
|
|
|
222
235
|
*/
|
|
223
236
|
const LITERAL_HEAD = new RegExp(String.raw `^(?:([A-Za-z_$][\w$]*)[ \t]*(?:=(?![=>])|:(?!:))[ \t]*)?((?:Some|String::from|SecretStr|Secret)\([ \t]*)?(${STRING_PREFIX})?(["'\x60])`);
|
|
224
237
|
/**
|
|
225
|
-
*
|
|
226
|
-
* (`.
|
|
238
|
+
* A method that converts the value it is called on and takes no other: `.to_string()`, `.encode("utf-8")`,
|
|
239
|
+
* `.getBytes(StandardCharsets.UTF_8)`, `.toCharArray()`, `.trim()`. Its argument, if any, is a constant (an encoding,
|
|
240
|
+
* a number, a name in capitals or with a dot), not a variable a method would combine the value with: `.concat(x)`,
|
|
241
|
+
* `", ".join(parts)`.
|
|
242
|
+
*/
|
|
243
|
+
const CONVERSION = String.raw `\.[A-Za-z_]\w{0,30}\([ \t]*(?:"[\w.-]{0,20}"|'[\w.-]{0,20}'|\d{1,5}|[A-Z][A-Z0-9_]{1,30}|[A-Za-z_]\w{0,30}(?:(?:\.|::)\w{1,30}){1,3})?[ \t]*\)`;
|
|
244
|
+
/**
|
|
245
|
+
* What may follow a literal and leave it the value: a Rust raw string's closing `#`s, the wrapper's `)`, and up to
|
|
246
|
+
* three conversions (CONVERSION).
|
|
227
247
|
*/
|
|
228
|
-
const LITERAL_TAIL =
|
|
248
|
+
const LITERAL_TAIL = new RegExp(String.raw `^#*(?:[ \t]*\))?(?:CONVERSION){0,3}[ \t]*$`.replace("CONVERSION", CONVERSION));
|
|
229
249
|
const KEYWORD = /^([A-Za-z_$][\w$]*)[ \t]*(?:=(?![=>])|:(?!:))/;
|
|
230
250
|
/** The literal head starting at `at`, or undefined. With `keyword`, a keyword before it is read too. */
|
|
231
251
|
export function literalHead(text, at, { keyword = false } = {}) {
|
|
@@ -250,8 +270,45 @@ function argument(text, start, end) {
|
|
|
250
270
|
const lit = head && literalOf(text, head);
|
|
251
271
|
if (lit && LITERAL_TAIL.test(text.slice(lit.end + 1, end)))
|
|
252
272
|
arg.literal = lit;
|
|
273
|
+
else if (!head) {
|
|
274
|
+
// A converter given the literal, the value it makes: `Buffer.from("…", "utf8")`, `Secret::new("…")`.
|
|
275
|
+
const converted = convertedLiteral(text, start + (keyword?.[0].length ?? 0));
|
|
276
|
+
if (converted && converted.close < end && LITERAL_TAIL.test(text.slice(converted.close + 1, end)))
|
|
277
|
+
arg.literal = converted;
|
|
278
|
+
}
|
|
253
279
|
return arg;
|
|
254
280
|
}
|
|
281
|
+
/** An encoding's name, which a converter takes beside the value it converts: `Buffer.from(key, "base64")`. */
|
|
282
|
+
const ENCODING = /^(?:utf-?8|utf-?16(?:le|be)?|latin-?1|ascii|iso-?8859-?\d+|cp\d+|base64|hex|binary|ucs-?2)$/i;
|
|
283
|
+
/**
|
|
284
|
+
* A converter's callee, up to its `(`: one that makes a value of the string it is given, `hexStringToUint8Array(`,
|
|
285
|
+
* `bytes.fromhex(`, `Buffer.from(`, `new TextEncoder().encode(`, `Secret::new(`, `SecretString::from(`, Go's `[]byte(`;
|
|
286
|
+
* or a digest that is given a password to hash, `MD5(`, `crypt(`, `hashlib.sha256(`, whose input is the password still.
|
|
287
|
+
*/
|
|
288
|
+
const DIGEST = /^(?:[A-Za-z_]\w{0,30}(?:\.|::))?(?:md5|sha(?:1|2|224|256|384|512)?|crypt|digest|hashpw)[ \t]*\(/i;
|
|
289
|
+
const CONVERTER = /^(?:new[ \t]+)?(?:\[\])?(?:[\w.]|::|\(\)){0,60}(?:hex|Hex|b64|base64|Base64|decode|Decode|fromhex|unhexlify|Buffer\.from|SecretStr|Secret|SecureString|getBytes|encode|bytes?|String|str|Uint8Array|parse|Parse)(?:[\w.]|::){0,40}\(/;
|
|
290
|
+
/**
|
|
291
|
+
* The literal a converter at `at` is given, the value it makes (CONVERTER), and where the converter's call closes.
|
|
292
|
+
* (Not a lookup's argument, a secret's name: Prefect's `Secret.load("db-password")`, `SecretStr.get(…)`; nor an
|
|
293
|
+
* encoding's name.)
|
|
294
|
+
*/
|
|
295
|
+
export function convertedLiteral(text, at) {
|
|
296
|
+
const head = text.slice(at, at + 120);
|
|
297
|
+
const converter = CONVERTER.exec(head) ?? DIGEST.exec(head);
|
|
298
|
+
if (!converter ||
|
|
299
|
+
/(?:^(?:new[ \t]+)?|\.|::)(?:load|get|fetch|read|lookup|retrieve|resolve|access)\w*\($/i.test(converter[0]))
|
|
300
|
+
return undefined;
|
|
301
|
+
const open = at + converter[0].length;
|
|
302
|
+
// (It is given the value alone, or with an encoding or a constant: a constructor given two strings,
|
|
303
|
+
// `new ClientSecretKeyVaultCredential("<client id>", "<secret>")`, makes no value of the first.)
|
|
304
|
+
const args = callArguments(text, open);
|
|
305
|
+
const literal = args?.[0]?.literal;
|
|
306
|
+
if (args?.slice(1).some((a) => a.literal && !ENCODING.test(a.literal.value)))
|
|
307
|
+
return undefined;
|
|
308
|
+
return literal && literal.value.length >= 4 && !ENCODING.test(literal.value)
|
|
309
|
+
? { ...literal, close: closingBracket(text, open) }
|
|
310
|
+
: undefined;
|
|
311
|
+
}
|
|
255
312
|
/** The string literal starting at `at` (after blanks), read as an argument would be: `"…"`, `b'…'`, `Some("…")`. */
|
|
256
313
|
export function literalAt(text, at) {
|
|
257
314
|
const start = at + /^[ \t]*/.exec(text.slice(at, at + 200))[0].length;
|
package/dist/checksums.js
CHANGED
|
@@ -40,10 +40,11 @@ function base58Check(t, alphabet) {
|
|
|
40
40
|
return check.slice(0, 4).every((b, i) => b === bytes[payload.length + i]) ? payload : undefined;
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
-
* The human-readable parts that name a secret, not an address: Nostr's `nsec` and `ncryptsec`,
|
|
44
|
-
*
|
|
43
|
+
* The human-readable parts that name a secret, not an address: Nostr's `nsec` and `ncryptsec`, the `sk` and `sec` that
|
|
44
|
+
* chains and tools give a private key (`sk1…`), and any that says private, secret or seed (`priv1…`, Sui's
|
|
45
|
+
* `suiprivkey1…`), whose checksum would verify as well.
|
|
45
46
|
*/
|
|
46
|
-
const SECRET_HRP = /^(?:nsec|ncryptsec|sk|sec|
|
|
47
|
+
const SECRET_HRP = /^(?:nsec|ncryptsec|sk|sec)$|priv|secret|seed/;
|
|
47
48
|
/** A bech32 or bech32m address (`bc1…`, `cosmos1…`, `npub1…`) whose checksum verifies; never a secret's prefix (`nsec1`). All lower case or all upper case (BIP-173); not mixed. */
|
|
48
49
|
export function isBech32Address(address) {
|
|
49
50
|
const t = address === address.toUpperCase() ? address.toLowerCase() : address;
|
package/dist/commands.d.ts
CHANGED
|
@@ -35,6 +35,11 @@ export declare function printsSecretFile(command: string): boolean;
|
|
|
35
35
|
* holds a value only where the cut didn't happen, a `NAME=value` line in it.
|
|
36
36
|
*/
|
|
37
37
|
export declare function printsSecretFileNames(command: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Whether what a command prints goes into the next command's input: by its own `|`, or by a group's around it, whatever
|
|
40
|
+
* follows it inside the group (`{ echo A=1; echo B=2; } | tee .env`).
|
|
41
|
+
*/
|
|
42
|
+
export declare function pipes(c: ShellCommand): boolean;
|
|
38
43
|
/** Whether a command prints a credential: any of its pipelines (`openssl rand -hex 32 && echo ok`) whose stage that makes one reaches its output. */
|
|
39
44
|
export declare function printsSecret(command: string): boolean;
|
|
40
45
|
/** How a command-line tool takes a password. */
|
package/dist/commands.js
CHANGED
|
@@ -64,7 +64,7 @@ export function holdsOnlySecrets(path) {
|
|
|
64
64
|
(/secret|credential|^auth\.json$|^\.(?:netrc|_netrc|pgpass|vault-token)$/i.test(path.slice(path.lastIndexOf("/") + 1)) ||
|
|
65
65
|
/(^|\/)\.?secrets\//.test(path)));
|
|
66
66
|
}
|
|
67
|
-
const CONTENT_COMMANDS = /^(cat|tac|head|tail|less|more|bat|batcat|nl|sed|awk|grep|egrep|fgrep|zgrep|rg|ag|ack|Select-String|sls|cut|sort|uniq|strings|xxd|od|base64|tr|column|jq|yq|type|Get-Content|gc|diff|sops|op|dotenv|printenv|env|set|export|declare)$/;
|
|
67
|
+
const CONTENT_COMMANDS = /^(cat|tac|head|tail|less|more|bat|batcat|nl|sed|awk|grep|egrep|fgrep|zgrep|rg|ag|ack|Select-String|sls|cut|sort|uniq|strings|xxd|od|base64|tr|column|jq|yq|type|Get-Content|gc|findstr|diff|sops|op|dotenv|printenv|env|set|export|declare)$/;
|
|
68
68
|
/**
|
|
69
69
|
* Commands whose whole output is a credential, by tool: each read from the program on (a commit message that mentions
|
|
70
70
|
* `gh auth token` isn't one).
|
|
@@ -112,6 +112,8 @@ const SECRET_TOOLS = new RegExp(`^(?:${[
|
|
|
112
112
|
/secret-tool\s+lookup\b/,
|
|
113
113
|
/lpass\s+show\b.*--password\b/,
|
|
114
114
|
/keepassxc-cli\s+show\b.*(?:-a|--attributes)\s+Password\b/,
|
|
115
|
+
// (PowerShell's SecretManagement; without `-AsPlainText` it prints `System.Security.SecureString`.)
|
|
116
|
+
/Get-Secret\b.*-AsPlainText\b/,
|
|
115
117
|
// Generators.
|
|
116
118
|
/openssl\s+rand\b/,
|
|
117
119
|
/pwgen\b/,
|
|
@@ -123,16 +125,22 @@ const SECRET_TOOLS = new RegExp(`^(?:${[
|
|
|
123
125
|
.map((r) => r.source)
|
|
124
126
|
.join("|")})`);
|
|
125
127
|
/** What a command reads or calls that is a secret, wherever it sits in the command: a random device, a language's generator. */
|
|
126
|
-
const SECRET_SOURCES = /\/dev\/u?random\b|get_random_secret_key|secrets\.token_(?:hex|urlsafe|bytes)|SecureRandom\.(?:hex|base64|urlsafe_base64)|crypto
|
|
128
|
+
const SECRET_SOURCES = /\/dev\/u?random\b|get_random_secret_key|secrets\.token_(?:hex|urlsafe|bytes)|SecureRandom\.(?:hex|base64|urlsafe_base64)|crypto(?:["']\))?\.randomBytes/;
|
|
129
|
+
/**
|
|
130
|
+
* Byte generators that make IDs, salts and test data as often as keys (`user = os.urandom(8).hex()`, a patch that
|
|
131
|
+
* changes `os.urandom(3)`): a secret only printed as made, `print(os.urandom(32).hex())`, `echo
|
|
132
|
+
* bin2hex(random_bytes(32));`.
|
|
133
|
+
*/
|
|
134
|
+
const PRINTED_SOURCE = /\b(?:print|puts|echo)\b[ \t(]*(?:[\w.]+\()*(?:os\.urandom\(|random_bytes\(|openssl_random_pseudo_bytes\()/;
|
|
127
135
|
/** A shell's name: `bash`, `sh`, `zsh`. */
|
|
128
136
|
export const SHELL = /^(?:ba|z|da|k)?sh$/;
|
|
129
137
|
/** Commands whose whole output is a set of secrets, read like a secret file; each read from the program on. */
|
|
130
138
|
const SECRET_DUMPS = new RegExp(String.raw `^(?:\bdoppler\s+secrets(?:\s+download)?\b(?![^\n]*\b(?:set|delete)\b)|\bsops\s+(?:-d|--decrypt)\b|\bansible-vault\s+(?:view|decrypt)\b|\b(?:bin\/)?rails\s+credentials:show\b|\binfisical\s+export\b|\bchamber\s+(?:export|read)\b|\bheroku\s+config(?:\s|$)(?!:)|\bvault\s+kv\s+get\b|\bop\s+inject\b|\baws\s+sts\s+(?:get-session-token|assume-role\S*|get-federation-token)\b|\baws\s+configure\s+export-credentials\b|\baz\s+ad\s+sp\s+(?:create-for-rbac|credential\s+reset)\b|\bkubectl\s+config\s+view\b[^\n]*--raw\b|\bterraform\s+output\s+-json\b|\bgit\s+credential\s+fill\b|\b(?:gh|glab)\s+auth\s+status\b[^\n]*(?:--show-token|\s-t\b))`);
|
|
131
139
|
/**
|
|
132
140
|
* A line written for PowerShell or cmd, where a backslash separates a path (`.\.env`, `C:\Users\me\.aws\credentials`,
|
|
133
|
-
* `$env:USERPROFILE\.ssh\id_rsa`): read without POSIX escapes.
|
|
141
|
+
* `$env:USERPROFILE\.ssh\id_rsa`, `%USERPROFILE%\.ssh`): read without POSIX escapes.
|
|
134
142
|
*/
|
|
135
|
-
const WINDOWS_LINE = /(?:^|[\s'"=(])(?:[A-Za-z]:\\|\.{1,2}\\[\w.$-]|~\\|\$(?:HOME|env:\w+)
|
|
143
|
+
const WINDOWS_LINE = /(?:^|[\s'"=(])(?:[A-Za-z]:\\|\.{1,2}\\[\w.$-]|~\\|\$(?:HOME|env:\w+)\\|%\w+%\\)|^[ \t]*(?:Get-\w+|Select-String|gc|type|findstr|pwsh|powershell|cmd)(?:\.exe)?[ \t]/i;
|
|
136
144
|
/**
|
|
137
145
|
* The simple commands a command line runs, in order, read through what runs a command of its own: a shell (`bash -lc
|
|
138
146
|
* "cat .env"`, `sudo sh -c '…'`, a heredoc or here-string fed to one: `ssh host <<'EOF'`), PowerShell (`pwsh -Command
|
|
@@ -165,7 +173,7 @@ export function commandsRun(text, { at = 0, oneLine = false, lines, depth = 0, }
|
|
|
165
173
|
out.push(c);
|
|
166
174
|
continue;
|
|
167
175
|
}
|
|
168
|
-
const wrapper = { kind: programName(simpleCommand(c.words)[0]), redirects: c.redirects };
|
|
176
|
+
const wrapper = { kind: programName(simpleCommand(c.words)[0]), redirects: c.redirects, then: c.then };
|
|
169
177
|
for (const r of run)
|
|
170
178
|
r.groups = [...r.groups, wrapper, ...c.groups];
|
|
171
179
|
run[run.length - 1].then = c.then;
|
|
@@ -194,7 +202,9 @@ function commandLine(text, at, { oneLine = false, lines } = {}) {
|
|
|
194
202
|
: prose
|
|
195
203
|
? at + prose[0].length - (prose[1] ? 1 : 0)
|
|
196
204
|
: at;
|
|
197
|
-
|
|
205
|
+
// (After PowerShell's prompt or cmd's, `PS C:\app> `, `C:\app>`, the line is theirs.)
|
|
206
|
+
const backslash = !(prompt && /^[ \t]*(?:PS\b|[A-Za-z]:\\)/.test(prompt[0])) &&
|
|
207
|
+
!WINDOWS_LINE.test(text.slice(from, lineEnd(text, from, 4000)));
|
|
198
208
|
return shellCommands(text, 0, { at: from, oneLine, lines, backslash });
|
|
199
209
|
}
|
|
200
210
|
/** A command read out of `source`'s string, with its positions where the source puts them in `text`. */
|
|
@@ -308,6 +318,14 @@ function innerCommand(c, text) {
|
|
|
308
318
|
const k = words.findIndex((w) => /^\/[ck]$/i.test(w.text));
|
|
309
319
|
return k > 0 ? from(words, k + 1) : undefined;
|
|
310
320
|
}
|
|
321
|
+
// `su - app -c 'cat .env'`, and runuser's twin of it, `runuser -l app -c …` (no `-u`, so no wrapper): the command
|
|
322
|
+
// string `-c` (`--command`) gives, as a shell's.
|
|
323
|
+
const runuser = programName(c.words.find((w) => !/^[A-Za-z_]\w*=/.test(w.text))) === "runuser";
|
|
324
|
+
if (program === "su" || runuser) {
|
|
325
|
+
const own = program === "su" ? words : c.words;
|
|
326
|
+
const k = own.findIndex((w) => /^(?:-c|--command)$/.test(w.text));
|
|
327
|
+
return k > 0 ? own[k + 1] && wordSource(own[k + 1]) : undefined;
|
|
328
|
+
}
|
|
311
329
|
const sub = subcommand(words);
|
|
312
330
|
if (/^(?:kubectl|oc)$/.test(program) && sub[0]?.text === "exec") {
|
|
313
331
|
const dashes = sub.findIndex((w) => w.text === "--");
|
|
@@ -392,13 +410,17 @@ function imageName(image) {
|
|
|
392
410
|
const ECHOES = /^(?:echo|printf|Write-Output|Write-Host)$/;
|
|
393
411
|
/** Whether a command prints the contents of a secret file (`cat .env`, `grep KEY .env.local`, `cat < .env`, `git show HEAD:.env`). */
|
|
394
412
|
export function printsSecretFile(command) {
|
|
413
|
+
// (A member of what a command gives prints that member, not the file or the listing: `(Get-Item Env:HOM*).Name`.)
|
|
414
|
+
if (EXPRESSION.test(command) &&
|
|
415
|
+
/\)(?:\.[A-Za-z_]\w*(?:\(\))?)*\.(?!value\b)[A-Za-z_]\w*(?:\(\))?[ \t]*$/i.test(command))
|
|
416
|
+
return false;
|
|
395
417
|
return printsWhatItReads(command, readsSecretFile, isSecretFile) || printsIndirectly(command);
|
|
396
418
|
}
|
|
397
419
|
/**
|
|
398
420
|
* A file printed by a name the pipeline reader doesn't see: `find … -exec cat {}`, `xargs cat`, a printer given a
|
|
399
421
|
* variable or a substitution (`cat "$f"`, `cat $(find …)`, `echo "$line"`), a script's `open(…).read()`.
|
|
400
422
|
*/
|
|
401
|
-
const PRINTER =
|
|
423
|
+
const PRINTER = /(?:-exec|\bxargs\b[^|&\n]*?|\bfd(?:find)?\b[^|;&\n]*[ \t]-[xX])[ \t'"]+(?:cat|tac|head|tail|less|more|bat|nl|sort|strings|awk|(?:e?grep|rg)\b(?![^;|&\n]*[ \t]-[A-Za-z]*[lLcq])|sed\b(?![^;|&\n]*[ \t]-(?:[A-Za-z]*i|-in-place)))\b|(?:^|[\s;|&(])(?:cat|tac|head|tail|less|more|bat|nl|echo|printf|print)\b[^;|&\n]*\$[{(\w"]|\.read\(\)|readlines\(\)|\b(?:print|puts|console\.log|echo)\b[^;\n]{0,80}?(?:readFileSync\(|\.read_text\(|\bFile\.read\(|file_get_contents\()/;
|
|
402
424
|
/**
|
|
403
425
|
* A secret file printed by a route the pipeline reader doesn't follow: named to `find … -exec cat {}`, `xargs cat`, a
|
|
404
426
|
* loop's variable, a substitution, a script's `open('.env').read()`, a `while read` loop's `< .env`. Taken as printed
|
|
@@ -485,6 +507,13 @@ function readsSecretFile(c) {
|
|
|
485
507
|
const xargs = xargsInput(c, words);
|
|
486
508
|
if (xargs !== undefined && (program === "xargs" || ECHOES.test(program)))
|
|
487
509
|
return isSecretFile(xargs);
|
|
510
|
+
// (PowerShell's `Get-ChildItem Env:` and `Get-Item Env:NAME` print the variables as a table of names and values: all
|
|
511
|
+
// of them, or those a wildcard matches; one variable named in full is judged by its name, `Env:AZURE_TENANT_ID`.)
|
|
512
|
+
const drive = /^(?:Get-ChildItem|gci|dir|ls|Get-Item|gi)$/.test(program)
|
|
513
|
+
? rest.find((t) => /^env:/i.test(t))
|
|
514
|
+
: undefined;
|
|
515
|
+
if (drive !== undefined)
|
|
516
|
+
return !/^env:\\?\w+$/i.test(drive) || isCredentialName(drive.replace(/^env:\\?/i, ""));
|
|
488
517
|
if (!CONTENT_COMMANDS.test(program) || printsNamesOnly(words) || editsInPlace(words))
|
|
489
518
|
return false;
|
|
490
519
|
// (`env` here runs no command: simpleCommand reads past it to the one it runs. `env -0` prints the environment too;
|
|
@@ -502,9 +531,12 @@ function readsSecretFile(c) {
|
|
|
502
531
|
// (Counting, listing and testing print none of what grep reads: `grep -c`, `grep -l`, `grep -q`.)
|
|
503
532
|
if (/^(?:grep|egrep|fgrep|rg|ag|ack|zgrep)$/.test(program) && rest.some((t) => countsMatches(program, t)))
|
|
504
533
|
return false;
|
|
534
|
+
// (A word with backslashes is read as written, a Windows path whatever else the line holds: PowerShell's `cat
|
|
535
|
+
// apps\web\.env.local`.)
|
|
536
|
+
const args = words.slice(1).map((w) => (/\\/.test(w.raw) && !/['"$`]/.test(w.raw) ? w.raw : w.text));
|
|
505
537
|
const files = /^(?:grep|egrep|fgrep|rg|ag|ack|zgrep|Select-String|sls)$/.test(program)
|
|
506
|
-
? searchedFiles(
|
|
507
|
-
:
|
|
538
|
+
? searchedFiles(args)
|
|
539
|
+
: args.filter((t) => !t.startsWith("-"));
|
|
508
540
|
return files.some((t) => isSecretFile(t));
|
|
509
541
|
}
|
|
510
542
|
/**
|
|
@@ -603,6 +635,13 @@ function printsWhatItReads(command, reads, source) {
|
|
|
603
635
|
}
|
|
604
636
|
return false;
|
|
605
637
|
}
|
|
638
|
+
/**
|
|
639
|
+
* Whether what a command prints goes into the next command's input: by its own `|`, or by a group's around it, whatever
|
|
640
|
+
* follows it inside the group (`{ echo A=1; echo B=2; } | tee .env`).
|
|
641
|
+
*/
|
|
642
|
+
export function pipes(c) {
|
|
643
|
+
return [c.then, ...c.groups.map((g) => g.then)].some((then) => then === "|" || then === "|&");
|
|
644
|
+
}
|
|
606
645
|
/** Devices that are what's shown: a tool's captured output holds stdout and stderr, and a terminal shows both. */
|
|
607
646
|
const SHOWN = /^\/dev\/(?:stdout|stderr|tty|fd\/[12])$/;
|
|
608
647
|
/** The file a command's output goes to, not to what's shown: `> out`, `>> log`, `&> /dev/null`, `>& out` (not `2>`, `>&2`, `> /dev/stderr`). */
|
|
@@ -646,7 +685,34 @@ function consumesInput(command) {
|
|
|
646
685
|
}
|
|
647
686
|
/** Whether a command prints a credential: any of its pipelines (`openssl rand -hex 32 && echo ok`) whose stage that makes one reaches its output. */
|
|
648
687
|
export function printsSecret(command) {
|
|
649
|
-
return printsWhatItReads(command, printsCredential);
|
|
688
|
+
return printsExpression(command) || printsWhatItReads(command, printsCredential);
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* A command line that is one expression, which PowerShell prints (as a REPL does; a POSIX shell runs a variable's value
|
|
692
|
+
* as a command and prints it in its error): a variable, `$env:NAME`, its members (`$cred.GetNetworkCredential().Password`),
|
|
693
|
+
* or a command's in parentheses (`(Get-Item Env:TOKEN).Value`).
|
|
694
|
+
*/
|
|
695
|
+
const EXPRESSION = /^[ \t]*(?:\$\{?(?:env:)?[A-Za-z_]\w*\}?|\([^()\n]{1,200}\))(?:\.[A-Za-z_]\w*(?:\(\))?)*[ \t]*$/i;
|
|
696
|
+
/**
|
|
697
|
+
* Whether a command line is an expression (EXPRESSION) that prints a credential: by its last member's name, or with
|
|
698
|
+
* none, or a `Value` (an entry's), by its variables' (`$cred.UserName` is a name; `$env:TOKEN`, `(Get-Item
|
|
699
|
+
* Env:TOKEN).Value` are the token).
|
|
700
|
+
*/
|
|
701
|
+
function printsExpression(command) {
|
|
702
|
+
if (!EXPRESSION.test(command))
|
|
703
|
+
return false;
|
|
704
|
+
const member = /\.([A-Za-z_]\w*)(?:\(\))?[ \t]*$/.exec(command)?.[1];
|
|
705
|
+
// (A dictionary's keys are its names, `$hash.Keys`, `(Get-Command x).Parameters.Keys`, and an entry's `$_.Key`.)
|
|
706
|
+
if (member !== undefined && !/^value$/i.test(member))
|
|
707
|
+
return !/^(?:all)?keys?$/i.test(member) && isCredentialName(member);
|
|
708
|
+
return variablesIn(command).some(isCredentialName);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* The variables a text reads, by their names, in any shell's syntax: `$NAME`, `${NAME}`, PowerShell's `$env:NAME`,
|
|
712
|
+
* `${env:NAME}` and its drive's `Env:NAME`, cmd's `%NAME%`.
|
|
713
|
+
*/
|
|
714
|
+
function variablesIn(text) {
|
|
715
|
+
return [...text.matchAll(/\$\{?(?:env:)?([A-Za-z_]\w*)|%([A-Za-z_]\w*)%|\benv:([A-Za-z_]\w*)/gi)].map((m) => m[1] ?? m[2] ?? m[3]);
|
|
650
716
|
}
|
|
651
717
|
function passesThrough(c) {
|
|
652
718
|
const words = simpleCommand(c.words);
|
|
@@ -670,13 +736,17 @@ function printsCredential(c) {
|
|
|
670
736
|
const words = simpleCommand(c.words);
|
|
671
737
|
// (Read from the program's own name on: `/usr/bin/gh auth token` is `gh auth token`.)
|
|
672
738
|
const line = [programName(words[0]), ...words.slice(1).map((w) => w.text)].join(" ").slice(0, MAX_TOOL_LINE);
|
|
673
|
-
if (SECRET_TOOLS.test(line) || SECRET_SOURCES.test(line))
|
|
739
|
+
if (SECRET_TOOLS.test(line) || SECRET_SOURCES.test(line) || PRINTED_SOURCE.test(line))
|
|
674
740
|
return true;
|
|
675
741
|
const program = programName(words[0]);
|
|
676
742
|
const args = words.slice(1);
|
|
677
743
|
if (program === "printenv")
|
|
678
744
|
return args.length === 1 && isCredentialName(args[0].text);
|
|
679
|
-
|
|
745
|
+
// (A script given on the command line that prints a credential's variable: `python3 -c "print(os.environ['TOKEN'])"`,
|
|
746
|
+
// `node -e "console.log(process.env.TOKEN)"`.)
|
|
747
|
+
if (INTERPRETER.test(program))
|
|
748
|
+
return [...line.matchAll(PRINTED_ENV_READ)].some((m) => isCredentialName(m[1] ?? m[2]));
|
|
749
|
+
if (program !== "echo" && program !== "printf" && !/^write-(?:host|output)$/i.test(program))
|
|
680
750
|
return false;
|
|
681
751
|
// (printf prints its format and its operands alike, `printf "Token: %s\n" "$TOKEN"`, `printf "$TOKEN"`; but `-v NAME`
|
|
682
752
|
// assigns what it would print and prints nothing.)
|
|
@@ -685,9 +755,18 @@ function printsCredential(c) {
|
|
|
685
755
|
: args[0]?.text === "-v"
|
|
686
756
|
? []
|
|
687
757
|
: args.filter((w) => w.text !== "--");
|
|
688
|
-
// (A credential's variable printed alone, `echo $DB_PASSWORD`, or inside its words, `echo
|
|
689
|
-
|
|
758
|
+
// (A credential's variable printed alone, `echo $DB_PASSWORD`, `echo %DB_PASSWORD%`, or inside its words, `echo
|
|
759
|
+
// "token: $GITHUB_TOKEN"`.)
|
|
760
|
+
return values.some((v) => !v.literalEnd && variablesIn(v.text).some(isCredentialName));
|
|
690
761
|
}
|
|
762
|
+
/** Interpreters that run a script given on their command line. */
|
|
763
|
+
const INTERPRETER = /^(?:python[\d.]*|node|deno|bun|ruby|perl|php)$/;
|
|
764
|
+
/**
|
|
765
|
+
* A read of an environment variable in those scripts, by its name, that their print takes: `print(os.environ['TOKEN'])`,
|
|
766
|
+
* `console.log("token", process.env.TOKEN)`. (A script that reads one to use it prints what it does with it: a login
|
|
767
|
+
* script's `type(process.env.PW)` and its `console.log("submitted")`.)
|
|
768
|
+
*/
|
|
769
|
+
const PRINTED_ENV_READ = /\b(?:print|puts|console\.log|echo|say|process\.stdout\.write)\b[^;\n]{0,80}?(?:(?:os\.environ(?:\.get)?|os\.getenv|\bENV(?:\.fetch)?|\bgetenv|\$_ENV|\$ENV)\s*[[({]\s*["']([A-Za-z_]\w*)["']|process\.env\.([A-Za-z_]\w*))/g;
|
|
691
770
|
/** Tools by name, and how each takes a password (structure.ts reads them). */
|
|
692
771
|
const TOOLS = [
|
|
693
772
|
[
|
package/dist/context.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export declare class Doc {
|
|
|
34
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
35
|
*/
|
|
36
36
|
inPublicArmor(at: number): boolean;
|
|
37
|
-
/** The cell of an SQL `INSERT`
|
|
37
|
+
/** The cell of an SQL row (`INSERT`, `COPY`) whose value holds `[start, end)`, read as sql.ts reads rows. */
|
|
38
38
|
sqlCell(start: number, end: number): SqlCell | undefined;
|
|
39
39
|
/**
|
|
40
40
|
* The first line of the comma-separated block that line `i` is in: the lines above it with as many cells, up to
|
|
@@ -64,7 +64,10 @@ export interface Context {
|
|
|
64
64
|
* on the line: "your new API key for the git server: <t>".
|
|
65
65
|
*/
|
|
66
66
|
hashPhrase: boolean;
|
|
67
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* The previous URL path segment, for `/hooks/<token>`, `/webhook/<token>`; in a URL, or a request's target after its
|
|
69
|
+
* method (`POST /hooks/<token> HTTP/1.1`).
|
|
70
|
+
*/
|
|
68
71
|
pathParent: string;
|
|
69
72
|
/** A phrase that hands over a value: "here's your key", "Token created:", "use this one". */
|
|
70
73
|
handover: boolean;
|
package/dist/context.js
CHANGED
|
@@ -7,7 +7,7 @@ import { lastText, lowerBound } from "./lines.js";
|
|
|
7
7
|
import { isGenericName, mentionsCredential, nameClass, phraseBefore, readPhrase } from "./names.js";
|
|
8
8
|
import { inJwk } from "./json.js";
|
|
9
9
|
import { publicBlocks } from "./publickeys.js";
|
|
10
|
-
import {
|
|
10
|
+
import { sqlCells } from "./sql.js";
|
|
11
11
|
import { backslashesBefore, delimitedCells, isEscapedBreak, PAIR_VALUE, PIPE_RULE, pipeCells, siblingName, STRING_PREFIX, } from "./syntax.js";
|
|
12
12
|
import { PARAM_NAME, PARAM_START, urlAround } from "./urls.js";
|
|
13
13
|
/** How far a line is read either side of a token, and the longest line read whole for its structure (tables, blobs). */
|
|
@@ -90,11 +90,11 @@ export class Doc {
|
|
|
90
90
|
const k = lowerBound(this.armor.begins, at + 1) - 1;
|
|
91
91
|
return k >= 0 && at < this.armor.ends[k];
|
|
92
92
|
}
|
|
93
|
-
/** The cell of an SQL `INSERT`
|
|
93
|
+
/** The cell of an SQL row (`INSERT`, `COPY`) whose value holds `[start, end)`, read as sql.ts reads rows. */
|
|
94
94
|
sqlCell(start, end) {
|
|
95
95
|
if (!this.cells) {
|
|
96
96
|
// (Values in the order they appear, so their starts are sorted.)
|
|
97
|
-
this.cells = [...
|
|
97
|
+
this.cells = [...sqlCells(this.text)].sort((a, b) => a.value.start - b.value.start);
|
|
98
98
|
this.cellStarts = this.cells.map((c) => c.value.start);
|
|
99
99
|
}
|
|
100
100
|
const cell = this.cells[lowerBound(this.cellStarts, start + 1) - 1];
|
|
@@ -156,7 +156,15 @@ const XML_ATTR = /\b([\w:.-]{1,80})=["']$/;
|
|
|
156
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
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
158
|
const TABLE = /^[ \t│|]{0,10}([A-Za-z_][\w.-]{0,80})(?:[ \t]{2,80}|[ \t]{0,80}[│|][ \t]{0,10})$/;
|
|
159
|
-
|
|
159
|
+
/** A hash algorithm's name: `sha256`, `sha-256` (HTTP's), `md5`, `blake3`. */
|
|
160
|
+
const HASH_ALGORITHM = String.raw `(?:sha-?(?:1|224|256|384|512)|md5|blake2b?|blake3|crc32|xxh\d*)`;
|
|
161
|
+
/**
|
|
162
|
+
* A hash's or a digest's name right before the token. (HTTP's digest fields, RFC 9530, write the digest as a structured
|
|
163
|
+
* field's byte sequence, `Content-Digest: sha-256=:<base64>:`; tools write the algorithm or the encoding in brackets
|
|
164
|
+
* after a label, `Hash (sha256): `, `Checksum (base64): `; BSD's checksum tools the file's name after the algorithm,
|
|
165
|
+
* `SHA256 (app.tar.gz) = `.)
|
|
166
|
+
*/
|
|
167
|
+
const HASH_PREFIX = new RegExp(String.raw `(?:^|[\s;])bh=$|pin-sha256="?$|(?<![A-Za-z0-9])(?:${HASH_ALGORITHM}|h1|integrity)[:=\-]:?[ \t]?$|(?<![A-Za-z0-9])(?:hash|checksum|digest|${HASH_ALGORITHM})[ \t]*\([^()\n]{1,100}\)[ \t]?[:=][ \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
168
|
/** The text before the last `name` in `left` (none when the name came from elsewhere, a sibling or a header). */
|
|
161
169
|
function textBefore(left, name) {
|
|
162
170
|
const at = left.lastIndexOf(name);
|
|
@@ -194,6 +202,18 @@ function isBlobLine(doc, start, end, i) {
|
|
|
194
202
|
}
|
|
195
203
|
return true;
|
|
196
204
|
}
|
|
205
|
+
/** A line holding one token alone, or as a list's item: `- Kx9mQ2…`. */
|
|
206
|
+
const LONE_TOKEN_LINE = /^[ \t]*(?:[-*•][ \t]+)?["'`]?[A-Za-z0-9+/=_.-]{16,}["'`]?[ \t]*\r?$/;
|
|
207
|
+
/**
|
|
208
|
+
* The line above line `i`, or above the lines of lone tokens line `i` ends (keys handed over one per line: "Here are
|
|
209
|
+
* your new API keys:" over three of them).
|
|
210
|
+
*/
|
|
211
|
+
function listHead(doc, i, above) {
|
|
212
|
+
let k = i - 1;
|
|
213
|
+
while (k > 0 && k >= i - 20 && LONE_TOKEN_LINE.test(doc.line(k)))
|
|
214
|
+
k--;
|
|
215
|
+
return k < i - 1 ? doc.line(k) : above;
|
|
216
|
+
}
|
|
197
217
|
/**
|
|
198
218
|
* The name the list that line `i` is an item of sits under (`api_keys:` above `- …` lines, `"keys": [` above `"…",`
|
|
199
219
|
* lines), read over its other items, blank lines and comments; undefined if none. (A line that only names: `Your API
|
|
@@ -300,8 +320,10 @@ function nameFromLeft(left, url) {
|
|
|
300
320
|
const trimmed = left.trimStart();
|
|
301
321
|
if (trimmed === "" || /^[-*•>+][ \t]*["'`]?$/.test(trimmed) || /^["'`]$/.test(trimmed))
|
|
302
322
|
return { name: "", syntax: /^[-*•]/.test(trimmed) ? "list" : "bare" };
|
|
303
|
-
// `Authorization: Bearer <token>`: the scheme word sits between name and value
|
|
304
|
-
|
|
323
|
+
// `Authorization: Bearer <token>`: the scheme word sits between name and value; or parameters and version tags do,
|
|
324
|
+
// `webhook-signature: v1,<sig>`, `Stripe-Signature: t=…,v1=<sig>`.
|
|
325
|
+
if ((m =
|
|
326
|
+
/^[ \t]{0,8}([A-Za-z][\w-]{1,60}):[ \t]{1,4}(?:[A-Za-z][\w-]{0,20}[ \t]{1,3}|(?:[\w-]{1,20}(?:=[^\s,;]{0,40})?[,;][ \t]*)+(?:[\w-]{1,20}=)?)?$/.exec(left)))
|
|
305
327
|
return { name: m[1], syntax: /^[A-Z]/.test(m[1]) || /-/.test(m[1]) ? "header" : "assign" };
|
|
306
328
|
if ((m = FLAG.exec(left)))
|
|
307
329
|
return { name: m[1].replace(/^-+/, ""), syntax: "flag" };
|
|
@@ -358,6 +380,9 @@ export function contextOf(doc, start, end) {
|
|
|
358
380
|
// An item of a list under a name (`api_keys:` then `- <key>` lines): the list's name.
|
|
359
381
|
if (!name && /^[ \t]*(?:[-*•][ \t]*["'`]?|["'`])$/.test(left))
|
|
360
382
|
name = listName(doc, i) ?? "";
|
|
383
|
+
// A request's target after its method, as access logs and HTTP dumps print it (`"POST /hooks/<token> HTTP/1.1"`): a
|
|
384
|
+
// URL's path, not a file's.
|
|
385
|
+
const requestPath = !inUrl && /\b(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)[ \t]+\/[^\s"]*$/.test(left);
|
|
361
386
|
const fsPath = !inUrl &&
|
|
362
387
|
(/(?:^|[\s"'`=:(,\[])(?:~|\.{1,2}|[A-Za-z]:)?(?:[\\/][\w.@+-]{1,80}){1,30}[\\/]$/.test(left) ||
|
|
363
388
|
(/[\\/]$/.test(left) && /^[\\/][\w.-]/.test(right)));
|
|
@@ -436,8 +461,10 @@ export function contextOf(doc, start, end) {
|
|
|
436
461
|
const before = phraseBefore(textBefore(left, name), 4);
|
|
437
462
|
({ name, class: cls } = readPhrase(before, name));
|
|
438
463
|
}
|
|
439
|
-
const pathParent = inUrl ? (/\/([^/\s?#]{1,60})\/$/.exec(left)?.[1] ?? "") : "";
|
|
440
|
-
|
|
464
|
+
const pathParent = inUrl || requestPath ? (/\/([^/\s?#]{1,60})\/$/.exec(left)?.[1] ?? "") : "";
|
|
465
|
+
// (A query parameter's credential words are its own name's: `?api_key=…&profile=<id>` says nothing of the profile.)
|
|
466
|
+
const ownLeft = syntax === "query" && name ? left.replace(/[?&][^?&\s"'=]+=[^&\s"']*(?=&[^&]*$)/g, "") : left;
|
|
467
|
+
const leftAndAbove = identifierWords(`${above}\n${ownLeft}`);
|
|
441
468
|
return {
|
|
442
469
|
name,
|
|
443
470
|
nameClass: cls,
|
|
@@ -452,7 +479,7 @@ export function contextOf(doc, start, end) {
|
|
|
452
479
|
pathParent,
|
|
453
480
|
sqlCell,
|
|
454
481
|
handover: HANDOVER.test(identifierWords(left.slice(-200))) ||
|
|
455
|
-
(left.trim() === "" && HANDOVER.test(identifierWords(above.slice(-200)))),
|
|
482
|
+
(left.trim() === "" && HANDOVER.test(identifierWords(listHead(doc, i, above).slice(-200)))),
|
|
456
483
|
inUrl,
|
|
457
484
|
fsPath,
|
|
458
485
|
armored: armoredAt(doc, start, left),
|
package/dist/encoded.js
CHANGED
|
@@ -8,6 +8,7 @@ import { naturalness } from "./charlm.js";
|
|
|
8
8
|
import { lineEnd, lineStart } from "./lines.js";
|
|
9
9
|
import { asciiSet, longRuns } from "./runs.js";
|
|
10
10
|
import { keepReason } from "./policy.js";
|
|
11
|
+
import { isJsonDocument } from "./secretfile.js";
|
|
11
12
|
import { hasThreeClasses } from "./values.js";
|
|
12
13
|
const UTF8 = new TextDecoder("utf-8");
|
|
13
14
|
/** The share of characters that must be printable for bytes to count as text, and of zero bytes for UTF-16. */
|
|
@@ -132,6 +133,10 @@ function isGeneratedToken(token) {
|
|
|
132
133
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token) ||
|
|
133
134
|
/^[\w.-]+@[\w.-]+$|^https?:/.test(token))
|
|
134
135
|
return false;
|
|
136
|
+
// (Nor a JSON object or array, which its quotes and braces give three classes: a JWT's header on a line of its own,
|
|
137
|
+
// `cut -d. -f1` of a token, is `{"alg":"HS256","typ":"JWT"}`.)
|
|
138
|
+
if (isJsonDocument(token))
|
|
139
|
+
return false;
|
|
135
140
|
if (keepReason(token, { start: 0, end: token.length, kind: "random token", rule: "unnamed", confidence: 0.8 }) !==
|
|
136
141
|
undefined)
|
|
137
142
|
return false;
|
|
@@ -245,10 +250,11 @@ function isLineAlone(text, start, end) {
|
|
|
245
250
|
*/
|
|
246
251
|
export function findHexDumps(text, inner) {
|
|
247
252
|
const out = [];
|
|
248
|
-
if (!/[0-9a-f]{4}/i.test(text))
|
|
253
|
+
if (!/[0-9a-f]{4}|[0-9a-f]{2}[ \t][0-9a-f]{2}[ \t][0-9a-f]{2}/i.test(text))
|
|
249
254
|
return out;
|
|
250
|
-
// Offset-prefixed dumps: runs of lines `<offset>[:] <hex groups> [ascii]`.
|
|
251
|
-
|
|
255
|
+
// Offset-prefixed dumps: runs of lines `<offset>[:] <hex groups> [ascii]`. (Groups of any whole number of bytes up to
|
|
256
|
+
// 16, and any number of them with an ASCII column of any width: `xxd -g 8`, `xxd -c 32 -g1`.)
|
|
257
|
+
for (const m of text.matchAll(/(?:^[0-9a-f]{6,8}:?(?:[ \t]{1,4}(?:[0-9a-f]{2}){1,16})[^\n]*(?:\n|$)){1,100000}/gim)) {
|
|
252
258
|
const words = [];
|
|
253
259
|
let bytes = "";
|
|
254
260
|
const lines = m[0].split("\n").filter((l) => l);
|
|
@@ -260,7 +266,7 @@ export function findHexDumps(text, inner) {
|
|
|
260
266
|
const body = line.replace(/^[0-9a-f]{6,8}:?[ \t]+/i, "");
|
|
261
267
|
let digits = 0;
|
|
262
268
|
for (const t of body.split(/[ \t]+/)) {
|
|
263
|
-
if (!/^(?:[0-9a-f]{2}
|
|
269
|
+
if (!/^(?:[0-9a-f]{2}){1,16}$/i.test(t) || digits + t.length > step * 2)
|
|
264
270
|
break;
|
|
265
271
|
if (t.length === 4)
|
|
266
272
|
words.push(t.slice(2) + t.slice(0, 2));
|
|
@@ -306,14 +312,15 @@ export function findHexDumps(text, inner) {
|
|
|
306
312
|
runStart = -1;
|
|
307
313
|
lines = 0;
|
|
308
314
|
};
|
|
309
|
-
|
|
315
|
+
// (Also bytes apart, with no offsets: `od -An -tx1`'s ` 44 42 5f …`.)
|
|
316
|
+
for (const m of text.matchAll(/^[ \t]*(?:[0-9a-f]{2}[ \t]?){1,100}\r?$/gim)) {
|
|
310
317
|
if (runStart >= 0 && m.index === runEnd + 1) {
|
|
311
318
|
runEnd = m.index + m[0].length;
|
|
312
319
|
lines++;
|
|
313
320
|
}
|
|
314
321
|
else {
|
|
315
322
|
flush();
|
|
316
|
-
if (m[0].
|
|
323
|
+
if (m[0].replace(/\s+/g, "").length < 16)
|
|
317
324
|
continue;
|
|
318
325
|
runStart = m.index;
|
|
319
326
|
runEnd = m.index + m[0].length;
|