@reneturcios/hookcheck 0.1.6 → 0.1.7

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/dist/core.js CHANGED
@@ -6,16 +6,25 @@
6
6
  */
7
7
  import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
8
8
  import { dirname, join, relative, resolve, sep } from "node:path";
9
- export const VERSION = "0.1.6";
9
+ export const VERSION = "0.1.7";
10
10
  export const RISKY_RULES = [
11
11
  { id: "DECODE-PIPE-SH", pattern: /\bbase64\s+(-d|--decode|-D)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|node|python3?|perl|ruby)\b/, evidence: "decodes a base64 payload and pipes it straight into an interpreter", severe: true, remote: true },
12
12
  { id: "CURL-PIPE-SH", pattern: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|node|python3?|perl|ruby)\b/, evidence: "pipes a network download straight into an interpreter", severe: true, remote: true },
13
13
  { id: "REMOTE-EVAL", pattern: /\beval\b[^;]{0,80}\b(curl|wget)\b|\beval\b[^;]{0,80}\$\(\s*(curl|wget)|\bFunction\s*\([^)]{0,80}(curl|wget|fetch)/, evidence: "evaluates content fetched from the network as code", severe: true, remote: true },
14
- { id: "NET-FETCH", pattern: /\b(curl|wget)\b|\bfetch\s*\(|https?\s*\.get\s*\(|requests\.(get|post)\b|Invoke-WebRequest\b|Invoke-RestMethod\b/, evidence: "performs a network fetch", remote: true },
14
+ // NET-FETCH (P-062): the curl and wget alternatives are NOT in this
15
+ // pattern. A fetch binary counts only in command position (see
16
+ // fetchPositions below): the token curl naming a package in an install
17
+ // line (pacman -S curl, apt-get install curl) is a package name being
18
+ // installed, not a command being run, and matching it anywhere in the
19
+ // step manufactured a phantom fetch on evilmartians/lefthook.
20
+ { id: "NET-FETCH", pattern: /\bfetch\s*\(|https?\s*\.get\s*\(|requests\.(get|post)\b|Invoke-WebRequest\b|Invoke-RestMethod\b/, evidence: "performs a network fetch", remote: true },
15
21
  { id: "REMOTE-URL", pattern: /https?:\/\/[^\s"'`]+/, evidence: "references a remote URL", remote: true },
16
22
  { id: "OBFUSCATION", pattern: /base64\s+(-d|--decode)|atob\s*\(|Buffer\.from\s*\([^)]*base64|\\x[0-9a-fA-F]{2}|\beval\b\s*\(|\beval\b\s+["'$(]|Function\s*\(/, evidence: "uses decoding or dynamic evaluation often used to hide payloads" },
17
23
  { id: "WRITE-OUTSIDE", pattern: /(>>?|tee)\s+~\/|(>>?|tee)\s+\$HOME\/|chmod\s+\+x/, evidence: "writes outside the repo or changes executable bits" },
18
- { id: "DOWNLOAD-EXEC", pattern: /\bnode\s+-e\b|\bpython3?\s+-c\b|\bdeno\s+eval\b|\bbun\s+-e\b/, evidence: "executes inline code supplied on the command line" },
24
+ // DOWNLOAD-EXEC is detected procedurally (see detectDownloadExec):
25
+ // inline code execution counts only when the evaluated content itself
26
+ // carries a download or remote reference (P-064). A constant
27
+ // node -e version comparison downloads nothing.
19
28
  ];
20
29
  /**
21
30
  * Rule metadata for procedurally detected rules (credential access and env
@@ -84,9 +93,12 @@ function inRanges(idx, ranges) {
84
93
  * disclosure next to the prose word "fetch" in a docstring.
85
94
  */
86
95
  function refOutsideData(seg, name, segStart, dataRanges) {
96
+ return refOutsideRanges(seg, name, segStart, dataRanges);
97
+ }
98
+ function refOutsideRanges(seg, name, segStart, ranges) {
87
99
  const re = new RegExp(`\\b${name.replace(/\$/g, "\\$")}\\b`, "g");
88
100
  for (const m of seg.matchAll(re)) {
89
- if (!inRanges(segStart + (m.index ?? 0), dataRanges))
101
+ if (!inRanges(segStart + (m.index ?? 0), ranges))
90
102
  return true;
91
103
  }
92
104
  return false;
@@ -389,6 +401,159 @@ function firstShellWord(segment) {
389
401
  const w = s.match(/^[A-Za-z_][A-Za-z0-9_.-]*/);
390
402
  return w ? w[0] : "";
391
403
  }
404
+ /**
405
+ * Command position for fetch binaries (P-062). The word curl inside a
406
+ * step is not necessarily a fetch: in `pacman -S --noconfirm openssh
407
+ * git curl` it is a package name in argument position, and counting it
408
+ * as NET-FETCH manufactured an exfil verdict on evilmartians/lefthook
409
+ * (the phantom fetch combined with a credential store write in the same
410
+ * step). A fetch counts only when curl or wget is the invoked command:
411
+ * the head of a statement or pipeline segment, after wrappers (sudo,
412
+ * env, command, xargs, shell keywords) and environment assignments, or
413
+ * the head of a command string literal (the string handed to
414
+ * bash -c, execSync, os.system, and friends IS a command). Positions
415
+ * are returned as indices of the fetch word in the input text.
416
+ */
417
+ const FETCH_WRAPPERS = new Set(["sudo", "doas", "env", "command", "builtin", "exec", "time", "nice", "nohup", "xargs", "if", "then", "else", "elif", "while", "until", "do", "!"]);
418
+ const WRAPPER_ARG_FLAGS = new Set(["-u", "-I", "-n", "-L", "-P", "-s", "-E", "-e", "-C", "-S"]);
419
+ /** Head word of one pipeline segment, with the fetch word offset. */
420
+ function segmentHead(seg) {
421
+ let i = 0;
422
+ let lastWrapper = "";
423
+ for (let iter = 0; iter < 16; iter++) {
424
+ while (i < seg.length && /[\s'"`([{\\]/.test(seg[i]))
425
+ i++;
426
+ if ((seg[i] === "$" && seg[i + 1] === "{") || (seg[i] === "$" && seg[i + 1] === "(")) {
427
+ i += 2;
428
+ continue;
429
+ }
430
+ if (i >= seg.length)
431
+ return null;
432
+ const m = seg.slice(i).match(/^[^\s|;&]+/);
433
+ if (!m)
434
+ return null;
435
+ const tok = m[0];
436
+ if (tok.startsWith("-") && lastWrapper) {
437
+ // A flag belonging to the wrapper (env -u NAME, xargs -n 1):
438
+ // skip the flag and, for flags that take a value, its argument.
439
+ i += tok.length;
440
+ if (WRAPPER_ARG_FLAGS.has(tok)) {
441
+ while (i < seg.length && /\s/.test(seg[i]))
442
+ i++;
443
+ const arg = seg.slice(i).match(/^[^\s|;&]+/);
444
+ if (arg)
445
+ i += arg[0].length;
446
+ }
447
+ continue;
448
+ }
449
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) {
450
+ i += tok.length;
451
+ lastWrapper = "";
452
+ continue;
453
+ }
454
+ const base = (tok.split("/").pop() ?? tok).replace(/['"`)\]]+$/, "");
455
+ if (FETCH_WRAPPERS.has(base)) {
456
+ lastWrapper = base;
457
+ i += tok.length;
458
+ continue;
459
+ }
460
+ const slash = tok.lastIndexOf("/");
461
+ return { word: base, idx: i + (slash === -1 ? 0 : slash + 1) };
462
+ }
463
+ return null;
464
+ }
465
+ /** Quoted literal content ranges (simple scanner, escape aware). */
466
+ function quotedContentRanges(text) {
467
+ const ranges = [];
468
+ let i = 0;
469
+ while (i < text.length) {
470
+ const c = text[i];
471
+ if (c === "'" || c === '"' || c === "`") {
472
+ let j = i + 1;
473
+ let depth = 0;
474
+ while (j < text.length) {
475
+ if (text[j] === "\\") {
476
+ j += 2;
477
+ continue;
478
+ }
479
+ if (c === "`" && text[j] === "$" && text[j + 1] === "{") {
480
+ depth++;
481
+ j += 2;
482
+ continue;
483
+ }
484
+ if (depth > 0 && text[j] === "}") {
485
+ depth--;
486
+ j++;
487
+ continue;
488
+ }
489
+ if (text[j] === c && depth === 0)
490
+ break;
491
+ j++;
492
+ }
493
+ ranges.push({ start: i + 1, end: j });
494
+ i = j + 1;
495
+ continue;
496
+ }
497
+ i++;
498
+ }
499
+ return ranges;
500
+ }
501
+ /**
502
+ * Indices of curl and wget words in command position in `text`.
503
+ * Command string literals are searched recursively (depth 2): a literal
504
+ * is a command string when code hands it to an interpreter, and the
505
+ * views this runs on have already blanked data literals in script
506
+ * files and echo arguments in shell text, so a quoted mention of the
507
+ * word in data does not produce a position.
508
+ */
509
+ function fetchPositions(text, depth = 0) {
510
+ const out = new Set();
511
+ for (const stmt of splitShell(text, ["\n", ";", "&&", "||"])) {
512
+ const stmtText = text.slice(stmt.start, stmt.end);
513
+ for (const seg of splitShell(stmtText, ["|"])) {
514
+ const segText = stmtText.slice(seg.start, seg.end);
515
+ const head = segmentHead(segText);
516
+ if (head && (head.word === "curl" || head.word === "wget")) {
517
+ out.add(stmt.start + seg.start + head.idx);
518
+ }
519
+ }
520
+ }
521
+ if (depth < 2) {
522
+ for (const r of quotedContentRanges(text)) {
523
+ const inner = text.slice(r.start, r.end);
524
+ if (!/\b(?:curl|wget)\b/.test(inner))
525
+ continue;
526
+ for (const p of fetchPositions(inner, depth + 1))
527
+ out.add(r.start + p);
528
+ }
529
+ // Interpreter -c and -e tails are command strings too, even when
530
+ // the quotes are not visible in the text under analysis: an MCP
531
+ // config's command and args are joined with spaces, so a server
532
+ // configured as sh with args [-c, "curl ... | sh"] reaches the
533
+ // classifier as the unquoted text `sh -c curl ... | sh`, whose
534
+ // pipeline head is sh. The tail after the -c flag is the program.
535
+ for (const m of text.matchAll(/\b(?:sh|bash|zsh|dash)\s+-c\s+|\b(?:node|bun|python3?|perl|ruby)\s+-(?:e|c)\s+/g)) {
536
+ const start = m.index + m[0].length;
537
+ const tail = text.slice(start);
538
+ if (!/\b(?:curl|wget)\b/.test(tail))
539
+ continue;
540
+ for (const p of fetchPositions(tail, depth + 1))
541
+ out.add(start + p);
542
+ }
543
+ }
544
+ return out;
545
+ }
546
+ /** True when a CURL-PIPE-SH match in `codeJoined` starts a real fetch. */
547
+ function curlPipeIsCommandFetch(codeJoined, positions) {
548
+ if (positions.size === 0)
549
+ return false;
550
+ for (const m of codeJoined.matchAll(/\b(curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|node|python3?|perl|ruby)\b/g)) {
551
+ const at = m.index + m[0].indexOf(m[1]);
552
+ if (positions.has(at))
553
+ return true;
554
+ }
555
+ return false;
556
+ }
392
557
  /**
393
558
  * Content ranges of echo and printf string arguments in shell text:
394
559
  * output data, blanked in the code view, unless the pipeline ends in a
@@ -442,10 +607,20 @@ function shellEchoDataRanges(text) {
442
607
  }
443
608
  return ranges;
444
609
  }
610
+ const NETWORK_CALL_RES = [
611
+ /\bfetch\s*\(/g,
612
+ /\baxios(?:\s*\.\s*\w+)?\s*\(/g,
613
+ /\brequests\s*\.\s*\w+\s*\(/g,
614
+ /\bhttpx\s*\.\s*\w+\s*\(/g,
615
+ /\bhttps?\s*\.\s*(?:request|get)\s*\(/g,
616
+ /\burlopen\s*\(/g,
617
+ /\bsendBeacon\s*\(/g,
618
+ /\bWebSocket\s*\(/g,
619
+ ];
445
620
  function buildViews(text, lang) {
446
621
  if (lang === "shell") {
447
622
  const dataRanges = shellEchoDataRanges(text);
448
- return { codeText: blankRanges(text, dataRanges), credText: text, dataRanges };
623
+ return { codeText: blankRanges(text, dataRanges), credText: text, dataRanges, urlText: text };
449
624
  }
450
625
  const literals = lang === "python" ? scanPyLiterals(text) : scanJsLiterals(text);
451
626
  const execSpans = callFirstArgSpans(text, EXEC_CALLS, literals);
@@ -474,7 +649,24 @@ function buildViews(text, lang) {
474
649
  // "code" literals keep their contents in both views: the text is a
475
650
  // program handed to an interpreter, and behavior rules should see it.
476
651
  }
477
- return { codeText: blankRanges(text, codeBlank), credText: blankRanges(text, credBlank), dataRanges };
652
+ const codeText = blankRanges(text, codeBlank);
653
+ // URL view (P-064): restore literal contents handed to network calls.
654
+ const netSpans = [];
655
+ for (const re of NETWORK_CALL_RES) {
656
+ let m;
657
+ while ((m = re.exec(text)) !== null)
658
+ netSpans.push(callSpan(text, m.index + m[0].length - 1));
659
+ }
660
+ const urlChars = codeText.split("");
661
+ for (const lit of literals) {
662
+ if (!netSpans.some((s) => lit.contentStart >= s.start && lit.contentEnd <= s.end))
663
+ continue;
664
+ for (const r of contentRanges(lit)) {
665
+ for (let i = r.start; i < r.end && i < urlChars.length; i++)
666
+ urlChars[i] = text[i];
667
+ }
668
+ }
669
+ return { codeText, credText: blankRanges(text, credBlank), dataRanges, urlText: urlChars.join("") };
478
670
  }
479
671
  const CRED_FILE_PATTERNS = [
480
672
  /~\/\.ssh(\/|\s|$|["'])/, /\$HOME\/\.ssh(\/|\s|$|["'])/, /(^|[\s"'(=])\.ssh\//,
@@ -650,6 +842,164 @@ function findAtoms(text, dataRanges) {
650
842
  * Round 2 showed it driving false exfil verdicts on langfuse, PostHog,
651
843
  * and cas release automation (P-018).
652
844
  */
845
+ /** Consume a balanced bracket span starting at the opener index. */
846
+ function consumeBalanced(text, openIdx) {
847
+ const open = text[openIdx];
848
+ const close = open === "(" ? ")" : open === "{" ? "}" : open === "[" ? "]" : "";
849
+ if (!close)
850
+ return openIdx + 1;
851
+ let depth = 0;
852
+ let quote = "";
853
+ for (let j = openIdx; j < text.length; j++) {
854
+ const c = text[j];
855
+ if (quote) {
856
+ if (c === "\\") {
857
+ j++;
858
+ continue;
859
+ }
860
+ if (c === quote)
861
+ quote = "";
862
+ continue;
863
+ }
864
+ if (c === "'" || c === '"' || c === "`") {
865
+ quote = c;
866
+ continue;
867
+ }
868
+ if (c === open)
869
+ depth++;
870
+ else if (c === close) {
871
+ depth--;
872
+ if (depth === 0)
873
+ return j + 1;
874
+ }
875
+ }
876
+ return text.length;
877
+ }
878
+ /** Consume one string literal starting at (or just before) index i. */
879
+ function consumeLiteral(text, i) {
880
+ let j = i;
881
+ while (j < text.length && /[furbFURB]/.test(text[j]))
882
+ j++;
883
+ const q = text[j];
884
+ if (q !== "'" && q !== '"' && q !== "`")
885
+ return i;
886
+ const triple = text.startsWith(q + q + q, j);
887
+ const qLen = triple ? 3 : 1;
888
+ let k = j + qLen;
889
+ let depth = 0;
890
+ while (k < text.length) {
891
+ if (text[k] === "\\") {
892
+ k += 2;
893
+ continue;
894
+ }
895
+ if ((q === "`" && text[k] === "$" && text[k + 1] === "{") || (q !== "`" && depth >= 0 && text[k] === "{" && /[fF]/.test(text.slice(Math.max(0, j - 1), j + 1)))) {
896
+ depth++;
897
+ k += q === "`" ? 2 : 1;
898
+ continue;
899
+ }
900
+ if (depth > 0 && text[k] === "}") {
901
+ depth--;
902
+ k++;
903
+ continue;
904
+ }
905
+ if (depth > 0 && (text[k] === "'" || text[k] === '"' || text[k] === "`")) {
906
+ k = consumeLiteral(text, k);
907
+ continue;
908
+ }
909
+ if (triple ? text.startsWith(q + q + q, k) : text[k] === q)
910
+ return k + qLen;
911
+ if (!triple && text[k] === "\n" && q !== "`")
912
+ return k;
913
+ k++;
914
+ }
915
+ return k;
916
+ }
917
+ /**
918
+ * Consume a value expression starting at `start`: a string literal
919
+ * (including template and f-string forms), a bare variable or dotted
920
+ * chain, a call, a bracketed value, or a +-concatenation of those.
921
+ * Returns the end index. Capped so a malformed value cannot run away.
922
+ */
923
+ function consumeValueExpr(text, start) {
924
+ let i = start;
925
+ const cap = Math.min(text.length, start + 400);
926
+ for (let part = 0; part < 6; part++) {
927
+ while (i < cap && /\s/.test(text[i]))
928
+ i++;
929
+ if (i >= cap)
930
+ break;
931
+ const c = text[i];
932
+ if (c === "'" || c === '"' || c === "`" || (/[furbFURB]/.test(c) && /["'`]/.test(text[i + 1] ?? ""))) {
933
+ i = consumeLiteral(text, i);
934
+ }
935
+ else if (c === "(" || c === "{" || c === "[") {
936
+ i = consumeBalanced(text, i);
937
+ }
938
+ else if (/[A-Za-z0-9_$]/.test(c)) {
939
+ while (i < cap && /[A-Za-z0-9_$.]/.test(text[i]))
940
+ i++;
941
+ if (text[i] === "[")
942
+ i = consumeBalanced(text, i);
943
+ while (i < cap && /\s/.test(text[i]))
944
+ i++;
945
+ if (text[i] === "(")
946
+ i = consumeBalanced(text, i);
947
+ // A template literal directly after a chain (tag or suffix).
948
+ while (i < cap && /\s/.test(text[i]))
949
+ i++;
950
+ if (text[i] === "`")
951
+ i = consumeLiteral(text, i);
952
+ }
953
+ else {
954
+ break;
955
+ }
956
+ let j = i;
957
+ while (j < cap && /\s/.test(text[j]))
958
+ j++;
959
+ if (text[j] === "+") {
960
+ i = j + 1;
961
+ continue;
962
+ }
963
+ break;
964
+ }
965
+ return i;
966
+ }
967
+ const AUTH_HEADER_KEY = /^(authorization|proxy-authorization|api[-_]?key|apikey|x-api-key|x-auth[-_]?token|auth[-_]?token|x-access[-_]?token|access[-_]?token|x-session[-_]?token|session[-_]?token)$/i;
968
+ function isAuthHeaderKey(key) {
969
+ const k = key.toLowerCase();
970
+ return AUTH_HEADER_KEY.test(k) || k.endsWith("-api-key") || k.endsWith("-apikey") || k.endsWith("-auth-token") || k.endsWith("-access-token");
971
+ }
972
+ /**
973
+ * Spans where a credential is used as authentication for the destination
974
+ * it belongs to: the userinfo of a URL (x-access-token pushes and clones
975
+ * to the repo's own host), the value of an Authorization header, and the
976
+ * value of a CLI auth flag (--token, --auth, --api-key). A token in one
977
+ * of these positions authenticates the send; it is not the payload, and
978
+ * Round 2 showed it driving false exfil verdicts on langfuse, PostHog,
979
+ * and cas release automation (P-018).
980
+ *
981
+ * Wave B (P-061): the v0.1.4 scrubbing recognized only the literal
982
+ * Authorization spelling in shell text. Real estates also authenticate
983
+ * with custom header names (statsig-api-key, x-api-key, any name at
984
+ * all) and with headers dictionaries inside one hop Python and
985
+ * TypeScript files, and those shapes still fired ENV-EXFIL on
986
+ * anthropics/claude-code, hesreallyhim/awesome-claude-code, and
987
+ * Aider-AI/aider. Authentication positions now also include:
988
+ * - any curl -H or --header argument, whatever the header name: the
989
+ * whole header value is an authentication position;
990
+ * - a dict or object entry whose key is an authentication header
991
+ * name (Authorization, *-api-key, *-auth-token, and friends),
992
+ * whatever the surrounding code;
993
+ * - any header-shaped entry (a header name key) inside a headers
994
+ * construction: a dict assigned to a variable named like headers,
995
+ * a headers= keyword argument, or a headers property of a fetch
996
+ * or request options object;
997
+ * - header setter calls (set, append, setHeader) and Python
998
+ * requests-style auth= tuples.
999
+ * A credential that reaches a payload position (body, query, URL path,
1000
+ * printed output next to a send) still fires: only the value
1001
+ * expressions of these header positions are scrubbed.
1002
+ */
653
1003
  function authRanges(text) {
654
1004
  const ranges = [];
655
1005
  for (const m of text.matchAll(/https?:\/\/[^\s"'`]+/g)) {
@@ -672,10 +1022,67 @@ function authRanges(text) {
672
1022
  for (const m of text.matchAll(/--(?:token|auth|api[-_]?key|password|secret)(?:\s*=\s*|\s+)["']?/gi)) {
673
1023
  ranges.push(valueAfter(m.index + m[0].length));
674
1024
  }
1025
+ // curl -H and --header arguments: any header name is authentication.
1026
+ for (const m of text.matchAll(/(?:^|[\s;|&])(?:-H|--header)(?:\s+|=)/g)) {
1027
+ let i = m.index + m[0].length;
1028
+ while (i < text.length && /\s/.test(text[i]))
1029
+ i++;
1030
+ if (i >= text.length)
1031
+ continue;
1032
+ if (text[i] === "'" || text[i] === '"' || text[i] === "`") {
1033
+ const end = consumeLiteral(text, i);
1034
+ ranges.push({ start: i + 1, end: Math.max(i + 1, end - 1) });
1035
+ }
1036
+ else {
1037
+ ranges.push(valueAfter(i));
1038
+ }
1039
+ }
1040
+ // Header setter calls and requests-style auth= tuples.
1041
+ for (const m of text.matchAll(/\b(?:set|append|setHeader|addHeader|header)\s*\(\s*["'][A-Za-z0-9-]+["']\s*,\s*/g)) {
1042
+ const end = consumeValueExpr(text, m.index + m[0].length);
1043
+ if (end > m.index + m[0].length)
1044
+ ranges.push({ start: m.index + m[0].length, end });
1045
+ }
1046
+ for (const m of text.matchAll(/\bauth\s*=\s*\(/g)) {
1047
+ ranges.push({ start: m.index, end: consumeBalanced(text, m.index + m[0].length - 1) });
1048
+ }
1049
+ // Dict and object entries: authentication header keys anywhere, and
1050
+ // any header-shaped key inside a headers construction.
1051
+ const entryRe = /(?:^|[{,;\n])\s*(?:(["'])([A-Za-z][A-Za-z0-9_-]*)\1|([A-Za-z][A-Za-z0-9_-]*))\s*:\s*/g;
1052
+ let em;
1053
+ while ((em = entryRe.exec(text)) !== null) {
1054
+ const key = em[2] ?? em[3] ?? "";
1055
+ if (!key)
1056
+ continue;
1057
+ const valueStart = em.index + em[0].length;
1058
+ let auth = isAuthHeaderKey(key);
1059
+ if (!auth) {
1060
+ // Headers construction context: the nearest opening brace before
1061
+ // this entry is introduced by a headers assignment, property, or
1062
+ // keyword argument.
1063
+ const brace = text.lastIndexOf("{", em.index);
1064
+ if (brace !== -1 && em.index - brace < 2000) {
1065
+ const before = text.slice(Math.max(0, brace - 90), brace);
1066
+ auth = /(?:^|[\s{,;])(?:[A-Za-z_$][A-Za-z0-9_$]*headers[A-Za-z0-9_$]*|headers)\s*[:=]\s*$/i.test(before)
1067
+ || /[A-Za-z_$][A-Za-z0-9_$]*[Hh]eaders[A-Za-z0-9_$]*\s*=\s*$/.test(before);
1068
+ }
1069
+ }
1070
+ if (!auth)
1071
+ continue;
1072
+ const end = consumeValueExpr(text, valueStart);
1073
+ if (end > valueStart)
1074
+ ranges.push({ start: valueStart, end });
1075
+ }
675
1076
  return ranges;
676
1077
  }
677
- /** Variables assigned (transitively) from a credential value expression. */
678
- function taintedVars(text, atoms, dataRanges) {
1078
+ /**
1079
+ * Variables assigned (transitively) from a credential value expression.
1080
+ * References inside authentication positions do not propagate taint
1081
+ * (P-061): a headers dict whose only credential use is its
1082
+ * Authorization value is an authentication container, not a tainted
1083
+ * payload, and passing it to fetch does not become exfil.
1084
+ */
1085
+ function taintedVars(text, atoms, dataRanges, auth = []) {
679
1086
  const tainted = new Set();
680
1087
  const assignRe = /(?:^|[;\n,])\s*(?:export\s+|const\s+|let\s+|var\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*=(?![=<>])\s*/g;
681
1088
  const assigns = [];
@@ -698,7 +1105,7 @@ function taintedVars(text, atoms, dataRanges) {
698
1105
  }
699
1106
  const rhs = text.slice(a.rhsStart, a.rhsEnd);
700
1107
  for (const v of tainted) {
701
- if (refOutsideData(rhs, v, a.rhsStart, dataRanges)) {
1108
+ if (refOutsideRanges(rhs, v, a.rhsStart, [...dataRanges, ...auth])) {
702
1109
  tainted.add(a.name);
703
1110
  break;
704
1111
  }
@@ -736,14 +1143,14 @@ function callSpan(text, openIdx) {
736
1143
  }
737
1144
  return { start: openIdx, end: text.length };
738
1145
  }
739
- function spanHasTaint(text, span, atoms, tainted, dataRanges) {
1146
+ function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
740
1147
  if (atoms.some((a) => !a.inData && a.start >= span.start && a.start < span.end))
741
1148
  return true;
742
1149
  if (tainted.size === 0)
743
1150
  return false;
744
1151
  const seg = text.slice(span.start, span.end);
745
1152
  for (const v of tainted) {
746
- if (refOutsideData(seg, v, span.start, dataRanges))
1153
+ if (refOutsideRanges(seg, v, span.start, [...dataRanges, ...auth]))
747
1154
  return true;
748
1155
  }
749
1156
  return false;
@@ -774,8 +1181,8 @@ function analyzeExfil(text, views, lang) {
774
1181
  const dataRanges = views.dataRanges;
775
1182
  const auth = authRanges(text);
776
1183
  const atoms = findAtoms(text, dataRanges).filter((a) => !inRanges(a.start, auth));
777
- const tainted = taintedVars(text, atoms, dataRanges);
778
- const has = (span) => spanHasTaint(text, span, atoms, tainted, dataRanges);
1184
+ const tainted = taintedVars(text, atoms, dataRanges, auth);
1185
+ const has = (span) => spanHasTaint(text, span, atoms, tainted, dataRanges, auth);
779
1186
  const callRes = [
780
1187
  /\bfetch\s*\(/g,
781
1188
  /\baxios(?:\s*\.\s*(?:post|put|patch|get|request|delete))?\s*\(/g,
@@ -792,9 +1199,21 @@ function analyzeExfil(text, views, lang) {
792
1199
  return true;
793
1200
  }
794
1201
  }
1202
+ // Fetch positions run on the code view with data blanked (indices
1203
+ // align with `text`: every view transform preserves length). A curl
1204
+ // word in argument position (P-062) opens no exfil path.
1205
+ const positionText = blankRanges(views.codeText, dataRanges);
1206
+ const positions = fetchPositions(positionText);
795
1207
  const statements = splitShell(text, ["\n", ";", "&&", "||"]);
796
1208
  for (const stmt of statements) {
797
- if (!/\b(?:curl|wget)\b/.test(text.slice(stmt.start, stmt.end)))
1209
+ let isFetchStmt = false;
1210
+ for (const p of positions) {
1211
+ if (p >= stmt.start && p < stmt.end) {
1212
+ isFetchStmt = true;
1213
+ break;
1214
+ }
1215
+ }
1216
+ if (!isFetchStmt)
798
1217
  continue;
799
1218
  if (has(stmt))
800
1219
  return true;
@@ -825,8 +1244,8 @@ function analyzeExfil(text, views, lang) {
825
1244
  // a code position (an f-string or template interpolation hole is code;
826
1245
  // prose inside a plain string is not). In shell text, echo and printf
827
1246
  // arguments ARE the printed values, so references inside them count.
828
- const gateText = blankRanges(views.codeText, dataRanges);
829
- const hasSendConstruct = /\b(?:fetch|axios|requests\s*\.|urlopen|https?\s*\.\s*(?:request|get)|curl|wget|nc|netcat)\b/.test(gateText);
1247
+ const gateText = positionText;
1248
+ const hasSendConstruct = /\b(?:fetch|axios|requests\s*\.|urlopen|https?\s*\.\s*(?:request|get)|nc|netcat)\b/.test(gateText) || positions.size > 0;
830
1249
  if (hasSendConstruct) {
831
1250
  for (const m of text.matchAll(/(?:^|[;\n|&]\s*)(?:echo|printf)\s+([^\n;|]*)/g)) {
832
1251
  const span = { start: m.index, end: m.index + m[0].length };
@@ -835,7 +1254,7 @@ function analyzeExfil(text, views, lang) {
835
1254
  const argText = m[1] ?? "";
836
1255
  const argStart = m.index + m[0].length - argText.length;
837
1256
  for (const v of tainted) {
838
- if (lang === "shell" || refOutsideData(argText, v, argStart, dataRanges))
1257
+ if (lang === "shell" || refOutsideRanges(argText, v, argStart, [...dataRanges, ...auth]))
839
1258
  return true;
840
1259
  }
841
1260
  }
@@ -846,7 +1265,7 @@ function analyzeExfil(text, views, lang) {
846
1265
  const argText = m[1] ?? "";
847
1266
  const argStart = m.index + m[0].indexOf(argText);
848
1267
  for (const v of tainted) {
849
- if (lang === "shell" || refOutsideData(argText, v, argStart, dataRanges))
1268
+ if (lang === "shell" || refOutsideRanges(argText, v, argStart, [...dataRanges, ...auth]))
850
1269
  return true;
851
1270
  }
852
1271
  }
@@ -866,6 +1285,39 @@ const SAFE_PATTERNS = [
866
1285
  /^patch-package$/i,
867
1286
  /^simple-git-hooks$/i,
868
1287
  ];
1288
+ /**
1289
+ * DOWNLOAD-EXEC (P-064). Inline code execution (node -e, python -c,
1290
+ * deno eval, bun -e) is download-exec only when the evaluated content
1291
+ * itself carries a download or remote reference: a URL, a fetch or
1292
+ * request call, a network module. Constant inline code (a node -e
1293
+ * version comparison in modelcontextprotocol/servers, uv run python -c
1294
+ * file parsers in wshobson/agents) downloads nothing and is not
1295
+ * download-exec; labeling it RISKY was pure noise.
1296
+ */
1297
+ function detectDownloadExec(codeText) {
1298
+ const re = /\b(?:node|bun)\s+-(?:e|-eval)\b|\bdeno\s+eval\b|\bpython3?\s+-c\b/g;
1299
+ const netRef = /https?:\/\/|\bfetch\s*\(|\bcurl\b|\bwget\b|\brequests\b|urllib|urlopen|\baxios\b|https?\s*\.\s*(?:get|request)|XMLHttpRequest|\bnet\s*\.\s*connect|\bsocket\b/i;
1300
+ let m;
1301
+ while ((m = re.exec(codeText)) !== null) {
1302
+ let i = m.index + m[0].length;
1303
+ while (i < codeText.length && /\s/.test(codeText[i]))
1304
+ i++;
1305
+ let content = "";
1306
+ if (codeText[i] === "'" || codeText[i] === '"' || codeText[i] === "`") {
1307
+ const end = consumeLiteral(codeText, i);
1308
+ content = codeText.slice(i, end);
1309
+ }
1310
+ else {
1311
+ let j = i;
1312
+ while (j < codeText.length && codeText[j] !== "\n" && codeText[j] !== ";" && codeText[j] !== "|")
1313
+ j++;
1314
+ content = codeText.slice(i, j);
1315
+ }
1316
+ if (netRef.test(content))
1317
+ return true;
1318
+ }
1319
+ return false;
1320
+ }
869
1321
  export function classifyCommand(command, lang = "shell") {
870
1322
  // Strip comments first (a docs URL in a comment is not a fetch), then
871
1323
  // normalize shell line continuations and stray carriage returns, so a
@@ -884,12 +1336,34 @@ export function classifyCommand(command, lang = "shell") {
884
1336
  const views = buildViews(cmd, lang);
885
1337
  const joined = cmd.replace(/\n/g, " ");
886
1338
  const codeJoined = views.codeText.replace(/\n/g, " ");
1339
+ const urlJoined = views.urlText.replace(/\n/g, " ");
1340
+ // Fetch positions are computed on the newline preserving code view:
1341
+ // newlines are statement separators, and flattening them first would
1342
+ // glue an install line and the curl command after it into one
1343
+ // statement whose head is the package manager. Indices align with
1344
+ // the joined views because joining only swaps newline for space.
1345
+ const positions = fetchPositions(views.codeText);
887
1346
  const fired = [];
888
1347
  for (const rule of RISKY_RULES) {
889
- const hay = rule.id === "REMOTE-URL" ? joined : codeJoined;
1348
+ if (rule.id === "NET-FETCH") {
1349
+ // P-062: the pattern covers the non-curl fetch forms; curl and
1350
+ // wget count only in command position.
1351
+ if (rule.pattern.test(codeJoined) || positions.size > 0)
1352
+ fired.push({ id: rule.id, evidence: rule.evidence });
1353
+ continue;
1354
+ }
1355
+ if (rule.id === "CURL-PIPE-SH") {
1356
+ if (curlPipeIsCommandFetch(codeJoined, positions))
1357
+ fired.push({ id: rule.id, evidence: rule.evidence });
1358
+ continue;
1359
+ }
1360
+ const hay = rule.id === "REMOTE-URL" ? urlJoined : codeJoined;
890
1361
  if (rule.pattern.test(hay))
891
1362
  fired.push({ id: rule.id, evidence: rule.evidence });
892
1363
  }
1364
+ if (detectDownloadExec(views.codeText)) {
1365
+ fired.push({ id: "DOWNLOAD-EXEC", evidence: "executes inline code that itself downloads or references remote content" });
1366
+ }
893
1367
  // Loopback downgrade (P-004): a fetch whose every URL is loopback is a
894
1368
  // local health check shape, not a network exfil shape. Downgrade only
895
1369
  // when no severe rule fired.
@@ -1136,6 +1610,48 @@ function escalateWithScriptChain(root, scripts, currentName, finding, baseAbs =
1136
1610
  * read, a download by itself) classify RISKY at most, and a RISKY-only
1137
1611
  * estate verdicts REVIEW, never COMPROMISED-PATTERN.
1138
1612
  */
1613
+ /**
1614
+ * Vendor installer pipe calibration (P-063, Wave B). A CI workflow step
1615
+ * whose ONLY severe evidence is a curl pipe to a named vendor's
1616
+ * official installer over HTTPS (the uv installer at astral.sh, the
1617
+ * opencode installer) is the same family as the esbuild and puppeteer
1618
+ * download-own-binary installers the v0.1.2 calibration already holds
1619
+ * at REVIEW: the pattern match is literal and the finding stays RISKY,
1620
+ * but nothing about the step exfils, persists, or touches a credential.
1621
+ * The narrowing is deliberately tight; ALL of these must hold:
1622
+ * - the finding is a GitHub workflow run step (CI context). The same
1623
+ * pipe on an auto-exec hook surface (package.json script, Claude
1624
+ * settings or plugin hook, folderOpen task) keeps the top verdict:
1625
+ * a hook runs on a developer machine at open or install time,
1626
+ * which is exactly the Shai-Hulud persistence shape;
1627
+ * - CURL-PIPE-SH is the only severe rule, and every rule present is
1628
+ * part of the pipe shape itself (CURL-PIPE-SH, NET-FETCH,
1629
+ * REMOTE-URL). Any credential rule, persistence rule, or second
1630
+ * severe signal disqualifies the finding;
1631
+ * - the piped URL is HTTPS and installer shaped: a path segment
1632
+ * named install, installer, or setup (optionally with a script
1633
+ * extension), or a host on the short known-installer list below.
1634
+ * A curl pipe to an unversioned or non-vendor source (payload.sh on an
1635
+ * arbitrary host) never qualifies.
1636
+ */
1637
+ const KNOWN_INSTALLER_HOSTS = new Set(["sh.rustup.rs", "get.docker.com", "install.python-poetry.org", "astral.sh", "opencode.ai"]);
1638
+ function isVendorInstallerPipeOnly(f) {
1639
+ if (f.source !== "github-workflow")
1640
+ return false;
1641
+ if (!f.rules.includes("CURL-PIPE-SH"))
1642
+ return false;
1643
+ const allowed = new Set(["CURL-PIPE-SH", "NET-FETCH", "REMOTE-URL"]);
1644
+ if (!f.rules.every((r) => allowed.has(r)))
1645
+ return false;
1646
+ const m = f.command.match(/https:\/\/([^\s"'`/:?#]+)([^\s"'`]*)/i);
1647
+ if (!m)
1648
+ return false;
1649
+ const host = m[1].toLowerCase();
1650
+ const path = (m[2] ?? "").toLowerCase();
1651
+ if (/(^|\/)(install|installer|setup)(\.(sh|bash|ps1|py))?($|[/?#])/.test(path))
1652
+ return true;
1653
+ return KNOWN_INSTALLER_HOSTS.has(host);
1654
+ }
1139
1655
  export function computeVerdict(findings) {
1140
1656
  let review = false;
1141
1657
  for (const f of findings) {
@@ -1144,7 +1660,10 @@ export function computeVerdict(findings) {
1144
1660
  const hasSevere = metas.some((x) => x.severe);
1145
1661
  const hasCredStore = f.rules.includes("CRED-ACCESS");
1146
1662
  const hasNet = metas.some((x) => x.remote);
1147
- if (hasSevere || (hasCredStore && hasNet))
1663
+ // P-063: a lone vendor installer pipe in CI is strong pattern
1664
+ // evidence but not, on its own, a compromise pattern.
1665
+ const vendorPipeOnly = isVendorInstallerPipeOnly(f);
1666
+ if ((hasSevere && !vendorPipeOnly) || (hasCredStore && hasNet))
1148
1667
  return "COMPROMISED-PATTERN";
1149
1668
  review = true;
1150
1669
  }
@@ -1309,20 +1828,20 @@ function scanPackageJson(root, findings, warnings) {
1309
1828
  scanOnePackageJson(root, rel, findings, warnings);
1310
1829
  }
1311
1830
  }
1312
- function collectClaudeHooks(node, file, root, findings, prefix) {
1831
+ function collectClaudeHooks(node, file, root, findings, prefix, source = "claude-settings", baseAbs = root) {
1313
1832
  if (!node || typeof node !== "object")
1314
1833
  return;
1315
1834
  const obj = node;
1316
1835
  if (typeof obj.command === "string" && obj.command.trim()) {
1317
- findings.push(makeFinding(root, file, "claude-settings", prefix || "hook", obj.command, String(findings.length)));
1836
+ findings.push(makeFinding(root, file, source, prefix || "hook", obj.command, String(findings.length), undefined, baseAbs));
1318
1837
  return;
1319
1838
  }
1320
1839
  for (const [key, value] of Object.entries(obj)) {
1321
1840
  if (Array.isArray(value)) {
1322
- value.forEach((item, i) => collectClaudeHooks(item, file, root, findings, prefix ? `${prefix}:${key}` : key));
1841
+ value.forEach((item, i) => collectClaudeHooks(item, file, root, findings, prefix ? `${prefix}:${key}` : key, source, baseAbs));
1323
1842
  }
1324
1843
  else if (value && typeof value === "object") {
1325
- collectClaudeHooks(value, file, root, findings, prefix ? `${prefix}:${key}` : key);
1844
+ collectClaudeHooks(value, file, root, findings, prefix ? `${prefix}:${key}` : key, source, baseAbs);
1326
1845
  }
1327
1846
  }
1328
1847
  }
@@ -1396,6 +1915,100 @@ function scanClaude(root, findings, warnings) {
1396
1915
  collectClaudeHooks(hooks, rel, root, findings, "");
1397
1916
  }
1398
1917
  }
1918
+ /**
1919
+ * Claude Code plugin hook files (P-060, BLOCKER). Plugins distribute
1920
+ * hooks in a hooks/ directory at the plugin root: hooks/hooks.json at
1921
+ * the repo root for a single plugin repo (obra/superpowers), or
1922
+ * plugins/<name>/hooks/hooks.json inside a marketplace or monorepo
1923
+ * (anthropics/claude-code carries 9 such files, wshobson/agents 2 among
1924
+ * 92 plugin directories). Before v0.1.7 none of these files was scanned
1925
+ * at any depth: a SessionStart curl pipe in hooks/hooks.json scanned
1926
+ * CLEAN with total 0 while the identical payload in .claude/settings.json
1927
+ * verdicts COMPROMISED-PATTERN. The file carries the same schema as the
1928
+ * hooks key of .claude/settings.json (an events map of command hooks:
1929
+ * SessionStart, PreToolUse, PostToolUse, Stop, UserPromptSubmit, and
1930
+ * the rest of the Claude Code event set), so commands are classified
1931
+ * exactly as settings hooks are. The sibling hooks/hooks-cursor.json
1932
+ * variant shipped by the same plugins (superpowers) carries the same
1933
+ * command object shape under Cursor event names, so it is inventoried
1934
+ * the same way.
1935
+ *
1936
+ * The walk mirrors the nested package.json discipline (P-032): sorted
1937
+ * order, node_modules, .git, and dot directories never entered,
1938
+ * symlinked directories never followed, and a cap with a warning. The
1939
+ * depth bound applies to the plugin root (at most PLUGIN_HOOKS_MAX_DEPTH
1940
+ * directories below the scan root); the hooks directory itself sits one
1941
+ * level below its plugin root.
1942
+ */
1943
+ export const PLUGIN_HOOKS_MAX_DEPTH = 4;
1944
+ export const PLUGIN_HOOKS_MAX_FILES = 200;
1945
+ function discoverPluginHookFiles(root, warnings) {
1946
+ const found = [];
1947
+ const skipDirs = new Set(["node_modules", ".git"]);
1948
+ const maxHooksDirDepth = PLUGIN_HOOKS_MAX_DEPTH + 1;
1949
+ const stack = [{ abs: root, rel: "", depth: 0 }];
1950
+ while (stack.length > 0) {
1951
+ const cur = stack.pop();
1952
+ let entries = [];
1953
+ try {
1954
+ entries = readdirSync(cur.abs);
1955
+ }
1956
+ catch {
1957
+ if (cur.rel)
1958
+ warnings.push(`Unreadable directory skipped: ${cur.rel} could not be listed; the scan is partial`);
1959
+ continue;
1960
+ }
1961
+ entries.sort();
1962
+ const base = cur.rel === "" ? "" : (cur.rel.split("/").pop() ?? "");
1963
+ if (cur.depth >= 1 && base === "hooks") {
1964
+ for (const name of ["hooks.json", "hooks-cursor.json"]) {
1965
+ if (entries.includes(name))
1966
+ found.push(cur.rel === "" ? name : `${cur.rel}/${name}`);
1967
+ }
1968
+ }
1969
+ if (cur.depth >= maxHooksDirDepth)
1970
+ continue;
1971
+ for (let i = entries.length - 1; i >= 0; i--) {
1972
+ const name = entries[i];
1973
+ if (skipDirs.has(name) || name.startsWith("."))
1974
+ continue;
1975
+ const childAbs = join(cur.abs, name);
1976
+ let lst;
1977
+ try {
1978
+ lst = lstatSync(childAbs);
1979
+ }
1980
+ catch {
1981
+ continue;
1982
+ }
1983
+ if (lst.isSymbolicLink() || !lst.isDirectory())
1984
+ continue;
1985
+ stack.push({ abs: childAbs, rel: cur.rel === "" ? name : `${cur.rel}/${name}`, depth: cur.depth + 1 });
1986
+ }
1987
+ }
1988
+ found.sort();
1989
+ if (found.length > PLUGIN_HOOKS_MAX_FILES) {
1990
+ warnings.push(`Plugin hooks inventory capped at ${PLUGIN_HOOKS_MAX_FILES} files (${found.length} found); the remaining plugin hook files were not inventoried and the scan is partial`);
1991
+ return found.slice(0, PLUGIN_HOOKS_MAX_FILES);
1992
+ }
1993
+ return found;
1994
+ }
1995
+ function scanPluginHooks(root, findings, warnings) {
1996
+ for (const rel of discoverPluginHookFiles(root, warnings)) {
1997
+ const data = readJson(root, rel, warnings);
1998
+ if (!data || typeof data !== "object" || Array.isArray(data))
1999
+ continue;
2000
+ // The events map lives under the hooks key when one is present;
2001
+ // some plugin files put the events map at the top level.
2002
+ const eventsNode = ("hooks" in data && data.hooks && typeof data.hooks === "object" && !Array.isArray(data.hooks))
2003
+ ? data.hooks
2004
+ : data;
2005
+ // Referenced local files resolve against the plugin root (the
2006
+ // directory holding the hooks folder), which is where
2007
+ // ${CLAUDE_PLUGIN_ROOT} points at runtime.
2008
+ const pluginRootAbs = dirname(dirname(join(root, rel)));
2009
+ collectClaudeHooks(eventsNode, rel, root, findings, "", "claude-plugin", pluginRootAbs);
2010
+ }
2011
+ }
1399
2012
  function scanVscode(root, findings, warnings) {
1400
2013
  const rel = ".vscode/tasks.json";
1401
2014
  if (existsSync(join(root, rel))) {
@@ -1812,6 +2425,7 @@ export function scanHooks(root) {
1812
2425
  }
1813
2426
  scanPackageJson(root, findings, warnings);
1814
2427
  scanClaude(root, findings, warnings);
2428
+ scanPluginHooks(root, findings, warnings);
1815
2429
  scanVscode(root, findings, warnings);
1816
2430
  scanDirFiles(root, ".husky", "husky", findings, warnings);
1817
2431
  scanLefthook(root, findings, warnings);