@reneturcios/hookcheck 0.1.5 → 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/README.md +12 -7
- package/dist/core.d.ts +41 -21
- package/dist/core.js +998 -91
- 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
|
@@ -4,18 +4,27 @@
|
|
|
4
4
|
* No network, no LLM. Every classification comes from explicit pattern
|
|
5
5
|
* rules so the same repo always produces the same report.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
|
-
import { join, relative, resolve, sep } from "node:path";
|
|
9
|
-
export const VERSION = "0.1.
|
|
7
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
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
|
-
|
|
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.
|
|
@@ -943,25 +1417,98 @@ export function classifyCommand(command, lang = "shell") {
|
|
|
943
1417
|
// referenced local file itself contains risky patterns we escalate below.
|
|
944
1418
|
return { classification: "UNKNOWN", rules: [], evidence: "no known safe pattern and no risky pattern matched; manual review needed" };
|
|
945
1419
|
}
|
|
1420
|
+
const SCRIPT_TOKEN_RE = /[A-Za-z0-9_.\/-]+\.(?:mjs|cjs|js|ts|sh|py)\b/g;
|
|
1421
|
+
/**
|
|
1422
|
+
* Second hop detection (P-031). The one hop limit means a payload two
|
|
1423
|
+
* files deep is never read, and that limit is documented. What was not
|
|
1424
|
+
* acceptable was the chain head classifying SAFE on the strength of its
|
|
1425
|
+
* own command text (`node scripts/a.js` matches a safe dev pattern)
|
|
1426
|
+
* while its target's whole job is spawning a further local script the
|
|
1427
|
+
* scanner cannot see: SAFE is an affirmative statement, and the README
|
|
1428
|
+
* floor for chains beyond the follower is UNKNOWN or REVIEW. This
|
|
1429
|
+
* detector returns the local script tokens that `content` EXECUTES:
|
|
1430
|
+
* script tokens inside exec style call spans (execSync, spawn, Popen,
|
|
1431
|
+
* os.system, and friends), and interpreter invocations (`node x.js`,
|
|
1432
|
+
* `bash x.sh`) appearing as command text. Spawning a plain binary
|
|
1433
|
+
* (spawn('esbuild', ...)) executes no local script and does not count,
|
|
1434
|
+
* and a mere require or mention of a file is not a spawn.
|
|
1435
|
+
*/
|
|
1436
|
+
function secondHopScripts(scanRootAbs, baseAbs, content, selfAbs) {
|
|
1437
|
+
const text = stripComments(content);
|
|
1438
|
+
const found = new Set();
|
|
1439
|
+
const collectFrom = (segText) => {
|
|
1440
|
+
for (const tok of segText.match(SCRIPT_TOKEN_RE) ?? [])
|
|
1441
|
+
found.add(tok);
|
|
1442
|
+
};
|
|
1443
|
+
// Exec style call spans: everything up to the matching close paren.
|
|
1444
|
+
const callRe = /\b(?:execSync|execFileSync|execSync|execFile|spawnSync|spawn|exec|system|popen|Popen|runInThisContext|runInNewContext)\s*\(/g;
|
|
1445
|
+
let cm;
|
|
1446
|
+
while ((cm = callRe.exec(text)) !== null) {
|
|
1447
|
+
const span = callSpan(text, cm.index + cm[0].length - 1);
|
|
1448
|
+
collectFrom(text.slice(span.start, span.end));
|
|
1449
|
+
}
|
|
1450
|
+
// Interpreter invocations written as command text anywhere (shell
|
|
1451
|
+
// lines, command strings outside the calls above).
|
|
1452
|
+
const interpRe = /(?:^|[\s'"`(;|&])(?:node|bash|sh|zsh|python3?|perl|ruby)\s+(?:\.\/)?([A-Za-z0-9_.\/-]+\.(?:mjs|cjs|js|ts|sh|py))\b/gm;
|
|
1453
|
+
let im;
|
|
1454
|
+
while ((im = interpRe.exec(text)) !== null)
|
|
1455
|
+
found.add(im[1]);
|
|
1456
|
+
const out = [];
|
|
1457
|
+
for (const tok of found) {
|
|
1458
|
+
const rel = tok.replace(/^\.\//, "");
|
|
1459
|
+
if (rel.includes("://"))
|
|
1460
|
+
continue;
|
|
1461
|
+
for (const base of [baseAbs, scanRootAbs]) {
|
|
1462
|
+
const abs = resolve(base, rel);
|
|
1463
|
+
if (abs === selfAbs)
|
|
1464
|
+
continue;
|
|
1465
|
+
if (abs !== scanRootAbs && !abs.startsWith(scanRootAbs + sep))
|
|
1466
|
+
continue;
|
|
1467
|
+
let st;
|
|
1468
|
+
try {
|
|
1469
|
+
st = statSync(abs);
|
|
1470
|
+
}
|
|
1471
|
+
catch {
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
if (st.isFile()) {
|
|
1475
|
+
out.push(rel);
|
|
1476
|
+
break;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
return out;
|
|
1481
|
+
}
|
|
946
1482
|
/**
|
|
947
1483
|
* If a command references local script files, inspect those files for risky
|
|
948
1484
|
* patterns too (one hop). References are found by token, not only after an
|
|
949
1485
|
* interpreter name, so `node setup.mjs`, `./setup.sh`, `node --require
|
|
950
1486
|
* ./setup.mjs`, and `bash setup.sh` all resolve. References that escape the
|
|
951
|
-
* scanned root are never followed.
|
|
1487
|
+
* scanned root are never followed. Local references resolve against
|
|
1488
|
+
* baseAbs first (the directory of the file that carries the hook, which
|
|
1489
|
+
* matters for nested package.json files) and then against the scan root.
|
|
1490
|
+
*
|
|
1491
|
+
* P-031 floor: when a referenced file is not itself RISKY but spawns a
|
|
1492
|
+
* further local script, the chain continues past the one hop limit, so a
|
|
1493
|
+
* SAFE classification is floored to UNKNOWN with the second hop named in
|
|
1494
|
+
* the evidence. The payload at hop two stays unclassified (the limit),
|
|
1495
|
+
* but the head can no longer be affirmatively cleared.
|
|
952
1496
|
*/
|
|
953
|
-
function escalateWithLocalFile(root, finding) {
|
|
954
|
-
const
|
|
955
|
-
const tokens = Array.from(new Set(finding.command.match(tokenRe) ?? [])).slice(0, 5);
|
|
1497
|
+
function escalateWithLocalFile(root, finding, baseAbs = root) {
|
|
1498
|
+
const tokens = Array.from(new Set(finding.command.match(SCRIPT_TOKEN_RE) ?? [])).slice(0, 5);
|
|
956
1499
|
if (tokens.length === 0)
|
|
957
1500
|
return finding;
|
|
958
1501
|
const rootAbs = resolve(root);
|
|
1502
|
+
const baseResolved = resolve(baseAbs);
|
|
959
1503
|
let out = finding;
|
|
960
1504
|
for (const token of tokens) {
|
|
961
1505
|
const rel = token.replace(/^\.\//, "");
|
|
962
1506
|
if (rel.includes("://"))
|
|
963
1507
|
continue;
|
|
964
|
-
|
|
1508
|
+
let abs = resolve(baseResolved, rel);
|
|
1509
|
+
if ((abs !== rootAbs && !abs.startsWith(rootAbs + sep)) || !existsSync(abs)) {
|
|
1510
|
+
abs = resolve(rootAbs, rel);
|
|
1511
|
+
}
|
|
965
1512
|
if (abs !== rootAbs && !abs.startsWith(rootAbs + sep))
|
|
966
1513
|
continue;
|
|
967
1514
|
if (!existsSync(abs))
|
|
@@ -991,15 +1538,25 @@ function escalateWithLocalFile(root, finding) {
|
|
|
991
1538
|
rules,
|
|
992
1539
|
evidence: `${out.evidence === "" ? "" : out.evidence + "; "}referenced local file ${rel} is RISKY: ${inner.evidence}`,
|
|
993
1540
|
};
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
const hops = secondHopScripts(rootAbs, dirname(abs), content, abs);
|
|
1544
|
+
if (hops.length > 0) {
|
|
1545
|
+
const note = `referenced local file ${rel} spawns further local script(s) ${hops.join(", ")} beyond the one hop limit; the chain head cannot be verified past hop one`;
|
|
1546
|
+
out = {
|
|
1547
|
+
...out,
|
|
1548
|
+
classification: out.classification === "SAFE" ? "UNKNOWN" : out.classification,
|
|
1549
|
+
evidence: `${out.evidence === "" ? "" : out.evidence + "; "}${note}; classification floored at UNKNOWN`,
|
|
1550
|
+
};
|
|
994
1551
|
}
|
|
995
1552
|
}
|
|
996
1553
|
return out;
|
|
997
1554
|
}
|
|
998
1555
|
/** Classify a command string, including one hop into local files it runs. */
|
|
999
|
-
function classifyWithLocalFile(root, source, command) {
|
|
1556
|
+
function classifyWithLocalFile(root, source, command, baseAbs = root) {
|
|
1000
1557
|
const c = classifyCommand(command);
|
|
1001
1558
|
const temp = { id: "", file: "", source, hook: "", command, classification: c.classification, rules: c.rules, evidence: c.evidence };
|
|
1002
|
-
const escalated = escalateWithLocalFile(root, temp);
|
|
1559
|
+
const escalated = escalateWithLocalFile(root, temp, baseAbs);
|
|
1003
1560
|
return { classification: escalated.classification, rules: escalated.rules, evidence: escalated.evidence };
|
|
1004
1561
|
}
|
|
1005
1562
|
/**
|
|
@@ -1008,7 +1565,7 @@ function classifyWithLocalFile(root, source, command) {
|
|
|
1008
1565
|
* script is RISKY (directly or through a local file it runs), the install
|
|
1009
1566
|
* hook inherits the classification.
|
|
1010
1567
|
*/
|
|
1011
|
-
function escalateWithScriptChain(root, scripts, currentName, finding) {
|
|
1568
|
+
function escalateWithScriptChain(root, scripts, currentName, finding, baseAbs = root) {
|
|
1012
1569
|
const chainRe = /\b(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?([A-Za-z0-9:_-]+)/g;
|
|
1013
1570
|
let out = finding;
|
|
1014
1571
|
let m;
|
|
@@ -1019,7 +1576,7 @@ function escalateWithScriptChain(root, scripts, currentName, finding) {
|
|
|
1019
1576
|
const chained = scripts[name];
|
|
1020
1577
|
if (typeof chained !== "string" || !chained.trim())
|
|
1021
1578
|
continue;
|
|
1022
|
-
const inner = classifyWithLocalFile(root, "package.json", chained);
|
|
1579
|
+
const inner = classifyWithLocalFile(root, "package.json", chained, baseAbs);
|
|
1023
1580
|
if (inner.classification === "RISKY") {
|
|
1024
1581
|
const rules = Array.from(new Set([...out.rules, ...inner.rules]));
|
|
1025
1582
|
out = {
|
|
@@ -1053,6 +1610,48 @@ function escalateWithScriptChain(root, scripts, currentName, finding) {
|
|
|
1053
1610
|
* read, a download by itself) classify RISKY at most, and a RISKY-only
|
|
1054
1611
|
* estate verdicts REVIEW, never COMPROMISED-PATTERN.
|
|
1055
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
|
+
}
|
|
1056
1655
|
export function computeVerdict(findings) {
|
|
1057
1656
|
let review = false;
|
|
1058
1657
|
for (const f of findings) {
|
|
@@ -1061,7 +1660,10 @@ export function computeVerdict(findings) {
|
|
|
1061
1660
|
const hasSevere = metas.some((x) => x.severe);
|
|
1062
1661
|
const hasCredStore = f.rules.includes("CRED-ACCESS");
|
|
1063
1662
|
const hasNet = metas.some((x) => x.remote);
|
|
1064
|
-
|
|
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))
|
|
1065
1667
|
return "COMPROMISED-PATTERN";
|
|
1066
1668
|
review = true;
|
|
1067
1669
|
}
|
|
@@ -1071,7 +1673,7 @@ export function computeVerdict(findings) {
|
|
|
1071
1673
|
}
|
|
1072
1674
|
return review ? "REVIEW" : "CLEAN";
|
|
1073
1675
|
}
|
|
1074
|
-
function makeFinding(root, file, source, hook, command, idSuffix, line) {
|
|
1676
|
+
function makeFinding(root, file, source, hook, command, idSuffix, line, baseAbs = root) {
|
|
1075
1677
|
const c = classifyCommand(command);
|
|
1076
1678
|
const id = `${file}:${hook}${idSuffix !== undefined ? `:${idSuffix}` : ""}`;
|
|
1077
1679
|
const base = {
|
|
@@ -1080,7 +1682,7 @@ function makeFinding(root, file, source, hook, command, idSuffix, line) {
|
|
|
1080
1682
|
classification: c.classification, rules: c.rules,
|
|
1081
1683
|
evidence: c.evidence,
|
|
1082
1684
|
};
|
|
1083
|
-
return escalateWithLocalFile(root, base);
|
|
1685
|
+
return escalateWithLocalFile(root, base, baseAbs);
|
|
1084
1686
|
}
|
|
1085
1687
|
function readText(abs) {
|
|
1086
1688
|
try {
|
|
@@ -1090,8 +1692,26 @@ function readText(abs) {
|
|
|
1090
1692
|
return null;
|
|
1091
1693
|
}
|
|
1092
1694
|
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Read a file the scanner has decided to inventory, warning loudly when
|
|
1697
|
+
* it cannot be read (P-049). A file that exists but cannot be read (the
|
|
1698
|
+
* classic case is mode 000) must never vanish silently: the warning
|
|
1699
|
+
* names the file, and scanHooks floors the verdict at REVIEW whenever
|
|
1700
|
+
* any warning carries the Unreadable prefix, so a partial scan never
|
|
1701
|
+
* presents as a complete all-clear.
|
|
1702
|
+
*/
|
|
1703
|
+
function readTextWarn(abs, rel, warnings) {
|
|
1704
|
+
try {
|
|
1705
|
+
return readFileSync(abs, "utf8");
|
|
1706
|
+
}
|
|
1707
|
+
catch (err) {
|
|
1708
|
+
const code = err?.code ?? "error";
|
|
1709
|
+
warnings.push(`Unreadable file skipped: ${rel} could not be read (${code}); the scan is partial and this file was not inventoried`);
|
|
1710
|
+
return null;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1093
1713
|
function readJson(root, rel, warnings) {
|
|
1094
|
-
const raw =
|
|
1714
|
+
const raw = readTextWarn(join(root, rel), rel, warnings);
|
|
1095
1715
|
if (raw === null)
|
|
1096
1716
|
return null;
|
|
1097
1717
|
// Strip a UTF-8 BOM first: Windows editors add one, JSON.parse rejects it,
|
|
@@ -1109,14 +1729,13 @@ function readJson(root, rel, warnings) {
|
|
|
1109
1729
|
return null;
|
|
1110
1730
|
}
|
|
1111
1731
|
}
|
|
1112
|
-
function
|
|
1113
|
-
const rel = "package.json";
|
|
1732
|
+
function scanOnePackageJson(root, rel, findings, warnings) {
|
|
1114
1733
|
if (!existsSync(join(root, rel)))
|
|
1115
1734
|
return;
|
|
1116
1735
|
const data = readJson(root, rel, warnings);
|
|
1117
1736
|
if (!data) {
|
|
1118
|
-
// readJson already warned when the file exists but is malformed
|
|
1119
|
-
// parsed to a non object, note it as well.
|
|
1737
|
+
// readJson already warned when the file exists but is malformed or
|
|
1738
|
+
// unreadable. If it parsed to a non object, note it as well.
|
|
1120
1739
|
if (readText(join(root, rel)) !== null && !warnings.some((w) => w.includes(rel))) {
|
|
1121
1740
|
warnings.push(`Could not parse ${rel} as an object`);
|
|
1122
1741
|
}
|
|
@@ -1132,28 +1751,97 @@ function scanPackageJson(root, findings, warnings) {
|
|
|
1132
1751
|
}
|
|
1133
1752
|
const scripts = (data.scripts ?? {});
|
|
1134
1753
|
const watched = ["preinstall", "install", "postinstall", "prepare", "prepublish", "prepublishOnly"];
|
|
1754
|
+
// Referenced local files resolve against this package's own directory:
|
|
1755
|
+
// a nested workspace package runs its scripts from its own folder.
|
|
1756
|
+
const baseAbs = dirname(join(root, rel));
|
|
1135
1757
|
for (const name of watched) {
|
|
1136
1758
|
const cmd = scripts[name];
|
|
1137
1759
|
if (typeof cmd === "string" && cmd.trim()) {
|
|
1138
|
-
const finding = makeFinding(root, rel, "package.json", name, cmd);
|
|
1139
|
-
findings.push(escalateWithScriptChain(root, scripts, name, finding));
|
|
1760
|
+
const finding = makeFinding(root, rel, "package.json", name, cmd, undefined, undefined, baseAbs);
|
|
1761
|
+
findings.push(escalateWithScriptChain(root, scripts, name, finding, baseAbs));
|
|
1140
1762
|
}
|
|
1141
1763
|
}
|
|
1142
1764
|
}
|
|
1143
|
-
|
|
1765
|
+
/**
|
|
1766
|
+
* Nested package.json discovery (P-032). Install scripts in workspace
|
|
1767
|
+
* subpackages (packages/foo, apps/web) run on a root install, so a
|
|
1768
|
+
* root-only scan misses exactly the surface a worm would persist in.
|
|
1769
|
+
* The walk is bounded so a hostile or giant tree cannot explode the
|
|
1770
|
+
* report: directories are visited in sorted order to depth
|
|
1771
|
+
* PACKAGE_MAX_DEPTH (the package.json directory depth below the root),
|
|
1772
|
+
* node_modules, .git, and dot directories are never entered, symlinked
|
|
1773
|
+
* directories are never followed (a symlink loop cannot hang the scan),
|
|
1774
|
+
* and at most PACKAGE_MAX_NESTED nested files are inventoried, with the
|
|
1775
|
+
* cap reported as a warning when it is hit.
|
|
1776
|
+
*/
|
|
1777
|
+
export const PACKAGE_MAX_DEPTH = 4;
|
|
1778
|
+
export const PACKAGE_MAX_NESTED = 200;
|
|
1779
|
+
function discoverNestedPackageJson(root, warnings) {
|
|
1780
|
+
const found = [];
|
|
1781
|
+
const skipDirs = new Set(["node_modules", ".git"]);
|
|
1782
|
+
const stack = [{ abs: root, rel: "", depth: 0 }];
|
|
1783
|
+
while (stack.length > 0) {
|
|
1784
|
+
const cur = stack.pop();
|
|
1785
|
+
let entries = [];
|
|
1786
|
+
try {
|
|
1787
|
+
entries = readdirSync(cur.abs);
|
|
1788
|
+
}
|
|
1789
|
+
catch {
|
|
1790
|
+
if (cur.rel)
|
|
1791
|
+
warnings.push(`Unreadable directory skipped: ${cur.rel} could not be listed; the scan is partial`);
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1794
|
+
entries.sort();
|
|
1795
|
+
if (cur.depth >= 1 && entries.includes("package.json")) {
|
|
1796
|
+
found.push(cur.rel === "" ? "package.json" : `${cur.rel}/package.json`);
|
|
1797
|
+
}
|
|
1798
|
+
if (cur.depth >= PACKAGE_MAX_DEPTH)
|
|
1799
|
+
continue;
|
|
1800
|
+
// Push in reverse so the stack pops in sorted order.
|
|
1801
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1802
|
+
const name = entries[i];
|
|
1803
|
+
if (skipDirs.has(name) || name.startsWith("."))
|
|
1804
|
+
continue;
|
|
1805
|
+
const childAbs = join(cur.abs, name);
|
|
1806
|
+
let lst;
|
|
1807
|
+
try {
|
|
1808
|
+
lst = lstatSync(childAbs);
|
|
1809
|
+
}
|
|
1810
|
+
catch {
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
if (lst.isSymbolicLink() || !lst.isDirectory())
|
|
1814
|
+
continue;
|
|
1815
|
+
stack.push({ abs: childAbs, rel: cur.rel === "" ? name : `${cur.rel}/${name}`, depth: cur.depth + 1 });
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
found.sort();
|
|
1819
|
+
if (found.length > PACKAGE_MAX_NESTED) {
|
|
1820
|
+
warnings.push(`Nested package.json inventory capped at ${PACKAGE_MAX_NESTED} files (${found.length} found); the remaining nested package.json files were not inventoried and the scan is partial`);
|
|
1821
|
+
return found.slice(0, PACKAGE_MAX_NESTED);
|
|
1822
|
+
}
|
|
1823
|
+
return found;
|
|
1824
|
+
}
|
|
1825
|
+
function scanPackageJson(root, findings, warnings) {
|
|
1826
|
+
scanOnePackageJson(root, "package.json", findings, warnings);
|
|
1827
|
+
for (const rel of discoverNestedPackageJson(root, warnings)) {
|
|
1828
|
+
scanOnePackageJson(root, rel, findings, warnings);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
function collectClaudeHooks(node, file, root, findings, prefix, source = "claude-settings", baseAbs = root) {
|
|
1144
1832
|
if (!node || typeof node !== "object")
|
|
1145
1833
|
return;
|
|
1146
1834
|
const obj = node;
|
|
1147
1835
|
if (typeof obj.command === "string" && obj.command.trim()) {
|
|
1148
|
-
findings.push(makeFinding(root, file,
|
|
1836
|
+
findings.push(makeFinding(root, file, source, prefix || "hook", obj.command, String(findings.length), undefined, baseAbs));
|
|
1149
1837
|
return;
|
|
1150
1838
|
}
|
|
1151
1839
|
for (const [key, value] of Object.entries(obj)) {
|
|
1152
1840
|
if (Array.isArray(value)) {
|
|
1153
|
-
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));
|
|
1154
1842
|
}
|
|
1155
1843
|
else if (value && typeof value === "object") {
|
|
1156
|
-
collectClaudeHooks(value, file, root, findings, prefix ? `${prefix}:${key}` : key);
|
|
1844
|
+
collectClaudeHooks(value, file, root, findings, prefix ? `${prefix}:${key}` : key, source, baseAbs);
|
|
1157
1845
|
}
|
|
1158
1846
|
}
|
|
1159
1847
|
}
|
|
@@ -1227,6 +1915,100 @@ function scanClaude(root, findings, warnings) {
|
|
|
1227
1915
|
collectClaudeHooks(hooks, rel, root, findings, "");
|
|
1228
1916
|
}
|
|
1229
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
|
+
}
|
|
1230
2012
|
function scanVscode(root, findings, warnings) {
|
|
1231
2013
|
const rel = ".vscode/tasks.json";
|
|
1232
2014
|
if (existsSync(join(root, rel))) {
|
|
@@ -1268,7 +2050,7 @@ function scanVscode(root, findings, warnings) {
|
|
|
1268
2050
|
// MCP config inside .vscode
|
|
1269
2051
|
scanMcpFile(root, ".vscode/mcp.json", findings, warnings);
|
|
1270
2052
|
}
|
|
1271
|
-
function scanDirFiles(root, dirRel, source, findings) {
|
|
2053
|
+
function scanDirFiles(root, dirRel, source, findings, warnings) {
|
|
1272
2054
|
const abs = join(root, dirRel);
|
|
1273
2055
|
if (!existsSync(abs))
|
|
1274
2056
|
return;
|
|
@@ -1277,6 +2059,7 @@ function scanDirFiles(root, dirRel, source, findings) {
|
|
|
1277
2059
|
entries = readdirSync(abs);
|
|
1278
2060
|
}
|
|
1279
2061
|
catch {
|
|
2062
|
+
warnings.push(`Unreadable directory skipped: ${dirRel} could not be listed; the scan is partial`);
|
|
1280
2063
|
return;
|
|
1281
2064
|
}
|
|
1282
2065
|
for (const name of entries.sort()) {
|
|
@@ -1292,7 +2075,7 @@ function scanDirFiles(root, dirRel, source, findings) {
|
|
|
1292
2075
|
continue;
|
|
1293
2076
|
if (name === "_" || name.startsWith("."))
|
|
1294
2077
|
continue;
|
|
1295
|
-
const content =
|
|
2078
|
+
const content = readTextWarn(fileAbs, `${dirRel}/${name}`, warnings);
|
|
1296
2079
|
if (content === null)
|
|
1297
2080
|
continue;
|
|
1298
2081
|
const firstMeaningful = content.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#") && !l.startsWith("set -")).join(" ");
|
|
@@ -1304,11 +2087,11 @@ function scanDirFiles(root, dirRel, source, findings) {
|
|
|
1304
2087
|
void firstMeaningful;
|
|
1305
2088
|
}
|
|
1306
2089
|
}
|
|
1307
|
-
function scanLefthook(root, findings) {
|
|
2090
|
+
function scanLefthook(root, findings, warnings) {
|
|
1308
2091
|
for (const rel of ["lefthook.yml", "lefthook.yaml", ".lefthook.yml"]) {
|
|
1309
2092
|
if (!existsSync(join(root, rel)))
|
|
1310
2093
|
continue;
|
|
1311
|
-
const raw =
|
|
2094
|
+
const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1312
2095
|
const runRe = /^\s*run:\s*(.+)$/gm;
|
|
1313
2096
|
let m;
|
|
1314
2097
|
let i = 0;
|
|
@@ -1317,11 +2100,11 @@ function scanLefthook(root, findings) {
|
|
|
1317
2100
|
}
|
|
1318
2101
|
}
|
|
1319
2102
|
}
|
|
1320
|
-
function scanPreCommit(root, findings) {
|
|
2103
|
+
function scanPreCommit(root, findings, warnings) {
|
|
1321
2104
|
const rel = ".pre-commit-config.yaml";
|
|
1322
2105
|
if (!existsSync(join(root, rel)))
|
|
1323
2106
|
return;
|
|
1324
|
-
const raw =
|
|
2107
|
+
const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1325
2108
|
const entryRe = /^\s*entry:\s*(.+)$/gm;
|
|
1326
2109
|
let m;
|
|
1327
2110
|
let i = 0;
|
|
@@ -1370,19 +2153,23 @@ function scanAgentDirs(root, findings, warnings) {
|
|
|
1370
2153
|
if (!st.isFile())
|
|
1371
2154
|
continue;
|
|
1372
2155
|
const rel = `${dir}/${name}`;
|
|
2156
|
+
// mcp.json is an MCP config surface, inventoried exactly once by
|
|
2157
|
+
// the central scanMcpFile pass in scanHooks (P-035). Running it
|
|
2158
|
+
// through the generic command walk here as well counted every
|
|
2159
|
+
// .cursor/mcp.json server twice.
|
|
2160
|
+
if (name === "mcp.json")
|
|
2161
|
+
continue;
|
|
1373
2162
|
if (name.endsWith(".json")) {
|
|
1374
2163
|
const data = readJson(root, rel, warnings);
|
|
1375
2164
|
if (data)
|
|
1376
2165
|
collectGenericCommands(data, rel, root, findings, source);
|
|
1377
2166
|
}
|
|
1378
2167
|
else {
|
|
1379
|
-
const content =
|
|
2168
|
+
const content = readTextWarn(fileAbs, rel, warnings);
|
|
1380
2169
|
if (content && content.trim()) {
|
|
1381
2170
|
findings.push(makeFinding(root, rel, source, name, content.trim().slice(0, 200000)));
|
|
1382
2171
|
}
|
|
1383
2172
|
}
|
|
1384
|
-
if (name === "mcp.json")
|
|
1385
|
-
scanMcpFile(root, rel, findings, warnings);
|
|
1386
2173
|
}
|
|
1387
2174
|
}
|
|
1388
2175
|
}
|
|
@@ -1429,8 +2216,24 @@ function scanMcpFile(root, rel, findings, warnings) {
|
|
|
1429
2216
|
* multi-line steps; their bodies are collected by indentation, dedented,
|
|
1430
2217
|
* and classified exactly like the same commands on one line. Every
|
|
1431
2218
|
* finding carries the 1-based line number of its run: key.
|
|
2219
|
+
*
|
|
2220
|
+
* Line endings are normalized before parsing (P-048): CRLF is a
|
|
2221
|
+
* semantics preserving encoding for YAML and GitHub Actions, but the
|
|
2222
|
+
* line parser keyed on patterns that a trailing carriage return broke,
|
|
2223
|
+
* so a Windows authored workflow inventoried as total 0. A CRLF file
|
|
2224
|
+
* now produces exactly the findings of its LF twin.
|
|
2225
|
+
*
|
|
2226
|
+
* YAML anchors defined in the same file are resolved (P-033): a scalar
|
|
2227
|
+
* alias used as a run value (run: *name) resolves to the anchored
|
|
2228
|
+
* scalar, a step that is only an alias (- *name) resolves to the
|
|
2229
|
+
* anchored mapping's run value, and a merge key step (<<: *name) is
|
|
2230
|
+
* inventoried by its resolved content. The anchored definition itself
|
|
2231
|
+
* (an x- style key outside any step) is not double counted: it is
|
|
2232
|
+
* inventoried at each use site instead. An alias that cannot be
|
|
2233
|
+
* resolved is still inventoried, as the literal alias text at UNKNOWN,
|
|
2234
|
+
* so no executable step ever yields zero findings.
|
|
1432
2235
|
*/
|
|
1433
|
-
function scanWorkflows(root, findings) {
|
|
2236
|
+
function scanWorkflows(root, findings, warnings) {
|
|
1434
2237
|
const dir = ".github/workflows";
|
|
1435
2238
|
const abs = join(root, dir);
|
|
1436
2239
|
if (!existsSync(abs))
|
|
@@ -1440,58 +2243,150 @@ function scanWorkflows(root, findings) {
|
|
|
1440
2243
|
entries = readdirSync(abs);
|
|
1441
2244
|
}
|
|
1442
2245
|
catch {
|
|
2246
|
+
warnings.push(`Unreadable directory skipped: ${dir} could not be listed; the scan is partial`);
|
|
1443
2247
|
return;
|
|
1444
2248
|
}
|
|
1445
2249
|
for (const name of entries.sort()) {
|
|
1446
2250
|
if (!/\.(yml|yaml)$/.test(name))
|
|
1447
2251
|
continue;
|
|
1448
2252
|
const rel = `${dir}/${name}`;
|
|
1449
|
-
const
|
|
2253
|
+
const rawFile = readTextWarn(join(root, rel), rel, warnings);
|
|
2254
|
+
if (rawFile === null)
|
|
2255
|
+
continue;
|
|
2256
|
+
const raw = rawFile.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1450
2257
|
const lines = raw.split("\n");
|
|
2258
|
+
const indentOf = (l) => l.length - l.trimStart().length;
|
|
2259
|
+
const unquote = (s) => {
|
|
2260
|
+
const t = s.trim();
|
|
2261
|
+
if (t.length >= 2 && ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))))
|
|
2262
|
+
return t.slice(1, -1);
|
|
2263
|
+
return t;
|
|
2264
|
+
};
|
|
2265
|
+
// Anchor tables, filled by a first pass over the whole file so a
|
|
2266
|
+
// use site resolves regardless of definition order.
|
|
2267
|
+
const scalarAnchors = new Map();
|
|
2268
|
+
const mapAnchors = new Map();
|
|
2269
|
+
const definitionRunLines = new Set();
|
|
2270
|
+
const resolveAlias = (value, depth = 0) => {
|
|
2271
|
+
const t = value.trim();
|
|
2272
|
+
const am = t.match(/^\*([A-Za-z0-9_-]+)$/);
|
|
2273
|
+
if (am && depth < 5) {
|
|
2274
|
+
if (scalarAnchors.has(am[1]))
|
|
2275
|
+
return resolveAlias(scalarAnchors.get(am[1]), depth + 1);
|
|
2276
|
+
if (mapAnchors.has(am[1]))
|
|
2277
|
+
return mapAnchors.get(am[1]);
|
|
2278
|
+
}
|
|
2279
|
+
return unquote(t);
|
|
2280
|
+
};
|
|
2281
|
+
// Collect the dedented body of a block scalar whose header sits at
|
|
2282
|
+
// line hdrIdx with run indent runIndent. Returns body and end index.
|
|
2283
|
+
const collectBlock = (hdrIdx, runIndent) => {
|
|
2284
|
+
const body = [];
|
|
2285
|
+
let j = hdrIdx + 1;
|
|
2286
|
+
for (; j < lines.length; j++) {
|
|
2287
|
+
const l = lines[j];
|
|
2288
|
+
if (l.trim() === "") {
|
|
2289
|
+
body.push("");
|
|
2290
|
+
continue;
|
|
2291
|
+
}
|
|
2292
|
+
if (indentOf(l) <= runIndent)
|
|
2293
|
+
break;
|
|
2294
|
+
body.push(l);
|
|
2295
|
+
}
|
|
2296
|
+
const nonBlank = body.filter((l) => l.trim() !== "");
|
|
2297
|
+
if (nonBlank.length === 0)
|
|
2298
|
+
return { body: "", end: j };
|
|
2299
|
+
const minIndent = Math.min(...nonBlank.map((l) => l.length - l.trimStart().length));
|
|
2300
|
+
const dedented = body.map((l) => (l.trim() === "" ? "" : l.slice(minIndent))).join("\n").trim();
|
|
2301
|
+
return { body: dedented, end: j };
|
|
2302
|
+
};
|
|
2303
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
2304
|
+
const line = lines[idx];
|
|
2305
|
+
const scalarM = line.match(/^(\s*)(?:-\s*)?([A-Za-z0-9_-]+):\s*&([A-Za-z0-9_-]+)\s+(.+?)\s*$/);
|
|
2306
|
+
if (scalarM && scalarM[2] !== "run") {
|
|
2307
|
+
scalarAnchors.set(scalarM[3], unquote(scalarM[4]));
|
|
2308
|
+
continue;
|
|
2309
|
+
}
|
|
2310
|
+
const mapM = line.match(/^(\s*)(-\s*)?([A-Za-z0-9_-]+):\s*&([A-Za-z0-9_-]+)\s*(#.*)?$/);
|
|
2311
|
+
if (mapM) {
|
|
2312
|
+
const isListItem = mapM[2] !== undefined;
|
|
2313
|
+
const baseIndent = indentOf(line) + (isListItem ? 2 : 0);
|
|
2314
|
+
for (let j = idx + 1; j < lines.length; j++) {
|
|
2315
|
+
const l = lines[j];
|
|
2316
|
+
if (l.trim() === "")
|
|
2317
|
+
continue;
|
|
2318
|
+
if (indentOf(l) <= baseIndent)
|
|
2319
|
+
break;
|
|
2320
|
+
const rm = l.match(/^(\s*)-?\s*run:\s*(.*)$/);
|
|
2321
|
+
if (rm) {
|
|
2322
|
+
const rest = rm[2].trim();
|
|
2323
|
+
const bh = rest.match(/^([|>])([+-]?)\s*(#.*)?$/);
|
|
2324
|
+
const value = bh ? collectBlock(j, rm[1].length).body : resolveAlias(rest);
|
|
2325
|
+
if (value)
|
|
2326
|
+
mapAnchors.set(mapM[4], value);
|
|
2327
|
+
// A definition outside a step (an x- key) never runs on its
|
|
2328
|
+
// own; its run line is inventoried at the use sites, not
|
|
2329
|
+
// here. A list item anchor is a real step and stays live.
|
|
2330
|
+
if (!isListItem)
|
|
2331
|
+
definitionRunLines.add(j);
|
|
2332
|
+
break;
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
1451
2337
|
let i = 0;
|
|
1452
2338
|
for (let idx = 0; idx < lines.length; idx++) {
|
|
1453
|
-
|
|
2339
|
+
if (definitionRunLines.has(idx))
|
|
2340
|
+
continue;
|
|
2341
|
+
const line = lines[idx];
|
|
2342
|
+
const lineNo = idx + 1;
|
|
2343
|
+
const aliasStep = line.match(/^\s*-\s*\*([A-Za-z0-9_-]+)\s*(#.*)?$/);
|
|
2344
|
+
if (aliasStep) {
|
|
2345
|
+
const name = aliasStep[1];
|
|
2346
|
+
const resolved = scalarAnchors.has(name) || mapAnchors.has(name) ? resolveAlias(`*${name}`) : `*${name}`;
|
|
2347
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo));
|
|
2348
|
+
continue;
|
|
2349
|
+
}
|
|
2350
|
+
const mergeKey = line.match(/^\s*<<:\s*\*([A-Za-z0-9_-]+)\s*(#.*)?$/);
|
|
2351
|
+
if (mergeKey) {
|
|
2352
|
+
const name = mergeKey[1];
|
|
2353
|
+
const resolved = scalarAnchors.has(name) || mapAnchors.has(name) ? resolveAlias(`*${name}`) : `*${name}`;
|
|
2354
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo));
|
|
2355
|
+
continue;
|
|
2356
|
+
}
|
|
2357
|
+
const m = line.match(/^(\s*)-?\s*run:\s*(.*)$/);
|
|
1454
2358
|
if (!m)
|
|
1455
2359
|
continue;
|
|
1456
2360
|
const runIndent = m[1].length;
|
|
1457
|
-
|
|
1458
|
-
const
|
|
2361
|
+
let rest = m[2].trim();
|
|
2362
|
+
const lineNoRun = idx + 1;
|
|
2363
|
+
// A run value that defines an anchor (run: &name cmd) is itself
|
|
2364
|
+
// executable; register the anchor and inventory the value.
|
|
2365
|
+
const anchorDef = rest.match(/^&([A-Za-z0-9_-]+)\s+(.+)$/);
|
|
2366
|
+
if (anchorDef) {
|
|
2367
|
+
scalarAnchors.set(anchorDef[1], unquote(anchorDef[2]));
|
|
2368
|
+
rest = anchorDef[2].trim();
|
|
2369
|
+
}
|
|
2370
|
+
else if (/^\*[A-Za-z0-9_-]+$/.test(rest)) {
|
|
2371
|
+
rest = resolveAlias(rest);
|
|
2372
|
+
}
|
|
1459
2373
|
const blockHeader = rest.match(/^([|>])([+-]?)\s*(#.*)?$/);
|
|
1460
2374
|
if (blockHeader) {
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
let j = idx + 1;
|
|
1465
|
-
for (; j < lines.length; j++) {
|
|
1466
|
-
const l = lines[j];
|
|
1467
|
-
if (l.trim() === "") {
|
|
1468
|
-
body.push("");
|
|
1469
|
-
continue;
|
|
1470
|
-
}
|
|
1471
|
-
const indent = l.length - l.trimStart().length;
|
|
1472
|
-
if (indent <= runIndent)
|
|
1473
|
-
break;
|
|
1474
|
-
body.push(l);
|
|
1475
|
-
}
|
|
1476
|
-
const nonBlank = body.filter((l) => l.trim() !== "");
|
|
1477
|
-
if (nonBlank.length > 0) {
|
|
1478
|
-
const minIndent = Math.min(...nonBlank.map((l) => l.length - l.trimStart().length));
|
|
1479
|
-
const dedented = body.map((l) => (l.trim() === "" ? "" : l.slice(minIndent))).join("\n").trim();
|
|
1480
|
-
if (dedented) {
|
|
1481
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", dedented, String(i++), lineNo));
|
|
1482
|
-
}
|
|
2375
|
+
const { body, end } = collectBlock(idx, runIndent);
|
|
2376
|
+
if (body) {
|
|
2377
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", body, String(i++), lineNoRun));
|
|
1483
2378
|
}
|
|
1484
|
-
idx =
|
|
2379
|
+
idx = end - 1;
|
|
1485
2380
|
continue;
|
|
1486
2381
|
}
|
|
1487
2382
|
if (rest === "")
|
|
1488
2383
|
continue;
|
|
1489
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", rest, String(i++),
|
|
2384
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", rest, String(i++), lineNoRun));
|
|
1490
2385
|
}
|
|
1491
2386
|
}
|
|
1492
2387
|
}
|
|
1493
|
-
function scanGitHooks(root, findings) {
|
|
1494
|
-
scanDirFiles(root, ".githooks", "githooks", findings);
|
|
2388
|
+
function scanGitHooks(root, findings, warnings) {
|
|
2389
|
+
scanDirFiles(root, ".githooks", "githooks", findings, warnings);
|
|
1495
2390
|
}
|
|
1496
2391
|
/**
|
|
1497
2392
|
* Empty estate note (P-014, hookcheck half). A scan that inventories
|
|
@@ -1530,15 +2425,19 @@ export function scanHooks(root) {
|
|
|
1530
2425
|
}
|
|
1531
2426
|
scanPackageJson(root, findings, warnings);
|
|
1532
2427
|
scanClaude(root, findings, warnings);
|
|
2428
|
+
scanPluginHooks(root, findings, warnings);
|
|
1533
2429
|
scanVscode(root, findings, warnings);
|
|
1534
|
-
scanDirFiles(root, ".husky", "husky", findings);
|
|
1535
|
-
scanLefthook(root, findings);
|
|
1536
|
-
scanPreCommit(root, findings);
|
|
2430
|
+
scanDirFiles(root, ".husky", "husky", findings, warnings);
|
|
2431
|
+
scanLefthook(root, findings, warnings);
|
|
2432
|
+
scanPreCommit(root, findings, warnings);
|
|
1537
2433
|
scanAgentDirs(root, findings, warnings);
|
|
1538
|
-
|
|
2434
|
+
// MCP config surfaces, each inventoried exactly once (P-035).
|
|
2435
|
+
// .windsurf/mcp.json joined the covered surfaces in v0.1.6 (P-034);
|
|
2436
|
+
// .vscode/mcp.json is handled inside scanVscode.
|
|
2437
|
+
for (const rel of [".mcp.json", "mcp.json", ".cursor/mcp.json", ".windsurf/mcp.json", ".codex/mcp.json"])
|
|
1539
2438
|
scanMcpFile(root, rel, findings, warnings);
|
|
1540
|
-
scanWorkflows(root, findings);
|
|
1541
|
-
scanGitHooks(root, findings);
|
|
2439
|
+
scanWorkflows(root, findings, warnings);
|
|
2440
|
+
scanGitHooks(root, findings, warnings);
|
|
1542
2441
|
// Dedupe identical ids (agent dir scan may double count mcp.json).
|
|
1543
2442
|
const seen = new Set();
|
|
1544
2443
|
const deduped = findings.filter((f) => {
|
|
@@ -1554,9 +2453,17 @@ export function scanHooks(root) {
|
|
|
1554
2453
|
risky: deduped.filter((f) => f.classification === "RISKY").length,
|
|
1555
2454
|
unknown: deduped.filter((f) => f.classification === "UNKNOWN").length,
|
|
1556
2455
|
};
|
|
2456
|
+
// Partial scan floor (P-049): when any inventoried file or directory
|
|
2457
|
+
// could not be read, the scan did not see the whole estate, so a bare
|
|
2458
|
+
// CLEAN is not an honest verdict. Floor at REVIEW; the warnings name
|
|
2459
|
+
// exactly what was missed.
|
|
2460
|
+
let verdict = computeVerdict(deduped);
|
|
2461
|
+
if (verdict === "CLEAN" && warnings.some((w) => w.startsWith("Unreadable"))) {
|
|
2462
|
+
verdict = "REVIEW";
|
|
2463
|
+
}
|
|
1557
2464
|
return {
|
|
1558
2465
|
path: root,
|
|
1559
|
-
verdict
|
|
2466
|
+
verdict,
|
|
1560
2467
|
findings: deduped,
|
|
1561
2468
|
counts,
|
|
1562
2469
|
warnings,
|