@reneturcios/hookcheck 0.1.6 → 0.1.8
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 +6 -3
- package/dist/core.d.ts +45 -32
- package/dist/core.js +1027 -266
- package/dist/core.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
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.
|
|
9
|
+
export const VERSION = "0.1.8";
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
-
/**
|
|
678
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
829
|
-
const hasSendConstruct = /\b(?:fetch|axios|requests\s*\.|urlopen|https?\s*\.\s*(?:request|get)|
|
|
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" ||
|
|
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" ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -1244,21 +1763,54 @@ function scanOnePackageJson(root, rel, findings, warnings) {
|
|
|
1244
1763
|
}
|
|
1245
1764
|
}
|
|
1246
1765
|
/**
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
1250
|
-
*
|
|
1251
|
-
*
|
|
1252
|
-
*
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1766
|
+
* Unified nested discovery (P-032, P-060, P-065). Every surface the
|
|
1767
|
+
* tool inventories at the scan root is also inventoried when it
|
|
1768
|
+
* appears inside a subdirectory: before v0.1.8 only nested package.json
|
|
1769
|
+
* files (P-032) and plugin hooks files (P-060) were recursed, so a
|
|
1770
|
+
* parent scan of a monorepo or workspace silently missed nested
|
|
1771
|
+
* .claude/settings.json, .vscode/tasks.json, workflows, husky and
|
|
1772
|
+
* githooks directories, lefthook and pre-commit configs, and MCP
|
|
1773
|
+
* configs, and a tree holding only nested surfaces scanned CLEAN with
|
|
1774
|
+
* the empty estate note attached (P-065).
|
|
1775
|
+
*
|
|
1776
|
+
* One bounded walk enumerates every project root (each directory at
|
|
1777
|
+
* most NESTED_MAX_DEPTH levels below the scan root, the scan root
|
|
1778
|
+
* itself included) plus the nested package.json and plugin hooks file
|
|
1779
|
+
* lists. Per surface scanners then run once per project root with the
|
|
1780
|
+
* root prefix carried into every finding file path, so findings carry
|
|
1781
|
+
* their real relative paths. The walk is bounded so a hostile or
|
|
1782
|
+
* giant tree cannot explode the report: directories are visited in
|
|
1783
|
+
* sorted order, node_modules, .git, and dot directories are never
|
|
1784
|
+
* entered, symlinked directories are never followed (a symlink loop
|
|
1785
|
+
* cannot hang the scan), and each surface family inventories at most
|
|
1786
|
+
* NESTED_MAX_FILES_PER_SURFACE nested files, with the cap reported as
|
|
1787
|
+
* a warning when it is hit. When directories remain below the depth
|
|
1788
|
+
* bound the walk says so in a warning naming the bound, and it probes
|
|
1789
|
+
* the directories just below the bound (names only, no file reads) so
|
|
1790
|
+
* surfaces sitting there are counted in the warning instead of being
|
|
1791
|
+
* silently dropped.
|
|
1257
1792
|
*/
|
|
1258
|
-
export const
|
|
1259
|
-
export const
|
|
1260
|
-
|
|
1261
|
-
|
|
1793
|
+
export const NESTED_MAX_DEPTH = 4;
|
|
1794
|
+
export const NESTED_MAX_FILES_PER_SURFACE = 200;
|
|
1795
|
+
export const PACKAGE_MAX_DEPTH = NESTED_MAX_DEPTH;
|
|
1796
|
+
export const PACKAGE_MAX_NESTED = NESTED_MAX_FILES_PER_SURFACE;
|
|
1797
|
+
export const PLUGIN_HOOKS_MAX_DEPTH = NESTED_MAX_DEPTH;
|
|
1798
|
+
export const PLUGIN_HOOKS_MAX_FILES = NESTED_MAX_FILES_PER_SURFACE;
|
|
1799
|
+
const SURFACE_FILE_INDICATORS = new Set([
|
|
1800
|
+
"package.json", "lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml",
|
|
1801
|
+
".pre-commit-config.yaml", ".pre-commit-config.yml", ".mcp.json", "mcp.json",
|
|
1802
|
+
]);
|
|
1803
|
+
const SURFACE_DIR_INDICATORS = new Set([
|
|
1804
|
+
".claude", "claude", ".vscode", ".github", ".husky", ".githooks",
|
|
1805
|
+
"hooks", ".cursor", ".windsurf", ".codex",
|
|
1806
|
+
]);
|
|
1807
|
+
function discoverSurfaces(root, warnings) {
|
|
1808
|
+
const projectRoots = [];
|
|
1809
|
+
const packageFiles = [];
|
|
1810
|
+
const pluginSet = new Set();
|
|
1811
|
+
let frontierDirs = 0;
|
|
1812
|
+
let surfacesBelowBound = 0;
|
|
1813
|
+
let probes = 0;
|
|
1262
1814
|
const skipDirs = new Set(["node_modules", ".git"]);
|
|
1263
1815
|
const stack = [{ abs: root, rel: "", depth: 0 }];
|
|
1264
1816
|
while (stack.length > 0) {
|
|
@@ -1273,14 +1825,38 @@ function discoverNestedPackageJson(root, warnings) {
|
|
|
1273
1825
|
continue;
|
|
1274
1826
|
}
|
|
1275
1827
|
entries.sort();
|
|
1828
|
+
projectRoots.push(cur.rel);
|
|
1276
1829
|
if (cur.depth >= 1 && entries.includes("package.json")) {
|
|
1277
|
-
|
|
1830
|
+
packageFiles.push(`${cur.rel}/package.json`);
|
|
1831
|
+
}
|
|
1832
|
+
// Plugin hooks files sit in a hooks directory one level below the
|
|
1833
|
+
// plugin root, so they are spotted from the plugin root listing
|
|
1834
|
+
// (the hooks directory itself can sit at depth bound plus one).
|
|
1835
|
+
if (entries.includes("hooks")) {
|
|
1836
|
+
const hooksAbs = join(cur.abs, "hooks");
|
|
1837
|
+
let lst = null;
|
|
1838
|
+
try {
|
|
1839
|
+
lst = lstatSync(hooksAbs);
|
|
1840
|
+
}
|
|
1841
|
+
catch {
|
|
1842
|
+
lst = null;
|
|
1843
|
+
}
|
|
1844
|
+
if (lst && lst.isDirectory() && !lst.isSymbolicLink()) {
|
|
1845
|
+
let hookEntries = [];
|
|
1846
|
+
try {
|
|
1847
|
+
hookEntries = readdirSync(hooksAbs);
|
|
1848
|
+
}
|
|
1849
|
+
catch {
|
|
1850
|
+
hookEntries = [];
|
|
1851
|
+
}
|
|
1852
|
+
for (const name of ["hooks.json", "hooks-cursor.json"]) {
|
|
1853
|
+
if (hookEntries.includes(name))
|
|
1854
|
+
pluginSet.add(cur.rel === "" ? `hooks/${name}` : `${cur.rel}/hooks/${name}`);
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1278
1857
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
// Push in reverse so the stack pops in sorted order.
|
|
1282
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1283
|
-
const name = entries[i];
|
|
1858
|
+
const children = [];
|
|
1859
|
+
for (const name of entries) {
|
|
1284
1860
|
if (skipDirs.has(name) || name.startsWith("."))
|
|
1285
1861
|
continue;
|
|
1286
1862
|
const childAbs = join(cur.abs, name);
|
|
@@ -1293,36 +1869,101 @@ function discoverNestedPackageJson(root, warnings) {
|
|
|
1293
1869
|
}
|
|
1294
1870
|
if (lst.isSymbolicLink() || !lst.isDirectory())
|
|
1295
1871
|
continue;
|
|
1296
|
-
|
|
1872
|
+
children.push({ abs: childAbs, rel: cur.rel === "" ? name : `${cur.rel}/${name}`, depth: cur.depth + 1 });
|
|
1873
|
+
}
|
|
1874
|
+
if (cur.depth >= NESTED_MAX_DEPTH) {
|
|
1875
|
+
frontierDirs += children.length;
|
|
1876
|
+
for (const child of children) {
|
|
1877
|
+
if (probes >= 1000)
|
|
1878
|
+
break;
|
|
1879
|
+
probes++;
|
|
1880
|
+
let sub = [];
|
|
1881
|
+
try {
|
|
1882
|
+
sub = readdirSync(child.abs);
|
|
1883
|
+
}
|
|
1884
|
+
catch {
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1887
|
+
if (sub.some((n) => SURFACE_FILE_INDICATORS.has(n) || SURFACE_DIR_INDICATORS.has(n)))
|
|
1888
|
+
surfacesBelowBound++;
|
|
1889
|
+
}
|
|
1890
|
+
continue;
|
|
1297
1891
|
}
|
|
1892
|
+
// Push in reverse so the stack pops in sorted order.
|
|
1893
|
+
for (let i = children.length - 1; i >= 0; i--)
|
|
1894
|
+
stack.push(children[i]);
|
|
1298
1895
|
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1896
|
+
projectRoots.sort((a, b) => (a === b ? 0 : a === "" ? -1 : b === "" ? 1 : a < b ? -1 : 1));
|
|
1897
|
+
packageFiles.sort();
|
|
1898
|
+
return {
|
|
1899
|
+
projectRoots,
|
|
1900
|
+
packageFiles,
|
|
1901
|
+
pluginHookFiles: Array.from(pluginSet).sort(),
|
|
1902
|
+
frontierDirs,
|
|
1903
|
+
surfacesBelowBound,
|
|
1904
|
+
depthBoundHit: frontierDirs > 0,
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
const FAMILY_LABELS = {
|
|
1908
|
+
settings: "Claude settings",
|
|
1909
|
+
tasks: "VS Code tasks",
|
|
1910
|
+
workflows: "workflow",
|
|
1911
|
+
husky: "husky hook",
|
|
1912
|
+
githooks: "githooks hook",
|
|
1913
|
+
lefthook: "lefthook config",
|
|
1914
|
+
"pre-commit": "pre-commit config",
|
|
1915
|
+
mcp: "MCP config",
|
|
1916
|
+
agent: "agent config",
|
|
1917
|
+
};
|
|
1918
|
+
/**
|
|
1919
|
+
* Claim one surface file for inventory. Returns false when the file
|
|
1920
|
+
* was already inventoried through another discovery path, or when the
|
|
1921
|
+
* nested file cap for its family is already spent (the skip is
|
|
1922
|
+
* counted, and scanHooks warns once per capped family).
|
|
1923
|
+
*/
|
|
1924
|
+
function claimFile(state, family, rel, isNested) {
|
|
1925
|
+
if (state.inventoried.has(rel))
|
|
1926
|
+
return false;
|
|
1927
|
+
if (isNested) {
|
|
1928
|
+
const found = (state.familyFound.get(family) ?? 0) + 1;
|
|
1929
|
+
state.familyFound.set(family, found);
|
|
1930
|
+
if (found > NESTED_MAX_FILES_PER_SURFACE) {
|
|
1931
|
+
state.familyCapped.set(family, (state.familyCapped.get(family) ?? 0) + 1);
|
|
1932
|
+
state.partial = true;
|
|
1933
|
+
return false;
|
|
1934
|
+
}
|
|
1303
1935
|
}
|
|
1304
|
-
|
|
1936
|
+
state.inventoried.add(rel);
|
|
1937
|
+
return true;
|
|
1305
1938
|
}
|
|
1306
|
-
function scanPackageJson(root, findings, warnings) {
|
|
1939
|
+
function scanPackageJson(root, findings, warnings, state) {
|
|
1307
1940
|
scanOnePackageJson(root, "package.json", findings, warnings);
|
|
1308
|
-
|
|
1941
|
+
state.inventoried.add("package.json");
|
|
1942
|
+
let nested = state.disc.packageFiles;
|
|
1943
|
+
if (nested.length > PACKAGE_MAX_NESTED) {
|
|
1944
|
+
warnings.push(`Nested package.json inventory capped at ${PACKAGE_MAX_NESTED} files (${nested.length} found); the remaining nested package.json files were not inventoried and the scan is partial`);
|
|
1945
|
+
state.partial = true;
|
|
1946
|
+
nested = nested.slice(0, PACKAGE_MAX_NESTED);
|
|
1947
|
+
}
|
|
1948
|
+
for (const rel of nested) {
|
|
1949
|
+
state.inventoried.add(rel);
|
|
1309
1950
|
scanOnePackageJson(root, rel, findings, warnings);
|
|
1310
1951
|
}
|
|
1311
1952
|
}
|
|
1312
|
-
function collectClaudeHooks(node, file, root, findings, prefix) {
|
|
1953
|
+
function collectClaudeHooks(node, file, root, findings, prefix, source = "claude-settings", baseAbs = root) {
|
|
1313
1954
|
if (!node || typeof node !== "object")
|
|
1314
1955
|
return;
|
|
1315
1956
|
const obj = node;
|
|
1316
1957
|
if (typeof obj.command === "string" && obj.command.trim()) {
|
|
1317
|
-
findings.push(makeFinding(root, file,
|
|
1958
|
+
findings.push(makeFinding(root, file, source, prefix || "hook", obj.command, String(findings.length), undefined, baseAbs));
|
|
1318
1959
|
return;
|
|
1319
1960
|
}
|
|
1320
1961
|
for (const [key, value] of Object.entries(obj)) {
|
|
1321
1962
|
if (Array.isArray(value)) {
|
|
1322
|
-
value.forEach((item, i) => collectClaudeHooks(item, file, root, findings, prefix ? `${prefix}:${key}` : key));
|
|
1963
|
+
value.forEach((item, i) => collectClaudeHooks(item, file, root, findings, prefix ? `${prefix}:${key}` : key, source, baseAbs));
|
|
1323
1964
|
}
|
|
1324
1965
|
else if (value && typeof value === "object") {
|
|
1325
|
-
collectClaudeHooks(value, file, root, findings, prefix ? `${prefix}:${key}` : key);
|
|
1966
|
+
collectClaudeHooks(value, file, root, findings, prefix ? `${prefix}:${key}` : key, source, baseAbs);
|
|
1326
1967
|
}
|
|
1327
1968
|
}
|
|
1328
1969
|
}
|
|
@@ -1331,16 +1972,22 @@ function collectClaudeHooks(node, file, root, findings, prefix) {
|
|
|
1331
1972
|
* the GNU stow layout used by real dotfiles repos (claude/settings.json,
|
|
1332
1973
|
* no dot), and a settings file nested one level under a config dir
|
|
1333
1974
|
* (config/.claude/settings.json). A settings path that exists as a
|
|
1334
|
-
* directory is reported as a warning, never silently skipped.
|
|
1975
|
+
* directory is reported as a warning, never silently skipped. Since
|
|
1976
|
+
* v0.1.8 (P-065) the candidates are computed per project root, so the
|
|
1977
|
+
* same locations are inventoried in every subdirectory within the
|
|
1978
|
+
* discovery bound; the inventoried set in ScanState keeps a file
|
|
1979
|
+
* reachable by two paths (a parent root one level candidate and its
|
|
1980
|
+
* own project root) counted exactly once.
|
|
1335
1981
|
*/
|
|
1336
|
-
function claudeSettingsCandidates(root) {
|
|
1982
|
+
function claudeSettingsCandidates(root, prefix) {
|
|
1983
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
1337
1984
|
const rels = [
|
|
1338
|
-
|
|
1339
|
-
|
|
1985
|
+
`${base}.claude/settings.json`, `${base}.claude/settings.local.json`,
|
|
1986
|
+
`${base}claude/settings.json`, `${base}claude/settings.local.json`,
|
|
1340
1987
|
];
|
|
1341
1988
|
let entries = [];
|
|
1342
1989
|
try {
|
|
1343
|
-
entries = readdirSync(root);
|
|
1990
|
+
entries = readdirSync(join(root, prefix));
|
|
1344
1991
|
}
|
|
1345
1992
|
catch {
|
|
1346
1993
|
entries = [];
|
|
@@ -1350,7 +1997,7 @@ function claudeSettingsCandidates(root) {
|
|
|
1350
1997
|
continue;
|
|
1351
1998
|
let st;
|
|
1352
1999
|
try {
|
|
1353
|
-
st = statSync(join(root, name));
|
|
2000
|
+
st = statSync(join(root, prefix, name));
|
|
1354
2001
|
}
|
|
1355
2002
|
catch {
|
|
1356
2003
|
continue;
|
|
@@ -1358,167 +2005,142 @@ function claudeSettingsCandidates(root) {
|
|
|
1358
2005
|
if (!st.isDirectory())
|
|
1359
2006
|
continue;
|
|
1360
2007
|
for (const f of ["settings.json", "settings.local.json"]) {
|
|
1361
|
-
rels.push(`${name}/.claude/${f}`);
|
|
2008
|
+
rels.push(`${base}${name}/.claude/${f}`);
|
|
1362
2009
|
}
|
|
1363
2010
|
}
|
|
1364
2011
|
return Array.from(new Set(rels));
|
|
1365
2012
|
}
|
|
1366
|
-
function scanClaude(root, findings, warnings) {
|
|
1367
|
-
for (const
|
|
1368
|
-
const
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
st
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
warnings.push(`${rel} is a directory, not a settings file: any hooks inside it cannot be parsed as Claude settings`);
|
|
1380
|
-
continue;
|
|
1381
|
-
}
|
|
1382
|
-
if (!st.isFile())
|
|
1383
|
-
continue;
|
|
1384
|
-
const data = readJson(root, rel, warnings);
|
|
1385
|
-
if (!data || typeof data !== "object")
|
|
1386
|
-
continue;
|
|
1387
|
-
if (!("hooks" in data)) {
|
|
1388
|
-
warnings.push(`No hooks key in ${rel}`);
|
|
1389
|
-
continue;
|
|
1390
|
-
}
|
|
1391
|
-
const hooks = data.hooks;
|
|
1392
|
-
if (!hooks || typeof hooks !== "object") {
|
|
1393
|
-
warnings.push(`hooks key in ${rel} is not an object: hooks skipped`);
|
|
1394
|
-
continue;
|
|
1395
|
-
}
|
|
1396
|
-
collectClaudeHooks(hooks, rel, root, findings, "");
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
function scanVscode(root, findings, warnings) {
|
|
1400
|
-
const rel = ".vscode/tasks.json";
|
|
1401
|
-
if (existsSync(join(root, rel))) {
|
|
1402
|
-
const data = readJson(root, rel, warnings);
|
|
1403
|
-
if (data && data.tasks !== undefined && !Array.isArray(data.tasks)) {
|
|
1404
|
-
warnings.push(`tasks key in ${rel} is not an array: tasks skipped`);
|
|
1405
|
-
}
|
|
1406
|
-
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
1407
|
-
tasks.forEach((rawTask, idx) => {
|
|
1408
|
-
// Malformed entries (null, strings, numbers) are skipped, never fatal:
|
|
1409
|
-
// one bad task must not hide every other surface in the repo.
|
|
1410
|
-
if (!rawTask || typeof rawTask !== "object")
|
|
1411
|
-
return;
|
|
1412
|
-
const task = rawTask;
|
|
1413
|
-
const runOptions = (task.runOptions ?? {});
|
|
1414
|
-
const options = (task.options ?? {});
|
|
1415
|
-
const optionsRunOptions = (options.runOptions ?? {});
|
|
1416
|
-
// folderOpen lives in runOptions in the standard schema, but real
|
|
1417
|
-
// world files also nest it under options or put runOn on the task.
|
|
1418
|
-
const runOn = runOptions.runOn ?? optionsRunOptions.runOn ?? task.runOn;
|
|
1419
|
-
const isFolderOpen = runOn === "folderOpen" || (Array.isArray(runOn) && runOn.includes("folderOpen"));
|
|
1420
|
-
const command = typeof task.command === "string" ? task.command : "";
|
|
1421
|
-
const args = Array.isArray(task.args) ? task.args.filter((a) => typeof a === "string").join(" ") : "";
|
|
1422
|
-
const full = [command, args].filter(Boolean).join(" ").trim();
|
|
1423
|
-
if (!full)
|
|
1424
|
-
return;
|
|
1425
|
-
const label = typeof task.label === "string" ? task.label : `task-${idx}`;
|
|
1426
|
-
if (isFolderOpen) {
|
|
1427
|
-
findings.push(makeFinding(root, rel, "vscode-tasks", `folderOpen:${label}`, full, String(idx)));
|
|
2013
|
+
function scanClaude(root, findings, warnings, state) {
|
|
2014
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2015
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2016
|
+
for (const rel of claudeSettingsCandidates(root, prefix)) {
|
|
2017
|
+
const abs = join(root, rel);
|
|
2018
|
+
if (!existsSync(abs))
|
|
2019
|
+
continue;
|
|
2020
|
+
let st;
|
|
2021
|
+
try {
|
|
2022
|
+
st = statSync(abs);
|
|
2023
|
+
}
|
|
2024
|
+
catch {
|
|
2025
|
+
continue;
|
|
1428
2026
|
}
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
2027
|
+
if (st.isDirectory()) {
|
|
2028
|
+
if (!state.inventoried.has(rel)) {
|
|
2029
|
+
state.inventoried.add(rel);
|
|
2030
|
+
warnings.push(`${rel} is a directory, not a settings file: any hooks inside it cannot be parsed as Claude settings`);
|
|
2031
|
+
}
|
|
2032
|
+
continue;
|
|
1434
2033
|
}
|
|
1435
|
-
|
|
2034
|
+
if (!st.isFile())
|
|
2035
|
+
continue;
|
|
2036
|
+
if (!claimFile(state, "settings", rel, prefix !== ""))
|
|
2037
|
+
continue;
|
|
2038
|
+
const data = readJson(root, rel, warnings);
|
|
2039
|
+
if (!data || typeof data !== "object")
|
|
2040
|
+
continue;
|
|
2041
|
+
if (!("hooks" in data)) {
|
|
2042
|
+
warnings.push(`No hooks key in ${rel}`);
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
const hooks = data.hooks;
|
|
2046
|
+
if (!hooks || typeof hooks !== "object") {
|
|
2047
|
+
warnings.push(`hooks key in ${rel} is not an object: hooks skipped`);
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
collectClaudeHooks(hooks, rel, root, findings, "", "claude-settings", baseAbs);
|
|
2051
|
+
}
|
|
1436
2052
|
}
|
|
1437
|
-
// MCP config inside .vscode
|
|
1438
|
-
scanMcpFile(root, ".vscode/mcp.json", findings, warnings);
|
|
1439
2053
|
}
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
2054
|
+
/**
|
|
2055
|
+
* Claude Code plugin hook files (P-060, BLOCKER). Plugins distribute
|
|
2056
|
+
* hooks in a hooks/ directory at the plugin root: hooks/hooks.json at
|
|
2057
|
+
* the repo root for a single plugin repo (obra/superpowers), or
|
|
2058
|
+
* plugins/<name>/hooks/hooks.json inside a marketplace or monorepo
|
|
2059
|
+
* (anthropics/claude-code carries 9 such files, wshobson/agents 2 among
|
|
2060
|
+
* 92 plugin directories). Before v0.1.7 none of these files was scanned
|
|
2061
|
+
* at any depth: a SessionStart curl pipe in hooks/hooks.json scanned
|
|
2062
|
+
* CLEAN with total 0 while the identical payload in .claude/settings.json
|
|
2063
|
+
* verdicts COMPROMISED-PATTERN. The file carries the same schema as the
|
|
2064
|
+
* hooks key of .claude/settings.json (an events map of command hooks:
|
|
2065
|
+
* SessionStart, PreToolUse, PostToolUse, Stop, UserPromptSubmit, and
|
|
2066
|
+
* the rest of the Claude Code event set), so commands are classified
|
|
2067
|
+
* exactly as settings hooks are. The sibling hooks/hooks-cursor.json
|
|
2068
|
+
* variant shipped by the same plugins (superpowers) carries the same
|
|
2069
|
+
* command object shape under Cursor event names, so it is inventoried
|
|
2070
|
+
* the same way.
|
|
2071
|
+
*
|
|
2072
|
+
* Discovery is part of the unified bounded walk (P-065): the file
|
|
2073
|
+
* list comes from discoverSurfaces, with the plugin root at most
|
|
2074
|
+
* PLUGIN_HOOKS_MAX_DEPTH directories below the scan root (the hooks
|
|
2075
|
+
* directory itself sits one level below its plugin root), and the
|
|
2076
|
+
* same cap with a warning applies.
|
|
2077
|
+
*/
|
|
2078
|
+
function scanPluginHooks(root, findings, warnings, state) {
|
|
2079
|
+
let files = state.disc.pluginHookFiles;
|
|
2080
|
+
if (files.length > PLUGIN_HOOKS_MAX_FILES) {
|
|
2081
|
+
warnings.push(`Plugin hooks inventory capped at ${PLUGIN_HOOKS_MAX_FILES} files (${files.length} found); the remaining plugin hook files were not inventoried and the scan is partial`);
|
|
2082
|
+
state.partial = true;
|
|
2083
|
+
files = files.slice(0, PLUGIN_HOOKS_MAX_FILES);
|
|
2084
|
+
}
|
|
2085
|
+
for (const rel of files) {
|
|
2086
|
+
state.inventoried.add(rel);
|
|
2087
|
+
const data = readJson(root, rel, warnings);
|
|
2088
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
1467
2089
|
continue;
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
2090
|
+
// The events map lives under the hooks key when one is present;
|
|
2091
|
+
// some plugin files put the events map at the top level.
|
|
2092
|
+
const eventsNode = ("hooks" in data && data.hooks && typeof data.hooks === "object" && !Array.isArray(data.hooks))
|
|
2093
|
+
? data.hooks
|
|
2094
|
+
: data;
|
|
2095
|
+
// Referenced local files resolve against the plugin root (the
|
|
2096
|
+
// directory holding the hooks folder), which is where
|
|
2097
|
+
// ${CLAUDE_PLUGIN_ROOT} points at runtime.
|
|
2098
|
+
const pluginRootAbs = dirname(dirname(join(root, rel)));
|
|
2099
|
+
collectClaudeHooks(eventsNode, rel, root, findings, "", "claude-plugin", pluginRootAbs);
|
|
1475
2100
|
}
|
|
1476
2101
|
}
|
|
1477
|
-
function
|
|
1478
|
-
for (const
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
const
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
2102
|
+
function scanVscode(root, findings, warnings, state) {
|
|
2103
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2104
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2105
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2106
|
+
const rel = `${base}.vscode/tasks.json`;
|
|
2107
|
+
if (existsSync(join(root, rel)) && claimFile(state, "tasks", rel, prefix !== "")) {
|
|
2108
|
+
const data = readJson(root, rel, warnings);
|
|
2109
|
+
if (data && data.tasks !== undefined && !Array.isArray(data.tasks)) {
|
|
2110
|
+
warnings.push(`tasks key in ${rel} is not an array: tasks skipped`);
|
|
2111
|
+
}
|
|
2112
|
+
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
2113
|
+
tasks.forEach((rawTask, idx) => {
|
|
2114
|
+
// Malformed entries (null, strings, numbers) are skipped, never fatal:
|
|
2115
|
+
// one bad task must not hide every other surface in the repo.
|
|
2116
|
+
if (!rawTask || typeof rawTask !== "object")
|
|
2117
|
+
return;
|
|
2118
|
+
const task = rawTask;
|
|
2119
|
+
const runOptions = (task.runOptions ?? {});
|
|
2120
|
+
const options = (task.options ?? {});
|
|
2121
|
+
const optionsRunOptions = (options.runOptions ?? {});
|
|
2122
|
+
// folderOpen lives in runOptions in the standard schema, but real
|
|
2123
|
+
// world files also nest it under options or put runOn on the task.
|
|
2124
|
+
const runOn = runOptions.runOn ?? optionsRunOptions.runOn ?? task.runOn;
|
|
2125
|
+
const isFolderOpen = runOn === "folderOpen" || (Array.isArray(runOn) && runOn.includes("folderOpen"));
|
|
2126
|
+
const command = typeof task.command === "string" ? task.command : "";
|
|
2127
|
+
const args = Array.isArray(task.args) ? task.args.filter((a) => typeof a === "string").join(" ") : "";
|
|
2128
|
+
const full = [command, args].filter(Boolean).join(" ").trim();
|
|
2129
|
+
if (!full)
|
|
2130
|
+
return;
|
|
2131
|
+
const label = typeof task.label === "string" ? task.label : `task-${idx}`;
|
|
2132
|
+
findings.push(makeFinding(root, rel, "vscode-tasks", isFolderOpen ? `folderOpen:${label}` : label, full, String(idx), undefined, baseAbs));
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
// MCP config inside .vscode
|
|
2136
|
+
scanMcpFile(root, `${base}.vscode/mcp.json`, findings, warnings, state, baseAbs, prefix !== "");
|
|
1488
2137
|
}
|
|
1489
2138
|
}
|
|
1490
|
-
function
|
|
1491
|
-
const
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
const entryRe = /^\s*entry:\s*(.+)$/gm;
|
|
1496
|
-
let m;
|
|
1497
|
-
let i = 0;
|
|
1498
|
-
while ((m = entryRe.exec(raw)) !== null) {
|
|
1499
|
-
findings.push(makeFinding(root, rel, "pre-commit", "entry", m[1].trim().replace(/^["']|["']$/g, ""), String(i++)));
|
|
1500
|
-
}
|
|
1501
|
-
const repoRe = /^\s*-\s*repo:\s*(https?:\/\/\S+)$/gm;
|
|
1502
|
-
while ((m = repoRe.exec(raw)) !== null) {
|
|
1503
|
-
// A remote hook repo is the standard pre-commit pattern, not a finding
|
|
1504
|
-
// of compromise: the URL alone is inventoried as UNKNOWN so a human can
|
|
1505
|
-
// review the pinned rev. Classifying the bare URL with the command
|
|
1506
|
-
// rules would verdict every normal pre-commit config COMPROMISED.
|
|
1507
|
-
findings.push({
|
|
1508
|
-
id: `${rel}:remote-repo:${i++}`,
|
|
1509
|
-
file: rel,
|
|
1510
|
-
source: "pre-commit",
|
|
1511
|
-
hook: "remote-repo",
|
|
1512
|
-
command: m[1].trim(),
|
|
1513
|
-
classification: "UNKNOWN",
|
|
1514
|
-
rules: [],
|
|
1515
|
-
evidence: "remote pre-commit hook repo (standard pattern): its hooks run locally at commit time; hookcheck cannot verify remote contents, review the pinned rev",
|
|
1516
|
-
});
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
function scanAgentDirs(root, findings, warnings) {
|
|
1520
|
-
for (const [dir, source] of [[".cursor", "cursor"], [".codex", "codex"]]) {
|
|
1521
|
-
const abs = join(root, dir);
|
|
2139
|
+
function scanDirFiles(root, dirName, source, family, findings, warnings, state) {
|
|
2140
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2141
|
+
const dirRel = prefix === "" ? dirName : `${prefix}/${dirName}`;
|
|
2142
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2143
|
+
const abs = join(root, dirRel);
|
|
1522
2144
|
if (!existsSync(abs))
|
|
1523
2145
|
continue;
|
|
1524
2146
|
let entries = [];
|
|
@@ -1526,6 +2148,7 @@ function scanAgentDirs(root, findings, warnings) {
|
|
|
1526
2148
|
entries = readdirSync(abs);
|
|
1527
2149
|
}
|
|
1528
2150
|
catch {
|
|
2151
|
+
warnings.push(`Unreadable directory skipped: ${dirRel} could not be listed; the scan is partial`);
|
|
1529
2152
|
continue;
|
|
1530
2153
|
}
|
|
1531
2154
|
for (const name of entries.sort()) {
|
|
@@ -1539,43 +2162,144 @@ function scanAgentDirs(root, findings, warnings) {
|
|
|
1539
2162
|
}
|
|
1540
2163
|
if (!st.isFile())
|
|
1541
2164
|
continue;
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
2165
|
+
if (name === "_" || name.startsWith("."))
|
|
2166
|
+
continue;
|
|
2167
|
+
const fileRel = `${dirRel}/${name}`;
|
|
2168
|
+
if (!claimFile(state, family, fileRel, prefix !== ""))
|
|
2169
|
+
continue;
|
|
2170
|
+
const content = readTextWarn(fileAbs, fileRel, warnings);
|
|
2171
|
+
if (content === null)
|
|
2172
|
+
continue;
|
|
2173
|
+
// Entire file content is the command surface for husky style hooks.
|
|
2174
|
+
// Classify generously deep into the file: a payload hidden behind
|
|
2175
|
+
// padding must still be seen. Display code truncates separately.
|
|
2176
|
+
findings.push(makeFinding(root, fileRel, source, name, content.trim().slice(0, 200000), undefined, undefined, baseAbs));
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
function scanLefthook(root, findings, warnings, state) {
|
|
2181
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2182
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2183
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2184
|
+
for (const name of ["lefthook.yml", "lefthook.yaml", ".lefthook.yml"]) {
|
|
2185
|
+
const rel = `${base}${name}`;
|
|
2186
|
+
if (!existsSync(join(root, rel)))
|
|
2187
|
+
continue;
|
|
2188
|
+
if (!claimFile(state, "lefthook", rel, prefix !== ""))
|
|
2189
|
+
continue;
|
|
2190
|
+
const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2191
|
+
const runRe = /^\s*run:\s*(.+)$/gm;
|
|
2192
|
+
let m;
|
|
2193
|
+
let i = 0;
|
|
2194
|
+
while ((m = runRe.exec(raw)) !== null) {
|
|
2195
|
+
findings.push(makeFinding(root, rel, "lefthook", "run", m[1].trim().replace(/^["']|["']$/g, ""), String(i++), undefined, baseAbs));
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
function scanPreCommit(root, findings, warnings, state) {
|
|
2201
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2202
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2203
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2204
|
+
const rel = `${base}.pre-commit-config.yaml`;
|
|
2205
|
+
if (!existsSync(join(root, rel)))
|
|
2206
|
+
continue;
|
|
2207
|
+
if (!claimFile(state, "pre-commit", rel, prefix !== ""))
|
|
2208
|
+
continue;
|
|
2209
|
+
const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2210
|
+
const entryRe = /^\s*entry:\s*(.+)$/gm;
|
|
2211
|
+
let m;
|
|
2212
|
+
let i = 0;
|
|
2213
|
+
while ((m = entryRe.exec(raw)) !== null) {
|
|
2214
|
+
findings.push(makeFinding(root, rel, "pre-commit", "entry", m[1].trim().replace(/^["']|["']$/g, ""), String(i++), undefined, baseAbs));
|
|
2215
|
+
}
|
|
2216
|
+
const repoRe = /^\s*-\s*repo:\s*(https?:\/\/\S+)$/gm;
|
|
2217
|
+
while ((m = repoRe.exec(raw)) !== null) {
|
|
2218
|
+
// A remote hook repo is the standard pre-commit pattern, not a finding
|
|
2219
|
+
// of compromise: the URL alone is inventoried as UNKNOWN so a human can
|
|
2220
|
+
// review the pinned rev. Classifying the bare URL with the command
|
|
2221
|
+
// rules would verdict every normal pre-commit config COMPROMISED.
|
|
2222
|
+
findings.push({
|
|
2223
|
+
id: `${rel}:remote-repo:${i++}`,
|
|
2224
|
+
file: rel,
|
|
2225
|
+
source: "pre-commit",
|
|
2226
|
+
hook: "remote-repo",
|
|
2227
|
+
command: m[1].trim(),
|
|
2228
|
+
classification: "UNKNOWN",
|
|
2229
|
+
rules: [],
|
|
2230
|
+
evidence: "remote pre-commit hook repo (standard pattern): its hooks run locally at commit time; hookcheck cannot verify remote contents, review the pinned rev",
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
function scanAgentDirs(root, findings, warnings, state) {
|
|
2236
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2237
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2238
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2239
|
+
for (const [dir, source] of [[".cursor", "cursor"], [".codex", "codex"]]) {
|
|
2240
|
+
const abs = join(root, base, dir);
|
|
2241
|
+
if (!existsSync(abs))
|
|
2242
|
+
continue;
|
|
2243
|
+
let entries = [];
|
|
2244
|
+
try {
|
|
2245
|
+
entries = readdirSync(abs);
|
|
2246
|
+
}
|
|
2247
|
+
catch {
|
|
1548
2248
|
continue;
|
|
1549
|
-
if (name.endsWith(".json")) {
|
|
1550
|
-
const data = readJson(root, rel, warnings);
|
|
1551
|
-
if (data)
|
|
1552
|
-
collectGenericCommands(data, rel, root, findings, source);
|
|
1553
2249
|
}
|
|
1554
|
-
|
|
1555
|
-
const
|
|
1556
|
-
|
|
1557
|
-
|
|
2250
|
+
for (const name of entries.sort()) {
|
|
2251
|
+
const fileAbs = join(abs, name);
|
|
2252
|
+
let st;
|
|
2253
|
+
try {
|
|
2254
|
+
st = statSync(fileAbs);
|
|
2255
|
+
}
|
|
2256
|
+
catch {
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2259
|
+
if (!st.isFile())
|
|
2260
|
+
continue;
|
|
2261
|
+
const rel = `${base}${dir}/${name}`;
|
|
2262
|
+
// mcp.json is an MCP config surface, inventoried exactly once by
|
|
2263
|
+
// the central scanMcpFile pass in scanHooks (P-035). Running it
|
|
2264
|
+
// through the generic command walk here as well counted every
|
|
2265
|
+
// .cursor/mcp.json server twice.
|
|
2266
|
+
if (name === "mcp.json")
|
|
2267
|
+
continue;
|
|
2268
|
+
if (!claimFile(state, "agent", rel, prefix !== ""))
|
|
2269
|
+
continue;
|
|
2270
|
+
if (name.endsWith(".json")) {
|
|
2271
|
+
const data = readJson(root, rel, warnings);
|
|
2272
|
+
if (data)
|
|
2273
|
+
collectGenericCommands(data, rel, root, findings, source, "", baseAbs);
|
|
2274
|
+
}
|
|
2275
|
+
else {
|
|
2276
|
+
const content = readTextWarn(fileAbs, rel, warnings);
|
|
2277
|
+
if (content && content.trim()) {
|
|
2278
|
+
findings.push(makeFinding(root, rel, source, name, content.trim().slice(0, 200000), undefined, undefined, baseAbs));
|
|
2279
|
+
}
|
|
1558
2280
|
}
|
|
1559
2281
|
}
|
|
1560
2282
|
}
|
|
1561
2283
|
}
|
|
1562
2284
|
}
|
|
1563
|
-
function collectGenericCommands(node, file, root, findings, source, prefix = "") {
|
|
2285
|
+
function collectGenericCommands(node, file, root, findings, source, prefix = "", baseAbs = root) {
|
|
1564
2286
|
if (!node || typeof node !== "object")
|
|
1565
2287
|
return;
|
|
1566
2288
|
const obj = node;
|
|
1567
2289
|
if (typeof obj.command === "string" && obj.command.trim()) {
|
|
1568
2290
|
const args = Array.isArray(obj.args) ? obj.args.filter((a) => typeof a === "string").join(" ") : "";
|
|
1569
|
-
findings.push(makeFinding(root, file, source, prefix || "command", [obj.command, args].filter(Boolean).join(" "), String(findings.length)));
|
|
2291
|
+
findings.push(makeFinding(root, file, source, prefix || "command", [obj.command, args].filter(Boolean).join(" "), String(findings.length), undefined, baseAbs));
|
|
1570
2292
|
}
|
|
1571
2293
|
for (const [key, value] of Object.entries(obj)) {
|
|
1572
2294
|
if (value && typeof value === "object")
|
|
1573
|
-
collectGenericCommands(value, file, root, findings, source, prefix ? `${prefix}:${key}` : key);
|
|
2295
|
+
collectGenericCommands(value, file, root, findings, source, prefix ? `${prefix}:${key}` : key, baseAbs);
|
|
1574
2296
|
}
|
|
1575
2297
|
}
|
|
1576
|
-
function scanMcpFile(root, rel, findings, warnings) {
|
|
2298
|
+
function scanMcpFile(root, rel, findings, warnings, state, baseAbs, isNested) {
|
|
1577
2299
|
if (!existsSync(join(root, rel)))
|
|
1578
2300
|
return;
|
|
2301
|
+
if (!claimFile(state, "mcp", rel, isNested))
|
|
2302
|
+
return;
|
|
1579
2303
|
const data = readJson(root, rel, warnings);
|
|
1580
2304
|
if (!data)
|
|
1581
2305
|
return;
|
|
@@ -1590,9 +2314,9 @@ function scanMcpFile(root, rel, findings, warnings) {
|
|
|
1590
2314
|
const args = Array.isArray(c.args) ? c.args.filter((a) => typeof a === "string").join(" ") : "";
|
|
1591
2315
|
const full = [command, args].filter(Boolean).join(" ").trim();
|
|
1592
2316
|
if (full)
|
|
1593
|
-
findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}`, full));
|
|
2317
|
+
findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}`, full, undefined, undefined, baseAbs));
|
|
1594
2318
|
if (typeof c.url === "string" && c.url) {
|
|
1595
|
-
findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}:url`, c.url));
|
|
2319
|
+
findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}:url`, c.url, undefined, undefined, baseAbs));
|
|
1596
2320
|
}
|
|
1597
2321
|
}
|
|
1598
2322
|
}
|
|
@@ -1620,26 +2344,37 @@ function scanMcpFile(root, rel, findings, warnings) {
|
|
|
1620
2344
|
* resolved is still inventoried, as the literal alias text at UNKNOWN,
|
|
1621
2345
|
* so no executable step ever yields zero findings.
|
|
1622
2346
|
*/
|
|
1623
|
-
function scanWorkflows(root, findings, warnings) {
|
|
1624
|
-
const
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
if (!/\.(yml|yaml)$/.test(name))
|
|
2347
|
+
function scanWorkflows(root, findings, warnings, state) {
|
|
2348
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2349
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2350
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2351
|
+
const dir = `${base}.github/workflows`;
|
|
2352
|
+
const abs = join(root, dir);
|
|
2353
|
+
if (!existsSync(abs))
|
|
2354
|
+
continue;
|
|
2355
|
+
let entries = [];
|
|
2356
|
+
try {
|
|
2357
|
+
entries = readdirSync(abs);
|
|
2358
|
+
}
|
|
2359
|
+
catch {
|
|
2360
|
+
warnings.push(`Unreadable directory skipped: ${dir} could not be listed; the scan is partial`);
|
|
1638
2361
|
continue;
|
|
1639
|
-
|
|
2362
|
+
}
|
|
2363
|
+
for (const name of entries.sort()) {
|
|
2364
|
+
if (!/\.(yml|yaml)$/.test(name))
|
|
2365
|
+
continue;
|
|
2366
|
+
const rel = `${dir}/${name}`;
|
|
2367
|
+
if (!claimFile(state, "workflows", rel, prefix !== ""))
|
|
2368
|
+
continue;
|
|
2369
|
+
scanOneWorkflow(root, rel, baseAbs, findings, warnings);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
function scanOneWorkflow(root, rel, baseAbs, findings, warnings) {
|
|
2374
|
+
{
|
|
1640
2375
|
const rawFile = readTextWarn(join(root, rel), rel, warnings);
|
|
1641
2376
|
if (rawFile === null)
|
|
1642
|
-
|
|
2377
|
+
return;
|
|
1643
2378
|
const raw = rawFile.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1644
2379
|
const lines = raw.split("\n");
|
|
1645
2380
|
const indentOf = (l) => l.length - l.trimStart().length;
|
|
@@ -1731,14 +2466,14 @@ function scanWorkflows(root, findings, warnings) {
|
|
|
1731
2466
|
if (aliasStep) {
|
|
1732
2467
|
const name = aliasStep[1];
|
|
1733
2468
|
const resolved = scalarAnchors.has(name) || mapAnchors.has(name) ? resolveAlias(`*${name}`) : `*${name}`;
|
|
1734
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo));
|
|
2469
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo, baseAbs));
|
|
1735
2470
|
continue;
|
|
1736
2471
|
}
|
|
1737
2472
|
const mergeKey = line.match(/^\s*<<:\s*\*([A-Za-z0-9_-]+)\s*(#.*)?$/);
|
|
1738
2473
|
if (mergeKey) {
|
|
1739
2474
|
const name = mergeKey[1];
|
|
1740
2475
|
const resolved = scalarAnchors.has(name) || mapAnchors.has(name) ? resolveAlias(`*${name}`) : `*${name}`;
|
|
1741
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo));
|
|
2476
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo, baseAbs));
|
|
1742
2477
|
continue;
|
|
1743
2478
|
}
|
|
1744
2479
|
const m = line.match(/^(\s*)-?\s*run:\s*(.*)$/);
|
|
@@ -1761,19 +2496,19 @@ function scanWorkflows(root, findings, warnings) {
|
|
|
1761
2496
|
if (blockHeader) {
|
|
1762
2497
|
const { body, end } = collectBlock(idx, runIndent);
|
|
1763
2498
|
if (body) {
|
|
1764
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", body, String(i++), lineNoRun));
|
|
2499
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", body, String(i++), lineNoRun, baseAbs));
|
|
1765
2500
|
}
|
|
1766
2501
|
idx = end - 1;
|
|
1767
2502
|
continue;
|
|
1768
2503
|
}
|
|
1769
2504
|
if (rest === "")
|
|
1770
2505
|
continue;
|
|
1771
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", rest, String(i++), lineNoRun));
|
|
2506
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", rest, String(i++), lineNoRun, baseAbs));
|
|
1772
2507
|
}
|
|
1773
2508
|
}
|
|
1774
2509
|
}
|
|
1775
|
-
function scanGitHooks(root, findings, warnings) {
|
|
1776
|
-
scanDirFiles(root, ".githooks", "githooks", findings, warnings);
|
|
2510
|
+
function scanGitHooks(root, findings, warnings, state) {
|
|
2511
|
+
scanDirFiles(root, ".githooks", "githooks", "githooks", findings, warnings, state);
|
|
1777
2512
|
}
|
|
1778
2513
|
/**
|
|
1779
2514
|
* Empty estate note (P-014, hookcheck half). A scan that inventories
|
|
@@ -1810,20 +2545,44 @@ export function scanHooks(root) {
|
|
|
1810
2545
|
note: EMPTY_ESTATE_NOTE,
|
|
1811
2546
|
};
|
|
1812
2547
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
2548
|
+
const disc = discoverSurfaces(root, warnings);
|
|
2549
|
+
const state = { disc, inventoried: new Set(), familyFound: new Map(), familyCapped: new Map(), partial: false };
|
|
2550
|
+
scanPackageJson(root, findings, warnings, state);
|
|
2551
|
+
scanClaude(root, findings, warnings, state);
|
|
2552
|
+
scanPluginHooks(root, findings, warnings, state);
|
|
2553
|
+
scanVscode(root, findings, warnings, state);
|
|
2554
|
+
scanDirFiles(root, ".husky", "husky", "husky", findings, warnings, state);
|
|
2555
|
+
scanLefthook(root, findings, warnings, state);
|
|
2556
|
+
scanPreCommit(root, findings, warnings, state);
|
|
2557
|
+
scanAgentDirs(root, findings, warnings, state);
|
|
2558
|
+
// MCP config surfaces, each inventoried exactly once (P-035), at the
|
|
2559
|
+
// scan root and at every nested project root (P-065).
|
|
1821
2560
|
// .windsurf/mcp.json joined the covered surfaces in v0.1.6 (P-034);
|
|
1822
2561
|
// .vscode/mcp.json is handled inside scanVscode.
|
|
1823
|
-
for (const
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
2562
|
+
for (const prefix of disc.projectRoots) {
|
|
2563
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2564
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2565
|
+
for (const name of [".mcp.json", "mcp.json", ".cursor/mcp.json", ".windsurf/mcp.json", ".codex/mcp.json"]) {
|
|
2566
|
+
scanMcpFile(root, `${base}${name}`, findings, warnings, state, baseAbs, prefix !== "");
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
scanWorkflows(root, findings, warnings, state);
|
|
2570
|
+
scanGitHooks(root, findings, warnings, state);
|
|
2571
|
+
// Bounded scan honesty (P-065): a scan cut short by the discovery
|
|
2572
|
+
// bounds must say so, naming the bound, instead of presenting as
|
|
2573
|
+
// complete. Surfaces spotted just below the depth bound are counted
|
|
2574
|
+
// in the warning, never silently dropped.
|
|
2575
|
+
if (disc.depthBoundHit) {
|
|
2576
|
+
const below = disc.surfacesBelowBound > 0
|
|
2577
|
+
? `; at least ${disc.surfacesBelowBound} of the directories just below the bound contain hook surface files or config directories that were not inventoried`
|
|
2578
|
+
: "";
|
|
2579
|
+
warnings.push(`Nested discovery bounded at depth ${NESTED_MAX_DEPTH}: project directories more than ${NESTED_MAX_DEPTH} levels below the scan root were not scanned (${disc.frontierDirs} ${disc.frontierDirs === 1 ? "directory" : "directories"} below the bound not entered${below}); the scan is partial (bounded)`);
|
|
2580
|
+
}
|
|
2581
|
+
for (const [family, capped] of state.familyCapped) {
|
|
2582
|
+
const label = FAMILY_LABELS[family] ?? family;
|
|
2583
|
+
warnings.push(`Nested ${label} inventory capped at ${NESTED_MAX_FILES_PER_SURFACE} files (${state.familyFound.get(family) ?? 0} found); the remaining nested ${label} files were not inventoried and the scan is partial`);
|
|
2584
|
+
void capped;
|
|
2585
|
+
}
|
|
1827
2586
|
// Dedupe identical ids (agent dir scan may double count mcp.json).
|
|
1828
2587
|
const seen = new Set();
|
|
1829
2588
|
const deduped = findings.filter((f) => {
|
|
@@ -1839,12 +2598,14 @@ export function scanHooks(root) {
|
|
|
1839
2598
|
risky: deduped.filter((f) => f.classification === "RISKY").length,
|
|
1840
2599
|
unknown: deduped.filter((f) => f.classification === "UNKNOWN").length,
|
|
1841
2600
|
};
|
|
1842
|
-
// Partial scan floor (P-049): when any inventoried
|
|
1843
|
-
// could not be read,
|
|
1844
|
-
//
|
|
1845
|
-
//
|
|
2601
|
+
// Partial scan floor (P-049, extended by P-065): when any inventoried
|
|
2602
|
+
// file or directory could not be read, when a file cap left surface
|
|
2603
|
+
// files uninventoried, or when hook surfaces were spotted just below
|
|
2604
|
+
// the discovery depth bound, the scan did not see the whole estate,
|
|
2605
|
+
// so a bare CLEAN is not an honest verdict. Floor at REVIEW; the
|
|
2606
|
+
// warnings name exactly what was missed.
|
|
1846
2607
|
let verdict = computeVerdict(deduped);
|
|
1847
|
-
if (verdict === "CLEAN" && warnings.some((w) => w.startsWith("Unreadable"))) {
|
|
2608
|
+
if (verdict === "CLEAN" && (warnings.some((w) => w.startsWith("Unreadable")) || state.partial || disc.surfacesBelowBound > 0)) {
|
|
1848
2609
|
verdict = "REVIEW";
|
|
1849
2610
|
}
|
|
1850
2611
|
return {
|