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.
Files changed (48) hide show
  1. package/README.md +11 -10
  2. package/dist/calls.d.ts +8 -0
  3. package/dist/calls.js +60 -3
  4. package/dist/checksums.js +4 -3
  5. package/dist/commands.d.ts +5 -0
  6. package/dist/commands.js +93 -14
  7. package/dist/context.d.ts +5 -2
  8. package/dist/context.js +36 -9
  9. package/dist/encoded.js +13 -6
  10. package/dist/environment.d.ts +5 -3
  11. package/dist/environment.js +38 -14
  12. package/dist/formats.js +12 -0
  13. package/dist/index.js +10 -4
  14. package/dist/json.js +29 -5
  15. package/dist/known.d.ts +7 -7
  16. package/dist/known.js +33 -25
  17. package/dist/named.d.ts +6 -0
  18. package/dist/named.js +421 -125
  19. package/dist/names.d.ts +5 -1
  20. package/dist/names.js +143 -18
  21. package/dist/normalize.d.ts +4 -1
  22. package/dist/normalize.js +37 -11
  23. package/dist/pem.d.ts +2 -0
  24. package/dist/pem.js +12 -0
  25. package/dist/policy.js +12 -5
  26. package/dist/public.js +35 -13
  27. package/dist/secretfile.d.ts +5 -0
  28. package/dist/secretfile.js +49 -20
  29. package/dist/shell.d.ts +12 -3
  30. package/dist/shell.js +51 -7
  31. package/dist/spans.d.ts +4 -3
  32. package/dist/spans.js +79 -8
  33. package/dist/sql.d.ts +12 -8
  34. package/dist/sql.js +94 -16
  35. package/dist/stream.d.ts +2 -1
  36. package/dist/stream.js +15 -3
  37. package/dist/structure.js +210 -61
  38. package/dist/syntax.d.ts +5 -2
  39. package/dist/syntax.js +7 -4
  40. package/dist/transcript.js +116 -28
  41. package/dist/unnamed.js +128 -20
  42. package/dist/urls.d.ts +8 -3
  43. package/dist/urls.js +16 -7
  44. package/dist/values.d.ts +2 -1
  45. package/dist/values.js +70 -25
  46. package/docs/holdout.svg +8 -8
  47. package/docs/layers.md +1 -1
  48. package/package.json +1 -1
@@ -16,6 +16,11 @@ export declare function findInSecretFile(text: string, { part, path }?: {
16
16
  * `--output text` rows of AWS's.
17
17
  */
18
18
  export declare function findPrintedSecret(text: string): Finding[];
19
+ /**
20
+ * Whether a line is a JSON document whole: an object, an array, or a string that holds one (output encoded once more,
21
+ * `--query SecretString`'s `"{\"password\": …}"`). A string that holds anything else is a value (jsonString).
22
+ */
23
+ export declare function isJsonDocument(line: string): boolean;
19
24
  /**
20
25
  * Where the first prompt line inside a command's output starts (a later command), or its length if none. (Not a `!`
21
26
  * line: in a command's output that is a value or an option file's directive, not a notebook's command.)
@@ -4,7 +4,7 @@
4
4
  * output under a prompt in a pasted session; and the places a secret file's content sits inside other text (a
5
5
  * diff's hunks, a heredoc that writes one, `echo … > ~/.vault_pass`).
6
6
  */
7
- import { commandsRun, holdsOnlySecrets, isConfigFileName, isSecretFile, printsSecret, printsSecretFile, } from "./commands.js";
7
+ import { commandsRun, holdsOnlySecrets, isConfigFileName, isSecretFile, pipes, printsSecret, printsSecretFile, } from "./commands.js";
8
8
  import { allRedirects, LineIndex, programName, PROMPT, simpleCommand, wordSpan } from "./shell.js";
9
9
  import { naturalness } from "./charlm.js";
10
10
  import { isPlaceholder } from "./public.js";
@@ -53,6 +53,11 @@ const MIN_UNNAMED_TOKEN = 24;
53
53
  function isBareVendorWord(name) {
54
54
  return /^[A-Z][A-Z0-9]{2,30}$/.test(name);
55
55
  }
56
+ /**
57
+ * What a setting names in numbers and lower-case words: a platform (`linux/amd64`, `linux/arm64/v8`), a size
58
+ * (`1920x1080`), a port and its protocol (`8080/tcp`, `5000-5010/udp`).
59
+ */
60
+ const PLATFORM_OR_MEASURE = /^[a-z][a-z0-9_.-]{0,30}(?:\/[a-z0-9][a-z0-9_.-]{0,30}){1,3}$|^\d+x\d+$|^\d+(?:-\d+)?\/(?:tcp|udp|sctp)$/;
56
61
  /** A host's name, and its port: `db.internal:5432`, `abc123def456.ngrok.io`. */
57
62
  const HOST = /^[\w-]+(?:\.[a-z][\w-]*)+(?::\d+)?$/;
58
63
  /**
@@ -65,6 +70,7 @@ function isSettingShaped(v) {
65
70
  isPasswordlessUrl(v) ||
66
71
  /^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(v) ||
67
72
  /^v?\d+(?:[.:-]\d+)*$/.test(v) ||
73
+ PLATFORM_OR_MEASURE.test(v) ||
68
74
  HOST.test(v) ||
69
75
  isWordySetting(v) ||
70
76
  isIdentifierShaped(v) ||
@@ -168,9 +174,10 @@ function isOneValueLine(line) {
168
174
  const nameShaped = named !== undefined && /^(?:[a-z_][a-z0-9_.-]*|[A-Z_][A-Z0-9_.-]*|[a-z]+(?:[A-Z][a-z]+)+)$/.test(named);
169
175
  if (line.length > 1000 || /[=:]\s|:\/\//.test(line) || nameShaped)
170
176
  return false;
171
- // One token in brackets is the value, brackets and all: `{Kd8pQ2zX9mL}`.
172
- if (/^(?:\[[^\s\]]*\]|\{[^\s}]*\}|<[^\s>]*>)$/.test(line) && !/[:,=]/.test(line))
173
- return true;
177
+ // One token in brackets is the value, brackets and all: `{Kd8pQ2zX9mL}`. (Not an empty document, `{}`, `[]`, nor an
178
+ // INI file's section, `[default]`, `[core]`: its name reads as a setting's.)
179
+ if (/^(?:\[[^\s\]]+\]|\{[^\s}]+\}|<[^\s>]+>)$/.test(line) && !/[:,=]/.test(line))
180
+ return !(line.startsWith("[") && isSettingShaped(line.slice(1, -1)));
174
181
  // (A netrc entry is read by its keywords, spacedKeywords: `machine github.com login x password ${GITHUB_TOKEN}`.)
175
182
  const structured = /^#[ \t!#]|^\[[^\]]*\]$|^\{[\s\S]*\}$|^<[\s\S]*>$|^["{[][\s\S]*[:,]|^(?:machine[ \t]+\S+|default)[ \t]+(?:login|password|account)[ \t]/.test(line);
176
183
  // (Nor a keyword and a reference: `requirepass ${REDIS_PASSWORD}`, its value read elsewhere.)
@@ -245,7 +252,10 @@ const TABLE_WORD = /^(?:true|false|null|n\/a|-+|values?|data|type|description)$/
245
252
  function tableRows(text, out) {
246
253
  for (const m of text.matchAll(/^(?:[ \t]*[│|┃║][ \t]*|[ \t]*)([\w.\-/@]{1,200})(?:[ \t]{2,}|[ \t]*[│|┃║][ \t]*)([^\s│|┃║](?:[^│|┃║\r\n]{0,4000}?[^\s│|┃║])?)[ \t]*(?:[│|┃║][^\r\n]*)?\r?$/dgm)) {
247
254
  const [, name, value] = m;
248
- if (!isCredentialName(name) ||
255
+ // (A value that starts with `=` or a `:` is a padded assignment's, `db_password = "…"` in a tfvars file: the
256
+ // assignments' reader reads it whole.)
257
+ if (/^(?:=|:(?!\/\/))/.test(value) ||
258
+ !isCredentialName(name) ||
249
259
  isNotSecret(value) ||
250
260
  isPlaceholder(value) ||
251
261
  TABLE_WORD.test(value) ||
@@ -300,9 +310,9 @@ function cookiesTxt(text, out) {
300
310
  out.push({ start: at(m, 2)[0], end: at(m, 2)[1], kind: "cookie", rule: "file:cookies", confidence: 0.95 });
301
311
  }
302
312
  }
303
- /** htpasswd: `user:<hash>`, a crypt hash's `$id$` (`$apr1$…`, `$2y$…`) or `{SSHA}`. (Not `admin:$ADMIN_PASSWORD`: a variable.) */
313
+ /** htpasswd: `user:<hash>`, a crypt hash's `$id$` (`$apr1$…`, `$2y$…`) or LDAP's `{SSHA}…`. (Not `admin:$ADMIN_PASSWORD`.) */
304
314
  function htpasswd(text, out) {
305
- for (const m of text.matchAll(/^[\w.@-]{1,100}:(\$[\w.+=-]{1,30}\$[\w$./+=-]{8,300}|\{SSHA\}[\w+/=]{10,200})[ \t]*$/dgm))
315
+ for (const m of text.matchAll(/^[\w.@-]{1,100}:(\$[\w.+=-]{1,30}\$[\w$./+=-]{8,300}|\{[A-Z][A-Z\d-]{1,20}\}[\w+/=.$-]{10,300})[ \t]*$/dgm))
306
316
  out.push({ start: at(m, 1)[0], end: at(m, 1)[1], kind: "password hash", rule: "file:hash", confidence: 1 });
307
317
  }
308
318
  /**
@@ -431,13 +441,19 @@ function firstLine(text) {
431
441
  export function findPrintedSecret(text) {
432
442
  const out = [];
433
443
  const add = (start, end) => out.push({ start, end, kind: "credential", rule: "command:prints-secret", confidence: 1 });
434
- // `--output text` rows: SSM's value follows its type, Secrets Manager's is the fourth field.
435
- for (const m of text.matchAll(/^(?:PARAMETER\t[^\n]*?\t(?:SecureString|String|StringList)\t([^\t\n]+)\t\d+|arn:aws:secretsmanager:[^\t\n]+\t[^\t\n]*\t[^\t\n]*\t([^\t\n]+)(?:\t[^\n]*)?)\r?$/dgm)) {
444
+ // `--output text` rows: SSM's value follows its type (a `PARAMETER` row, or `get-parameters`' `PARAMETERS`), Secrets
445
+ // Manager's is the fourth field.
446
+ for (const m of text.matchAll(/^(?:PARAMETERS?\t[^\n]*?\t(?:SecureString|String|StringList)\t([^\t\n]+)\t\d+|arn:aws:secretsmanager:[^\t\n]+\t[^\t\n]*\t[^\t\n]*\t([^\t\n]+)(?:\t[^\n]*)?)\r?$/dgm)) {
436
447
  const [start, end] = at(m, m[1] !== undefined ? 1 : 2);
437
448
  add(start, end);
438
449
  }
439
450
  if (out.length)
440
451
  return out;
452
+ // Output in an env file's lines (`heroku config:get -s`, `netlify env:list --plain`, a pulled `.env` read back): read
453
+ // as that file, each value by its name, so a `NEXT_PUBLIC_…` setting stays.
454
+ const lines = text.split("\n").filter((l) => l.trim());
455
+ if (lines.length && lines.every((l) => /^[ \t]*(?:export[ \t]+)?[A-Za-z_][\w.]*=/.test(l)))
456
+ return findInSecretFile(text, {});
441
457
  // (A path is where a credential is, not one: `GOOGLE_APPLICATION_CREDENTIALS=/workspace/secrets/sa.json`.)
442
458
  const notSecret = (line) => isInert(line) ||
443
459
  isPath(line.replace(/^[A-Za-z_][\w.-]*=/, "")) ||
@@ -451,13 +467,23 @@ export function findPrintedSecret(text) {
451
467
  const quote = firstValue === first.value ? 0 : 1;
452
468
  if (firstValue && !notSecret(firstValue) && !/^[{[]$/.test(firstValue) && readsAsValue(firstValue))
453
469
  add(first.start + quote, first.start + first.value.length - quote);
454
- for (const m of text.slice(firstEnd).matchAll(/^[ \t]*(\S{6,})[ \t]*\r?$/dgm)) {
455
- const value = jsonString(m[1]) ?? m[1];
456
- const q = value === m[1] ? 0 : 1;
457
- // (A message's tokens are not values; `[`/`{` start a JSON document's lines.)
458
- if (isMessageToken(value) || /^[{[]/.test(value) || notSecret(value))
470
+ // (Up to a blank line: what follows one is no longer the command's output, `Perfect!` from whoever ran it. A markup
471
+ // tag's closer ends the output too, `</tool_result>`, on its own line or after the last one, `honeybear</output>`; an
472
+ // opener, `<output>`, is the harness's wrapper, not a value.)
473
+ const blank = /\n[ \t]*\r?\n/.exec(text.slice(firstEnd));
474
+ const rest = text.slice(firstEnd, blank ? firstEnd + blank.index : text.length);
475
+ for (const m of rest.matchAll(/^[ \t]*(\S{6,})[ \t]*\r?$/dgm)) {
476
+ const closed = /<\/[A-Za-z][\w:.-]*>$/.exec(m[1]);
477
+ const token = closed ? m[1].slice(0, closed.index) : m[1];
478
+ if (/^<[A-Za-z][\w:.-]*>$/.test(token))
459
479
  continue;
460
- add(firstEnd + at(m, 1)[0] + q, firstEnd + at(m, 1)[1] - q);
480
+ const value = jsonString(token) ?? token;
481
+ const q = value === token ? 0 : 1;
482
+ // (A message's tokens are not values; `[`/`{` start a JSON document's lines.)
483
+ if (value.length >= 6 && !isMessageToken(value) && !/^[{[]/.test(value) && !notSecret(value))
484
+ add(firstEnd + at(m, 1)[0] + q, firstEnd + at(m, 1)[0] + token.length - q);
485
+ if (closed)
486
+ break;
461
487
  }
462
488
  return out;
463
489
  }
@@ -491,7 +517,7 @@ function readsAsValue(line) {
491
517
  * Whether a line is a JSON document whole: an object, an array, or a string that holds one (output encoded once more,
492
518
  * `--query SecretString`'s `"{\"password\": …}"`). A string that holds anything else is a value (jsonString).
493
519
  */
494
- function isJsonDocument(line) {
520
+ export function isJsonDocument(line) {
495
521
  if (!/^(?:[{[].*[}\]]|".*")$/.test(line))
496
522
  return false;
497
523
  try {
@@ -677,7 +703,7 @@ function writtenToSecretFile(commands) {
677
703
  for (let k = 0; k < commands.length; k++) {
678
704
  const c = commands[k];
679
705
  pipeline.push(c);
680
- if ((c.then === "|" || c.then === "|&") && commands[k + 1]?.host === c.host)
706
+ if (pipes(c) && commands[k + 1]?.host === c.host)
681
707
  continue;
682
708
  // Where it writes: a redirection's file (`>`, `>>`, `>|`, `&>`, `>&`), its groups' included, and `tee`'s.
683
709
  // (A descriptor, `2>&1`, `>&-`, is no secret file's name.)
@@ -706,9 +732,12 @@ function writtenToSecretFile(commands) {
706
732
  if (r.op === "<<<" && r.target)
707
733
  sections.push({ ...wordSpan(r.target), part });
708
734
  }
709
- const first = simpleCommand(pipeline[0].words);
710
- const program = programName(first[0]);
711
- if (program === "echo" || program === "printf") {
735
+ // (Each of the echoes that start it, a group's several: `{ echo A=1; echo B=2; } | tee .env`.)
736
+ for (const p of pipeline) {
737
+ const first = simpleCommand(p.words);
738
+ const program = programName(first[0]);
739
+ if (program !== "echo" && program !== "printf")
740
+ break;
712
741
  const args = first.slice(1).filter((w) => !/^-[neE]+$/.test(w.text));
713
742
  const operands = program === "printf" && args.length > 1 ? args.slice(1) : args;
714
743
  for (const w of operands)
package/dist/shell.d.ts CHANGED
@@ -45,6 +45,8 @@ export interface Heredoc {
45
45
  export interface ShellGroup {
46
46
  kind: string;
47
47
  redirects: ShellRedirect[];
48
+ /** The operator after the group: `|` pipes what every command in it prints (`{ echo A=1; echo B=2; } | tee .env`). */
49
+ then?: ShellOperator;
48
50
  }
49
51
  type ShellOperator = "|" | "|&" | "||" | "&&" | ";" | "&" | "\n" | "";
50
52
  export interface ShellCommand {
@@ -110,13 +112,20 @@ export declare function wordSpan(word: ShellWord, from?: number): {
110
112
  /** The command line an argument vector stands for, each word quoted as a shell needs it: `["bash", "-lc", "cat .env"]` → `bash -lc 'cat .env'`. */
111
113
  export declare function shellLine(argv: string[]): string;
112
114
  /**
113
- * A terminal prompt at the start of a line, before a command: a shell's `$ `, `user@host:~$ `, PowerShell's
114
- * `PS C:\> `, a notebook's `In [3]: !` or a cell's bare `!` before a command. Group 1 is the command.
115
+ * A terminal prompt at the start of a line, before a command. Group 1 is the command. A POSIX shell's, after a
116
+ * virtualenv's or conda's `(venv) `: `$ `, `user@host:~/app$ `, `root@3f2a9c1d:/app# `, RHEL's `[ec2-user@ip-10-0-1-5
117
+ * app]$ `, macOS zsh's `user@host app % `, a bare shell's `bash-5.1$ ` and `sh-4.2# `, busybox's `/app # `. PowerShell's
118
+ * `PS C:\> ` and `PS> `, cmd's `C:\app>` (no blank after it), a notebook's `In [3]: !` or a cell's bare `!` before a
119
+ * command. (Not root's bare `# `: a comment or a Markdown heading is written so.)
115
120
  */
116
121
  export declare const PROMPT: RegExp;
117
122
  /** Whether a word names a wrapper, a command that runs the command after it. */
118
123
  export declare function isWrapper(word: string): boolean;
119
- /** A program's name without its directory and Windows' `.exe`: `/usr/bin/grep` → `grep`, `.\\steamcmd.exe` → `steamcmd`. */
124
+ /**
125
+ * A program's name without its directory and Windows' `.exe`: `/usr/bin/grep` → `grep`, `.\\steamcmd.exe` → `steamcmd`.
126
+ * PowerShell's and cmd's commands in the case the readers know them by, as both shells take any case (`get-content`,
127
+ * `TYPE`, `Select-string`).
128
+ */
120
129
  export declare function programName(word: ShellWord | undefined): string;
121
130
  /**
122
131
  * A simple command's words from the program it runs: past assignments (`FOO=1`), reserved words (`!`, `then`, `do`)
package/dist/shell.js CHANGED
@@ -408,6 +408,7 @@ export function shellCommands(text, offset = 0, { backslash = true, oneLine = fa
408
408
  if (closed && !words.length) {
409
409
  // The redirections and the operator after a group's closer are the group's.
410
410
  closed.group.redirects.push(...redirects);
411
+ closed.group.then = then;
411
412
  const last = out.at(-1);
412
413
  if (last && out.length > closed.from)
413
414
  last.then = then;
@@ -608,7 +609,10 @@ export function wordSpan(word, from = 0) {
608
609
  let start = word.map[from];
609
610
  let end = word.ends.at(-1);
610
611
  const raw = (k) => word.raw[k - word.start];
611
- if (word.raw.slice(start - word.start, end - word.start) !== word.text.slice(from)) {
612
+ // (A span that starts and ends in the same quotes, or outside any, keeps the word's quotes about it as they were: the
613
+ // `'\''` a single-quoted password writes its quote with, `'admin:pa'\''ss'`, leaves the closing quote out.)
614
+ if (word.raw.slice(start - word.start, end - word.start) !== word.text.slice(from) &&
615
+ quoteAt(word.raw, start - word.start) !== quoteAt(word.raw, end - word.start)) {
612
616
  while (start > word.start && /["'$]/.test(raw(start - 1)))
613
617
  start--;
614
618
  while (end < word.end && /["']/.test(raw(end)))
@@ -616,15 +620,30 @@ export function wordSpan(word, from = 0) {
616
620
  }
617
621
  return { start, end };
618
622
  }
623
+ /** The quote a shell word's raw text is inside at `at`: `'`, `"`, or "" outside quotes. */
624
+ function quoteAt(raw, at) {
625
+ let quote = "";
626
+ for (let k = 0; k < at; k++) {
627
+ const c = raw[k];
628
+ if (c === "\\" && quote !== "'")
629
+ k++;
630
+ else if (quote ? c === quote : c === "'" || c === '"')
631
+ quote = quote ? "" : c;
632
+ }
633
+ return quote;
634
+ }
619
635
  /** The command line an argument vector stands for, each word quoted as a shell needs it: `["bash", "-lc", "cat .env"]` → `bash -lc 'cat .env'`. */
620
636
  export function shellLine(argv) {
621
637
  return argv.map((w) => (/^[\w@%+=:,./-]+$/.test(w) ? w : `'${w.replace(/'/g, "'\\''")}'`)).join(" ");
622
638
  }
623
639
  /**
624
- * A terminal prompt at the start of a line, before a command: a shell's `$ `, `user@host:~$ `, PowerShell's
625
- * `PS C:\> `, a notebook's `In [3]: !` or a cell's bare `!` before a command. Group 1 is the command.
640
+ * A terminal prompt at the start of a line, before a command. Group 1 is the command. A POSIX shell's, after a
641
+ * virtualenv's or conda's `(venv) `: `$ `, `user@host:~/app$ `, `root@3f2a9c1d:/app# `, RHEL's `[ec2-user@ip-10-0-1-5
642
+ * app]$ `, macOS zsh's `user@host app % `, a bare shell's `bash-5.1$ ` and `sh-4.2# `, busybox's `/app # `. PowerShell's
643
+ * `PS C:\> ` and `PS> `, cmd's `C:\app>` (no blank after it), a notebook's `In [3]: !` or a cell's bare `!` before a
644
+ * command. (Not root's bare `# `: a comment or a Markdown heading is written so.)
626
645
  */
627
- export const PROMPT = /^[ \t]*(?:\$ |In \[\d*\]:[ \t]*!|PS [^>\n]{0,200}> |[\w.-]+@[\w.-]+:[^\n$#]{0,200}[$#] |!(?=[a-z]))[ \t]*(\S[^\n]{0,2000})/;
646
+ export const PROMPT = /^[ \t]*(?:(?:\([\w.-]{1,40}\)[ \t]+)?(?:\$ |[\w.-]+@[\w.-]+:[^\n$#]{0,200}[$#] |\[[\w.-]+@[\w.-]+(?:[ \t][^\]\n]{0,200})?\][$#] |[\w.-]+@[\w.-]+[ \t]\S{1,200}[ \t][%$#] |[a-z]+-\d+(?:\.\d+)*[$#] |[~/][\w./~-]{0,200}[ \t]#[ \t])|In \[\d*\]:[ \t]*!|PS(?: [^>\n]{0,200})?> |[A-Za-z]:\\[^<>|"\n]{0,200}>|!(?=[a-z]))[ \t]*(\S[^\n]{0,2000})/;
628
647
  /** Words before a simple command's own (`!` negates it); `then`, `do`, `else` open a group's commands. */
629
648
  const RESERVED = new Set(["!", "if", "then", "else", "elif", "do", "while", "until"]);
630
649
  /**
@@ -637,7 +656,8 @@ const WRAPPERS = new Map([
637
656
  { values: "ugCDhprtTUR", long: /^--(?:user|group|chdir|host|prompt|role|type|close-from|other-user|chroot)$/ },
638
657
  ],
639
658
  ["doas", { values: "uC" }],
640
- ["runuser", { values: "ugl", long: /^--(?:user|group)$/ }],
659
+ // (`runuser -u app cmd`; with no `-u` it is su's twin, `runuser -l app -c 'cmd'`, and its `-l` is `--login`.)
660
+ ["runuser", { values: "ug", long: /^--(?:user|group)$/ }],
641
661
  ["env", { values: "uCS", long: /^--(?:unset|chdir|split-string)$/ }],
642
662
  ["timeout", { values: "sk", long: /^--(?:signal|kill-after)$/, operands: 1 }],
643
663
  ["nice", { values: "n", long: /^--adjustment$/ }],
@@ -661,10 +681,34 @@ const WRAPPERS = new Map([
661
681
  export function isWrapper(word) {
662
682
  return WRAPPERS.has(word);
663
683
  }
664
- /** A program's name without its directory and Windows' `.exe`: `/usr/bin/grep` → `grep`, `.\\steamcmd.exe` → `steamcmd`. */
684
+ /**
685
+ * A program's name without its directory and Windows' `.exe`: `/usr/bin/grep` → `grep`, `.\\steamcmd.exe` → `steamcmd`.
686
+ * PowerShell's and cmd's commands in the case the readers know them by, as both shells take any case (`get-content`,
687
+ * `TYPE`, `Select-string`).
688
+ */
665
689
  export function programName(word) {
666
- return (word?.text ?? "").replace(/^.*[/\\]/, "").replace(/\.exe$/i, "");
690
+ const name = (word?.text ?? "").replace(/^.*[/\\]/, "").replace(/\.exe$/i, "");
691
+ return WINDOWS_COMMANDS.get(name.toLowerCase()) ?? name;
667
692
  }
693
+ /** PowerShell's cmdlets and aliases and cmd's builtins that the readers name, by their names in lower case. */
694
+ const WINDOWS_COMMANDS = new Map([
695
+ "Get-Content",
696
+ "Select-String",
697
+ "Write-Output",
698
+ "Write-Host",
699
+ "Get-Item",
700
+ "Get-ChildItem",
701
+ "Get-Secret",
702
+ "gc",
703
+ "sls",
704
+ "gci",
705
+ "gi",
706
+ "type",
707
+ "findstr",
708
+ "pwsh",
709
+ "powershell",
710
+ "cmd",
711
+ ].map((name) => [name.toLowerCase(), name]));
668
712
  /**
669
713
  * A simple command's words from the program it runs: past assignments (`FOO=1`), reserved words (`!`, `then`, `do`)
670
714
  * and wrappers with their options and operands (`sudo -iu app`, `env -i`, `timeout 5`, `nice -n 10`, `xargs -I{}`).
package/dist/spans.d.ts CHANGED
@@ -24,9 +24,10 @@ export declare function isLearnable(f: Finding, value: string, minConfidence?: n
24
24
  * The secret a finding covers, as the program reads it, so one secret written two ways gets one tag and is learned
25
25
  * once: its recorded value (a known value whichever way it was written, a literal split in pieces joined, a JSON
26
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.)
27
+ * query's `%26`, `+` a blank), a double-quoted string's escapes (JSON's, `"Ab\"cd9"`, `\u0041`, or else a shell's,
28
+ * `"Ab\$cd9"`), a single-quoted one's quote written inside it (YAML's and SQL's `'Ab''cd9'`, a shell's `'Ab'\''cd9'`,
29
+ * JavaScript's and Python's `'Ab\'cd9'`), or a shell's backslashes outside quotes (`Harbor2291\!x`, `Ab\ cd9`).
30
+ * (A single-quoted string's other backslashes stay: a shell's has no escapes.)
30
31
  */
31
32
  export declare function secretValue(text: string, f: {
32
33
  start: number;
package/dist/spans.js CHANGED
@@ -59,9 +59,10 @@ export function isLearnable(f, value, minConfidence = 0.9) {
59
59
  * The secret a finding covers, as the program reads it, so one secret written two ways gets one tag and is learned
60
60
  * once: its recorded value (a known value whichever way it was written, a literal split in pieces joined, a JSON
61
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.)
62
+ * query's `%26`, `+` a blank), a double-quoted string's escapes (JSON's, `"Ab\"cd9"`, `\u0041`, or else a shell's,
63
+ * `"Ab\$cd9"`), a single-quoted one's quote written inside it (YAML's and SQL's `'Ab''cd9'`, a shell's `'Ab'\''cd9'`,
64
+ * JavaScript's and Python's `'Ab\'cd9'`), or a shell's backslashes outside quotes (`Harbor2291\!x`, `Ab\ cd9`).
65
+ * (A single-quoted string's other backslashes stay: a shell's has no escapes.)
65
66
  */
66
67
  export function secretValue(text, f) {
67
68
  if (f.value !== undefined)
@@ -81,10 +82,19 @@ export function secretValue(text, f) {
81
82
  try {
82
83
  return JSON.parse(`"${raw}"`);
83
84
  }
84
- catch { } // not JSON's escapes: the text as it is
85
+ catch { } // not JSON's escapes: a shell's, or the text as it is
86
+ return raw.replace(/\\([$`"\\])/g, "$1");
85
87
  }
86
- if (/\\[^\w\s]/.test(raw) && !/["'`]$/.test(before))
87
- return raw.replace(/\\([^\w\s])/g, "$1");
88
+ if (/'$/.test(before) && raw.includes("'")) {
89
+ // (`\'` is no shell's, whose single quotes hold no escapes: a language's that has them, `\\` included.)
90
+ if (/'\\''/.test(raw))
91
+ return raw.replace(/'\\''/g, "'");
92
+ if (/\\'/.test(raw))
93
+ return raw.replace(/\\(['\\])/g, "$1");
94
+ return raw.replace(/''/g, "'");
95
+ }
96
+ if (/\\[^\w\n]/.test(raw) && !/["'`]$/.test(before))
97
+ return raw.replace(/\\([^\w\n])/g, "$1");
88
98
  return raw;
89
99
  }
90
100
  /** The escape sequence covering position `i` (after its backslash), as [backslash, end), if `i` is inside one. */
@@ -154,8 +164,28 @@ function preferNarrower(findings) {
154
164
  return true;
155
165
  });
156
166
  }
167
+ /**
168
+ * A token's character, where a format that stops before it may have been cut and its tail is still secret: a
169
+ * percent-encoded one too (a session token's `%2B` in a URL); a dot only inside a token (`hvs.…`), not a sentence's
170
+ * full stop.
171
+ */
172
+ const TOKEN_CHAR = /^(?:[\w+/=~-]|\.\w|%[0-9A-Fa-f])/;
173
+ /**
174
+ * Whether `f`, starting with or inside a format's token `token`, runs past it only by reading on into what follows it
175
+ * (`ghp_…;git push`, `ghp_…&q=foo`, `ghp_…#top`): an unquoted value read to the next blank. The format's regex bounds
176
+ * the token, so the rest is the text after it, not more of the secret. (A finding that starts before the token, a
177
+ * quote or a prefix, or a token that stops before a token character, may have been cut: those widen.)
178
+ */
179
+ function runsPastToken(text, token, f) {
180
+ return (text !== undefined &&
181
+ layerOf(token.rule) === "format" &&
182
+ token.confidence > f.confidence &&
183
+ f.start >= token.start &&
184
+ f.end > token.end &&
185
+ !TOKEN_CHAR.test(text.slice(token.end, token.end + 3)));
186
+ }
157
187
  /** Overlapping findings as one span each, every field but the span the most confident one's. */
158
- function union(findings) {
188
+ function union(findings, text) {
159
189
  const sorted = [...findings].sort((a, b) => a.start - b.start || b.end - a.end);
160
190
  const out = [];
161
191
  for (const f of sorted) {
@@ -166,6 +196,12 @@ function union(findings) {
166
196
  out.push({ ...f });
167
197
  continue;
168
198
  }
199
+ if (runsPastToken(text, last, f))
200
+ continue;
201
+ if (runsPastToken(text, f, last)) {
202
+ out[out.length - 1] = { ...f };
203
+ continue;
204
+ }
169
205
  const end = Math.max(last.end, f.end);
170
206
  const winner = f.confidence > last.confidence ? f : last;
171
207
  // (A value goes with its own span only: `last` may start earlier or `f` end later than the winner.)
@@ -223,11 +259,46 @@ export function trimmedToValue(text, f) {
223
259
  }
224
260
  if (end - start > 1 && /[,;]/.test(text[end - 1]) && /^[ \t]+[A-Za-z_][\w.-]*=/.test(text.slice(end, end + 80)))
225
261
  end--;
262
+ else if (layerOf(f.rule) === "named" && !/["'`]/.test(q ?? ""))
263
+ end = start + nextSetting(text.slice(start, end));
226
264
  return start === f.start && end === f.end ? f : { ...f, start, end };
227
265
  }
266
+ /**
267
+ * Settings that end an unquoted value read to the next blank: `;`, `,` or `&`, a name of a word's shape (lower case,
268
+ * upper case or camel case, at least two letters), `=` and a plain value, to the end (`;username=bob`,
269
+ * `,DB_HOST=db,DB_PORT=5432`, `&page=2&sort=desc`). A password's own punctuation seldom reads that way to its end
270
+ * (`86)68/C{,C=wyhJH9`, `Nakp&nDiqWJ-K=nW`).
271
+ */
272
+ const SETTINGS_AFTER = /[;,&](?=[\w.-]*[A-Za-z][\w.-]*[A-Za-z])(?:[a-z][a-z0-9]*(?:[_.-][a-z0-9]+)*|[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*|[a-z]+(?:[A-Z][a-z0-9]+)+)=[\w.:/@+-]*(?=[;,&]|$)/y;
273
+ /**
274
+ * Where an unquoted value read to the next blank ends at the settings after it of a list it is in, with no blank
275
+ * between (`password=Kx9…;username=bob`, `A=…,B=db`): the value's length up to there, or all of it. Not in a value that
276
+ * is itself a list of settings or a URL (an `=` or `?` before), whose own separators these are (`DSN=host=db;password=…`),
277
+ * and not after a value too short to be one.
278
+ */
279
+ const readsAsName = (setting) => naturalness(setting
280
+ .slice(1, setting.indexOf("="))
281
+ .replace(/[_.-]+/g, " ")
282
+ .toLowerCase()) > 0;
283
+ function nextSetting(value) {
284
+ const list = value.search(/=+[^=;,&]|\?|:\/\//);
285
+ const separators = /[;,&]/g;
286
+ for (let m; (m = separators.exec(value)) && (list < 0 || m.index < list);) {
287
+ if (m.index < 6)
288
+ continue;
289
+ let at = m.index;
290
+ SETTINGS_AFTER.lastIndex = at;
291
+ // (A setting's name reads as words, `username`, `DB_HOST`; a random password's `;jAnr=` or `;uKj=` doesn't.)
292
+ for (let s; at < value.length && (s = SETTINGS_AFTER.exec(value)) && readsAsName(s[0]);)
293
+ at = SETTINGS_AFTER.lastIndex;
294
+ if (at === value.length)
295
+ return m.index;
296
+ }
297
+ return value.length;
298
+ }
228
299
  /** Findings merged into one span each, sorted; with `text`, kept off escape sequences. (The inputs aren't changed.) */
229
300
  export function merge(findings, text) {
230
- return union(preferNarrower(text ? findings.map((f) => onEscapes(text, f)) : findings));
301
+ return union(preferNarrower(text ? findings.map((f) => onEscapes(text, f)) : findings), text);
231
302
  }
232
303
  /** The first of `sorted` (merged, sorted by start) that ends after `start`, by binary search. */
233
304
  function firstEndingAfter(sorted, start) {
package/dist/sql.d.ts CHANGED
@@ -1,8 +1,9 @@
1
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.
2
+ * SQL rows read into cells: each value of an `INSERT … VALUES` statement, or of a `COPY … FROM stdin` block as
3
+ * pg_dump writes its data, under its table and column. One reader for every rule that asks what a value in a row is:
4
+ * the name layer hides a credential column's values (named.ts sqlRows), the random layer keeps the other columns'
5
+ * IDs and data (context.ts, `sqlCell`). A statement without a column list (`INSERT INTO t VALUES …`, as mysqldump
6
+ * writes by default) names no column, so it isn't read.
6
7
  */
7
8
  import { type Argument } from "./calls.ts";
8
9
  export interface SqlCell {
@@ -10,11 +11,14 @@ export interface SqlCell {
10
11
  table: string;
11
12
  /** The column, without quotes: `password` in `"password"`, `` `password` ``, `[password]`. */
12
13
  column: string;
13
- /** The value as an argument of the row: a string literal's content is its `literal`. */
14
+ /** The value as an argument of the row: the string it gives the column is its `literal` (sqlLiteral). */
14
15
  value: Argument;
16
+ /** The row's columns and values, the cell's among them: what a key/value table's value is named by. */
17
+ columns: readonly string[];
18
+ row: readonly Argument[];
15
19
  }
16
20
  /**
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).
21
+ * The cells of every `INSERT` and `COPY` in `text`, row by row (up to 10,000 rows a statement); with `wants`, only of
22
+ * the statements whose table and columns it accepts (so a dump's other tables cost no reading).
19
23
  */
20
- export declare function insertCells(text: string, wants?: (table: string, columns: string[]) => boolean): Generator<SqlCell>;
24
+ export declare function sqlCells(text: string, wants?: (table: string, columns: string[]) => boolean): Generator<SqlCell>;
package/dist/sql.js CHANGED
@@ -1,27 +1,36 @@
1
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.
2
+ * SQL rows read into cells: each value of an `INSERT … VALUES` statement, or of a `COPY … FROM stdin` block as
3
+ * pg_dump writes its data, under its table and column. One reader for every rule that asks what a value in a row is:
4
+ * the name layer hides a credential column's values (named.ts sqlRows), the random layer keeps the other columns'
5
+ * IDs and data (context.ts, `sqlCell`). A statement without a column list (`INSERT INTO t VALUES …`, as mysqldump
6
+ * writes by default) names no column, so it isn't read.
6
7
  */
7
- import { callArguments, closingBracket } from "./calls.js";
8
+ import { callArguments, closingBracket, literalAt } from "./calls.js";
8
9
  import { delimitedCells } from "./syntax.js";
9
10
  /** An identifier as SQL dialects quote it: `"name"`, `` `name` ``, `[name]`. */
10
11
  const unquote = (name) => name.trim().replace(/^["`[]|["`\]]$/g, "");
12
+ /** A table's name as any dialect quotes it: `"my table"`, `` `app`.`users` ``, `[dbo].[users]`. */
13
+ const TABLE = String.raw `((?:"[^"\n]{1,100}"|\x60[^\x60\n]{1,100}\x60|\[[^\]\n]{1,100}\]|[\w.]){1,200})`;
11
14
  /**
12
15
  * `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]`).
16
+ * `REPLACE INTO`).
14
17
  */
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;
18
+ const INSERT = new RegExp(String.raw `\b(?:INSERT(?:\s+IGNORE|\s+OR\s+(?:REPLACE|IGNORE|ABORT|FAIL|ROLLBACK))?|REPLACE)\s+(?:INTO\s+)?${TABLE}\s*\(([^()]{1,2000})\)\s*VALUES\s*`, "gi");
19
+ /** `COPY <table> (<columns>) FROM stdin;` and its line break: pg_dump's data, one tab-separated row a line after it. */
20
+ const COPY = new RegExp(String.raw `(?:^|\n)COPY\s+${TABLE}\s*\(([^()]{1,2000})\)\s+FROM\s+stdin\b[^;\n]{0,200};[ \t]*\r?\n`, "gi");
16
21
  /** What may stand between two rows: blanks, comments and one comma. */
17
22
  const BETWEEN_ROWS = /(?:\s|--[^\n]*|\/\*[\s\S]{0,2000}?\*\/)*,?(?:\s|--[^\n]*|\/\*[\s\S]{0,2000}?\*\/)*/y;
18
23
  /**
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).
24
+ * The cells of every `INSERT` and `COPY` in `text`, row by row (up to 10,000 rows a statement); with `wants`, only of
25
+ * the statements whose table and columns it accepts (so a dump's other tables cost no reading).
21
26
  */
22
- export function* insertCells(text, wants) {
23
- if (!/\b(?:INSERT|REPLACE)\b/i.test(text))
24
- return;
27
+ export function* sqlCells(text, wants) {
28
+ if (/\b(?:INSERT|REPLACE)\b/i.test(text))
29
+ yield* insertCells(text, wants);
30
+ if (/\bCOPY\b/i.test(text))
31
+ yield* copyCells(text, wants);
32
+ }
33
+ function* insertCells(text, wants) {
25
34
  for (const m of text.matchAll(INSERT)) {
26
35
  const table = unquote(m[1].split(".").at(-1));
27
36
  const columns = delimitedCells(m[2], ",").map((c) => unquote(c.text));
@@ -33,8 +42,10 @@ export function* insertCells(text, wants) {
33
42
  if (text[at] === "(" && closingBracket(text, at + 1) < 0) {
34
43
  const end = text.indexOf("\n", at);
35
44
  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) };
45
+ for (const cell of rowCells(line, at - m.index, table, columns)) {
46
+ const row = cell.row.map((a) => shifted(a, m.index));
47
+ yield { ...cell, value: row[cell.row.indexOf(cell.value)], row };
48
+ }
38
49
  }
39
50
  else
40
51
  yield* rowCells(text, at, table, columns);
@@ -44,17 +55,84 @@ export function* insertCells(text, wants) {
44
55
  function* rowCells(text, at, table, columns) {
45
56
  for (let row = 0; row < 10_000 && text[at] === "("; row++) {
46
57
  // (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);
58
+ const values = callArguments(text, at + 1)?.map((value) => withLiteral(text, value));
48
59
  if (!values)
49
60
  break;
50
61
  const close = closingBracket(text, at + 1);
51
62
  for (const [k, value] of values.entries())
52
63
  if (columns[k])
53
- yield { table, column: columns[k], value };
64
+ yield { table, column: columns[k], value, columns, row: values };
54
65
  BETWEEN_ROWS.lastIndex = close + 1;
55
66
  at = close + 1 + BETWEEN_ROWS.exec(text)[0].length;
56
67
  }
57
68
  }
69
+ /**
70
+ * The cells of `COPY … FROM stdin` blocks: tab-separated rows up to the `\.` line, each value as written (a `\N` is
71
+ * NULL, no value). (Up to 10,000 rows a block, as for an INSERT's.)
72
+ */
73
+ function* copyCells(text, wants) {
74
+ for (const m of text.matchAll(COPY)) {
75
+ const table = unquote(m[1].split(".").at(-1));
76
+ const columns = delimitedCells(m[2], ",").map((c) => unquote(c.text));
77
+ if (wants && !wants(table, columns))
78
+ continue;
79
+ let at = m.index + m[0].length;
80
+ for (let n = 0; n < 10_000 && at < text.length; n++) {
81
+ const stop = text.indexOf("\n", at);
82
+ const end = stop < 0 ? text.length : stop;
83
+ const line = text.slice(at, end).replace(/\r$/, "");
84
+ if (line === "\\." || !line.includes("\t"))
85
+ break;
86
+ const row = [];
87
+ let start = at;
88
+ for (const value of line.split("\t")) {
89
+ const span = { value, start, end: start + value.length };
90
+ row.push({
91
+ text: value,
92
+ start,
93
+ end: span.end,
94
+ ...(value === "\\N" ? {} : { literal: { ...span, quote: "" } }),
95
+ });
96
+ start = span.end + 1;
97
+ }
98
+ for (const [k, value] of row.entries())
99
+ if (columns[k])
100
+ yield { table, column: columns[k], value, columns, row };
101
+ at = end + 1;
102
+ }
103
+ }
104
+ }
105
+ /**
106
+ * The string a row's value gives its column, read through SQL's own ways of writing one: a prefix (`N'…'`, `E'…'`,
107
+ * calls.ts reads those), dollar quoting (`$$…$$`, `$body$…$body$`), a cast (`'…'::text`) and the functions a value is
108
+ * stored through (`crypt('…', gen_salt('bf'))`, `MD5('…')`, `encode(digest('…', 'sha256'), 'hex')`), which are given it
109
+ * first. Undefined for any other expression, and for a number.
110
+ */
111
+ function sqlLiteral(text, value, depth = 0) {
112
+ if (value.literal)
113
+ return value.literal;
114
+ const literal = literalAt(text, value.start);
115
+ if (literal && literal.end < value.end) {
116
+ const rest = text.slice(literal.end + 1, value.end);
117
+ return CAST_TAIL.test(rest) ? literal : undefined;
118
+ }
119
+ // (A function's first argument, read the same way, not too deep to stay cheap: the functions a value is stored
120
+ // through, `encode(digest('…', 'sha256'), 'hex')`, are given it first.)
121
+ const call = /^[A-Za-z_][\w.]{0,60}[ \t]*\(/.exec(value.text);
122
+ if (!call ||
123
+ depth >= 3 ||
124
+ !CAST_TAIL.test(text.slice(closingBracket(text, value.start + call[0].length) + 1, value.end)))
125
+ return undefined;
126
+ const first = callArguments(text, value.start + call[0].length)?.[0];
127
+ return first && sqlLiteral(text, first, depth + 1);
128
+ }
129
+ /** What may follow a string and leave it the value: Postgres' casts, `::text`, `::character varying(255)`, `::bytea[]`. */
130
+ const CAST_TAIL = /^(?:[ \t]*::[ \t]*[A-Za-z_][\w ."]{0,60}?(?:\([\d, ]{1,12}\))?(?:\[\])?)*[ \t]*$/;
131
+ /** A row's value with the string it gives its column as its literal (sqlLiteral). */
132
+ function withLiteral(text, value) {
133
+ const literal = sqlLiteral(text, value);
134
+ return literal && !value.literal ? { ...value, literal } : value;
135
+ }
58
136
  /** An argument read from a piece of a text, placed where the piece starts in it. */
59
137
  function shifted(a, by) {
60
138
  return {