@reneturcios/hookcheck 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
8
  import { join, relative, resolve, sep } from "node:path";
9
- export const VERSION = "0.1.2";
9
+ export const VERSION = "0.1.4";
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 },
@@ -29,7 +29,15 @@ const EXTRA_RULE_META = {
29
29
  "CRED-ACCESS": { credential: true },
30
30
  "ENV-ACCESS": { credential: true },
31
31
  "ENV-BULK": { credential: true },
32
- "ENV-EXFIL": { credential: true, remote: true },
32
+ // ENV-EXFIL is severe since v0.1.4 (P-018): it no longer fires on
33
+ // co-occurrence of a credential shaped name and a URL, only when the
34
+ // credential value demonstrably flows into an outbound send, so on its
35
+ // own it is strong evidence. Token as authentication (an Authorization
36
+ // header, the userinfo of a push URL, a --token flag) never counts.
37
+ "ENV-EXFIL": { credential: true, remote: true, severe: true },
38
+ // CRED-EXFIL: credential store contents piped straight into a network
39
+ // tool. Severe for the same reason: the value flow is in the text.
40
+ "CRED-EXFIL": { credential: true, remote: true, severe: true },
33
41
  };
34
42
  function ruleMeta(id) {
35
43
  const r = RISKY_RULES.find((x) => x.id === id);
@@ -54,6 +62,401 @@ export function stripComments(text) {
54
62
  })
55
63
  .join("\n");
56
64
  }
65
+ function langForFile(name) {
66
+ if (/\.py$/i.test(name))
67
+ return "python";
68
+ if (/\.(?:mjs|cjs|js|ts|tsx|jsx)$/i.test(name))
69
+ return "js";
70
+ return "shell";
71
+ }
72
+ function inRanges(idx, ranges) {
73
+ return ranges.some((r) => idx >= r.start && idx < r.end);
74
+ }
75
+ function blankRanges(text, ranges) {
76
+ if (ranges.length === 0)
77
+ return text;
78
+ const chars = text.split("");
79
+ for (const r of ranges) {
80
+ for (let i = r.start; i < r.end && i < chars.length; i++) {
81
+ if (chars[i] !== "\n")
82
+ chars[i] = " ";
83
+ }
84
+ }
85
+ return chars.join("");
86
+ }
87
+ /** Content ranges of a literal with its interpolation holes left visible. */
88
+ function contentRanges(lit) {
89
+ const out = [];
90
+ let cur = lit.contentStart;
91
+ for (const h of lit.holes) {
92
+ if (h.start > cur)
93
+ out.push({ start: cur, end: h.start });
94
+ cur = Math.max(cur, h.end);
95
+ }
96
+ if (lit.contentEnd > cur)
97
+ out.push({ start: cur, end: lit.contentEnd });
98
+ return out;
99
+ }
100
+ function scanJsLiterals(text) {
101
+ const spans = [];
102
+ const n = text.length;
103
+ let i = 0;
104
+ let prevSig = "";
105
+ let prevWord = "";
106
+ const regexAfterChar = new Set("=({[,;:!&|?+-*%~^<>".split(""));
107
+ const regexAfterWord = new Set(["return", "typeof", "case", "in", "of", "new", "delete", "void", "instanceof", "yield", "await", "throw", "else", "do"]);
108
+ while (i < n) {
109
+ const ch = text[i];
110
+ if (ch === "/" && text[i + 1] === "/") {
111
+ const e = text.indexOf("\n", i);
112
+ i = e === -1 ? n : e;
113
+ continue;
114
+ }
115
+ if (ch === "/" && text[i + 1] === "*") {
116
+ const e = text.indexOf("*/", i + 2);
117
+ i = e === -1 ? n : e + 2;
118
+ continue;
119
+ }
120
+ if (ch === "'" || ch === '"') {
121
+ let j = i + 1;
122
+ while (j < n) {
123
+ if (text[j] === "\\") {
124
+ j += 2;
125
+ continue;
126
+ }
127
+ if (text[j] === ch || text[j] === "\n")
128
+ break;
129
+ j++;
130
+ }
131
+ spans.push({ start: i, end: Math.min(j + 1, n), contentStart: i + 1, contentEnd: j, holes: [] });
132
+ i = Math.min(j + 1, n);
133
+ prevSig = ch;
134
+ prevWord = "";
135
+ continue;
136
+ }
137
+ if (ch === "`") {
138
+ let j = i + 1;
139
+ const holes = [];
140
+ while (j < n) {
141
+ if (text[j] === "\\") {
142
+ j += 2;
143
+ continue;
144
+ }
145
+ if (text[j] === "`")
146
+ break;
147
+ if (text[j] === "$" && text[j + 1] === "{") {
148
+ const holeStart = j;
149
+ let depth = 1;
150
+ j += 2;
151
+ while (j < n && depth > 0) {
152
+ if (text[j] === "{")
153
+ depth++;
154
+ else if (text[j] === "}")
155
+ depth--;
156
+ j++;
157
+ }
158
+ holes.push({ start: holeStart, end: j });
159
+ continue;
160
+ }
161
+ j++;
162
+ }
163
+ spans.push({ start: i, end: Math.min(j + 1, n), contentStart: i + 1, contentEnd: j, holes });
164
+ i = Math.min(j + 1, n);
165
+ prevSig = "`";
166
+ prevWord = "";
167
+ continue;
168
+ }
169
+ if (ch === "/" && (prevSig === "" || regexAfterChar.has(prevSig) || regexAfterWord.has(prevWord))) {
170
+ let j = i + 1;
171
+ let inClass = false;
172
+ while (j < n) {
173
+ if (text[j] === "\\") {
174
+ j += 2;
175
+ continue;
176
+ }
177
+ if (text[j] === "[")
178
+ inClass = true;
179
+ else if (text[j] === "]")
180
+ inClass = false;
181
+ else if (text[j] === "/" && !inClass)
182
+ break;
183
+ else if (text[j] === "\n")
184
+ break;
185
+ j++;
186
+ }
187
+ let end = Math.min(j + 1, n);
188
+ while (end < n && /[a-z]/i.test(text[end]))
189
+ end++;
190
+ spans.push({ start: i, end, contentStart: i + 1, contentEnd: j, holes: [] });
191
+ i = end;
192
+ prevSig = "/";
193
+ prevWord = "";
194
+ continue;
195
+ }
196
+ if (/[A-Za-z0-9_$]/.test(ch)) {
197
+ prevWord += ch;
198
+ prevSig = ch;
199
+ i++;
200
+ continue;
201
+ }
202
+ if (/\s/.test(ch)) {
203
+ i++;
204
+ continue;
205
+ }
206
+ prevSig = ch;
207
+ prevWord = "";
208
+ i++;
209
+ }
210
+ return spans;
211
+ }
212
+ function scanPyLiterals(text) {
213
+ const spans = [];
214
+ const n = text.length;
215
+ let i = 0;
216
+ while (i < n) {
217
+ const ch = text[i];
218
+ if (ch === "#") {
219
+ const e = text.indexOf("\n", i);
220
+ i = e === -1 ? n : e;
221
+ continue;
222
+ }
223
+ if (ch === "'" || ch === '"') {
224
+ // String prefix letters (r, b, f, u) sit immediately before the quote.
225
+ let p = i - 1;
226
+ let prefix = "";
227
+ while (p >= 0 && /[rRbBfFuU]/.test(text[p])) {
228
+ prefix = text[p] + prefix;
229
+ p--;
230
+ }
231
+ const isF = /f/i.test(prefix);
232
+ const triple = text.startsWith(ch + ch + ch, i);
233
+ const qLen = triple ? 3 : 1;
234
+ let j = i + qLen;
235
+ const holes = [];
236
+ while (j < n) {
237
+ if (text[j] === "\\") {
238
+ j += 2;
239
+ continue;
240
+ }
241
+ if (triple ? text.startsWith(ch + ch + ch, j) : text[j] === ch || text[j] === "\n")
242
+ break;
243
+ if (isF && text[j] === "{") {
244
+ if (text[j + 1] === "{") {
245
+ j += 2;
246
+ continue;
247
+ }
248
+ const holeStart = j;
249
+ let depth = 1;
250
+ j++;
251
+ while (j < n && depth > 0) {
252
+ if (text[j] === "{")
253
+ depth++;
254
+ else if (text[j] === "}")
255
+ depth--;
256
+ j++;
257
+ }
258
+ holes.push({ start: holeStart, end: j });
259
+ continue;
260
+ }
261
+ if (isF && text[j] === "}" && text[j + 1] === "}") {
262
+ j += 2;
263
+ continue;
264
+ }
265
+ j++;
266
+ }
267
+ spans.push({ start: i, end: Math.min(j + qLen, n), contentStart: i + qLen, contentEnd: j, holes });
268
+ i = Math.min(j + qLen, n);
269
+ continue;
270
+ }
271
+ i++;
272
+ }
273
+ return spans;
274
+ }
275
+ const EXEC_CALLS = ["eval", "exec", "execSync", "execFile", "execFileSync", "spawn", "spawnSync", "system", "popen", "Popen", "Function", "runInThisContext", "runInNewContext", "runInContext", "setTimeout", "setInterval", "compile"];
276
+ const PATH_CALLS = ["readFileSync", "readFile", "createReadStream", "createWriteStream", "open", "join", "resolve", "Path", "expanduser", "homedir"];
277
+ /**
278
+ * First argument spans of calls to the named functions. The matching
279
+ * close paren is found with a scan that jumps over literal spans, so
280
+ * parens inside strings do not unbalance it.
281
+ */
282
+ function callFirstArgSpans(text, names, literals) {
283
+ const spans = [];
284
+ const litAt = new Map();
285
+ for (const l of literals)
286
+ litAt.set(l.start, l);
287
+ const re = new RegExp(`\\b(?:${names.join("|")})\\s*\\(`, "g");
288
+ let m;
289
+ while ((m = re.exec(text)) !== null) {
290
+ const open = m.index + m[0].length - 1;
291
+ let depth = 1;
292
+ let j = open + 1;
293
+ let firstArgEnd = -1;
294
+ while (j < text.length && depth > 0) {
295
+ const lit = litAt.get(j);
296
+ if (lit) {
297
+ j = lit.end;
298
+ continue;
299
+ }
300
+ const c = text[j];
301
+ if (c === "(")
302
+ depth++;
303
+ else if (c === ")") {
304
+ depth--;
305
+ if (depth === 0)
306
+ break;
307
+ }
308
+ else if (c === "," && depth === 1 && firstArgEnd === -1)
309
+ firstArgEnd = j;
310
+ j++;
311
+ }
312
+ spans.push({ start: open + 1, end: firstArgEnd === -1 ? j : firstArgEnd });
313
+ }
314
+ return spans;
315
+ }
316
+ /**
317
+ * Split shell text into segments on the given separators, tracking
318
+ * single and double quotes so separators inside quoted strings do not
319
+ * split. Returns segment ranges in the original text.
320
+ */
321
+ function splitShell(text, separators) {
322
+ const out = [];
323
+ let start = 0;
324
+ let i = 0;
325
+ let quote = "";
326
+ while (i < text.length) {
327
+ const c = text[i];
328
+ if (quote) {
329
+ if (c === "\\" && quote === '"') {
330
+ i += 2;
331
+ continue;
332
+ }
333
+ if (c === quote)
334
+ quote = "";
335
+ i++;
336
+ continue;
337
+ }
338
+ if (c === "'" || c === '"') {
339
+ quote = c;
340
+ i++;
341
+ continue;
342
+ }
343
+ const sep = separators.find((s) => text.startsWith(s, i));
344
+ if (sep) {
345
+ out.push({ start, end: i });
346
+ i += sep.length;
347
+ start = i;
348
+ continue;
349
+ }
350
+ i++;
351
+ }
352
+ out.push({ start, end: text.length });
353
+ return out;
354
+ }
355
+ function firstShellWord(segment) {
356
+ let s = segment.trim();
357
+ // Skip leading env assignments and sudo.
358
+ for (;;) {
359
+ const assign = s.match(/^[A-Za-z_][A-Za-z0-9_]*=\S+\s+/);
360
+ if (assign) {
361
+ s = s.slice(assign[0].length);
362
+ continue;
363
+ }
364
+ if (/^sudo\s+/.test(s)) {
365
+ s = s.replace(/^sudo\s+/, "");
366
+ continue;
367
+ }
368
+ break;
369
+ }
370
+ const w = s.match(/^[A-Za-z_][A-Za-z0-9_.-]*/);
371
+ return w ? w[0] : "";
372
+ }
373
+ /**
374
+ * Content ranges of echo and printf string arguments in shell text:
375
+ * output data, blanked in the code view, unless the pipeline ends in a
376
+ * bare interpreter (or a bare sh or bash), where the printed text is fed
377
+ * to the interpreter as its program.
378
+ */
379
+ function shellEchoDataRanges(text) {
380
+ const ranges = [];
381
+ for (const stmt of splitShell(text, ["\n", ";", "&&", "||"])) {
382
+ const stmtText = text.slice(stmt.start, stmt.end);
383
+ if (!stmtText.trim())
384
+ continue;
385
+ const segments = splitShell(stmtText, ["|"]);
386
+ const lastText = stmtText.slice(segments[segments.length - 1].start, segments[segments.length - 1].end);
387
+ const lastWord = firstShellWord(lastText);
388
+ let execSink = ["sh", "bash", "zsh", "dash", "eval", "source"].includes(lastWord);
389
+ if (!execSink && ["python", "python3", "node", "perl", "ruby"].includes(lastWord)) {
390
+ const rest = lastText.trim().replace(/^[A-Za-z_][A-Za-z0-9_.-]*/, "").trim();
391
+ // Bare interpreter (optionally a lone "-") reads its program from stdin.
392
+ execSink = rest === "" || rest === "-";
393
+ }
394
+ if (execSink)
395
+ continue;
396
+ for (const seg of segments) {
397
+ const segText = stmtText.slice(seg.start, seg.end);
398
+ const word = firstShellWord(segText);
399
+ if (word !== "echo" && word !== "printf")
400
+ continue;
401
+ // Every quoted literal in an echo segment is printed data.
402
+ let j = 0;
403
+ while (j < segText.length) {
404
+ const c = segText[j];
405
+ if (c === "'" || c === '"') {
406
+ let k = j + 1;
407
+ while (k < segText.length) {
408
+ if (segText[k] === "\\" && c === '"') {
409
+ k += 2;
410
+ continue;
411
+ }
412
+ if (segText[k] === c)
413
+ break;
414
+ k++;
415
+ }
416
+ ranges.push({ start: stmt.start + seg.start + j + 1, end: stmt.start + seg.start + k });
417
+ j = k + 1;
418
+ continue;
419
+ }
420
+ j++;
421
+ }
422
+ }
423
+ }
424
+ return ranges;
425
+ }
426
+ function buildViews(text, lang) {
427
+ if (lang === "shell") {
428
+ const dataRanges = shellEchoDataRanges(text);
429
+ return { codeText: blankRanges(text, dataRanges), credText: text, dataRanges };
430
+ }
431
+ const literals = lang === "python" ? scanPyLiterals(text) : scanJsLiterals(text);
432
+ const execSpans = callFirstArgSpans(text, EXEC_CALLS, literals);
433
+ const pathSpans = callFirstArgSpans(text, PATH_CALLS, literals);
434
+ const role = (lit) => {
435
+ if (execSpans.some((s) => lit.contentStart >= s.start && lit.contentEnd <= s.end))
436
+ return "code";
437
+ if (pathSpans.some((s) => lit.contentStart >= s.start && lit.contentEnd <= s.end))
438
+ return "path";
439
+ return "data";
440
+ };
441
+ const codeBlank = [];
442
+ const credBlank = [];
443
+ const dataRanges = [];
444
+ for (const lit of literals) {
445
+ const r = role(lit);
446
+ const contents = contentRanges(lit);
447
+ if (r === "data") {
448
+ codeBlank.push(...contents);
449
+ credBlank.push(...contents);
450
+ dataRanges.push(...contents);
451
+ }
452
+ else if (r === "path") {
453
+ codeBlank.push(...contents);
454
+ }
455
+ // "code" literals keep their contents in both views: the text is a
456
+ // program handed to an interpreter, and behavior rules should see it.
457
+ }
458
+ return { codeText: blankRanges(text, codeBlank), credText: blankRanges(text, credBlank), dataRanges };
459
+ }
57
460
  const CRED_FILE_PATTERNS = [
58
461
  /~\/\.ssh(\/|\s|$|["'])/, /\$HOME\/\.ssh(\/|\s|$|["'])/, /(^|[\s"'(=])\.ssh\//,
59
462
  /\.aws\/credentials\b/, /\.aws\/config\b/,
@@ -83,10 +486,27 @@ const CRED_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY|ACCES
83
486
  * Only credential shaped names (token, secret, password, api key, and
84
487
  * friends, in any access syntax) and bulk access (whole object
85
488
  * serialization, printenv, bare env dumps, whole os.environ use) count.
489
+ *
490
+ * Round 2 (P-020): the rules must match reads, not any mention.
491
+ * os.environ.setdefault writes one named key with a default and
492
+ * os.environ.pop removes one; neither is bulk access, and the bare
493
+ * os.environ scan below excludes the accessor, subscript, and mutation
494
+ * forms explicitly. Passing the environment to a subprocess
495
+ * (env=os.environ) is inheritance, not a read. In shell text,
496
+ * `env -u NAME` unsets a variable for the subprocess, the opposite of a
497
+ * read, and a bare [ -n "$NAME" ] or [ -z "$NAME" ] test checks
498
+ * existence without reading the value into anything; matches inside
499
+ * those spans, and matches that start inside a quoted data literal
500
+ * (an echo string, a pattern list), are skipped.
86
501
  */
87
- function detectEnvAccess(cmd) {
502
+ function detectEnvAccess(cmd, dataRanges) {
88
503
  let cred = false;
89
504
  let bulk = false;
505
+ const skipSpans = [
506
+ ...Array.from(cmd.matchAll(/\benv(?:\s+-u\s+[A-Za-z_][A-Za-z0-9_]*)+/g), (m) => ({ start: m.index, end: m.index + m[0].length })),
507
+ ...Array.from(cmd.matchAll(/\[\[?\s+-[nz]\s+"?\$\{?[A-Za-z_][A-Za-z0-9_]*\}?"?\s*\]\]?/g), (m) => ({ start: m.index, end: m.index + m[0].length })),
508
+ ];
509
+ const skipped = (idx) => inRanges(idx, dataRanges) || inRanges(idx, skipSpans);
90
510
  const nameRes = [
91
511
  /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
92
512
  /process\.env\[["']([A-Za-z_][A-Za-z0-9_]*)["']\]/g,
@@ -95,27 +515,315 @@ function detectEnvAccess(cmd) {
95
515
  /getenv\(["']([A-Za-z_][A-Za-z0-9_]*)["']/g,
96
516
  /Deno\.env\.get\(["']([A-Za-z_][A-Za-z0-9_]*)["']/g,
97
517
  /import\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
98
- /\$\{?([A-Z][A-Z0-9_]{2,})\}?/g,
518
+ /\$(?:\{)?([A-Za-z_][A-Za-z0-9_]*)\}?/g,
99
519
  ];
100
520
  for (const re of nameRes) {
101
521
  let m;
102
522
  while ((m = re.exec(cmd)) !== null) {
523
+ if (m[0].startsWith("${#"))
524
+ continue; // ${#NAME} is a length, not a value
525
+ if (skipped(m.index))
526
+ continue;
103
527
  if (CRED_ENV_NAME.test(m[1]))
104
528
  cred = true;
105
529
  }
106
530
  }
107
- if (/(JSON\.stringify|Object\.(keys|values|entries|assign)|util\.inspect)\s*\([^)]*process\.env/.test(cmd))
531
+ if (/(JSON\.stringify|Object\.(keys|values|entries|assign)|util\.inspect)\s*\([^)]*(process\.env|os\.environ)/.test(cmd))
108
532
  bulk = true;
109
- if (/\bprintenv\b/.test(cmd))
533
+ if (/\bprintenv\b/.test(cmd) && !inRanges(cmd.search(/\bprintenv\b/), dataRanges))
110
534
  bulk = true;
111
- if (/(^|[;&|]\s*)env\s*($|[|>;])/.test(cmd))
535
+ if (/(^|[;&|]\s*)env\s*($|[|>;])/m.test(cmd))
112
536
  bulk = true;
113
537
  if (/\b(str|dict|list)\s*\(\s*os\.environ\s*\)/.test(cmd))
114
538
  bulk = true;
115
- if (/\bos\.environ\b(?!\s*(\.get\s*\(|\[))/.test(cmd))
539
+ // Whole os.environ use (P-020): scan every occurrence and judge the
540
+ // operation that follows it, instead of one negative lookahead.
541
+ const envRe = /\bos\.environ\b/g;
542
+ let em;
543
+ while ((em = envRe.exec(cmd)) !== null) {
544
+ if (skipped(em.index))
545
+ continue;
546
+ const after = cmd.slice(em.index + em[0].length, em.index + em[0].length + 30);
547
+ if (/^\s*\.get\s*\(/.test(after))
548
+ continue; // named read, judged above
549
+ if (/^\s*\[/.test(after))
550
+ continue; // named subscript read
551
+ if (/^\s*\.(?:setdefault|pop|update)\s*\(/.test(after))
552
+ continue; // write or removal, not a read
553
+ if (/^\s*\.copy\s*\(/.test(after)) {
554
+ bulk = true;
555
+ continue;
556
+ } // whole copy is a bulk read
557
+ const before = cmd.slice(Math.max(0, em.index - 24), em.index);
558
+ if (/env\s*=\s*$/.test(before))
559
+ continue; // subprocess env inheritance
116
560
  bulk = true;
561
+ }
117
562
  return { cred, bulk };
118
563
  }
564
+ /**
565
+ * Credential value atoms: text positions where a credential shaped env
566
+ * value, or the whole environment, is read. Atoms that start inside a
567
+ * quoted data literal are marked inData: a pattern list that MENTIONS
568
+ * process.env.SECRET is not reading it. Print disclosure is the one
569
+ * analysis that still uses inData atoms, because echoing a value prints
570
+ * it whether or not it was quoted.
571
+ */
572
+ function findAtoms(text, dataRanges) {
573
+ const atoms = [];
574
+ const named = [
575
+ { re: /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g },
576
+ { re: /process\.env\[["']([A-Za-z_][A-Za-z0-9_]*)["']\]/g },
577
+ { re: /os\.environ\.get\(["']([A-Za-z_][A-Za-z0-9_]*)["']/g },
578
+ { re: /os\.environ\[["']([A-Za-z_][A-Za-z0-9_]*)["']\]/g },
579
+ { re: /getenv\(["']([A-Za-z_][A-Za-z0-9_]*)["']/g },
580
+ { re: /Deno\.env\.get\(["']([A-Za-z_][A-Za-z0-9_]*)["']/g },
581
+ { re: /import\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)/g },
582
+ { re: /\$(?:\{)?([A-Za-z_][A-Za-z0-9_]*)\}?/g },
583
+ { re: /\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g, always: true },
584
+ { re: /\$\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g },
585
+ ];
586
+ for (const { re, always } of named) {
587
+ let m;
588
+ while ((m = re.exec(text)) !== null) {
589
+ if (m[0].startsWith("${#"))
590
+ continue;
591
+ if (!always && !CRED_ENV_NAME.test(m[1]))
592
+ continue;
593
+ atoms.push({ start: m.index, end: m.index + m[0].length, kind: "cred", inData: inRanges(m.index, dataRanges) });
594
+ }
595
+ }
596
+ const bulkRes = [
597
+ /(?:JSON\.stringify|util\.inspect|Object\.(?:keys|values|entries|assign))\s*\([^)]*(?:process\.env|os\.environ)/g,
598
+ /\bprintenv\b/g,
599
+ /\b(?:str|dict|list)\s*\(\s*os\.environ\s*\)/g,
600
+ /\bos\.environ\.copy\s*\(\s*\)/g,
601
+ /\bprocess\.env(?!\s*(?:\.|\[))/g,
602
+ /(^|[;&|]\s*)env\s*($|[|>;])/gm,
603
+ ];
604
+ for (const re of bulkRes) {
605
+ let m;
606
+ while ((m = re.exec(text)) !== null) {
607
+ const rel = m[0].search(/process\.env|os\.environ|\benv\b|printenv/);
608
+ const at = m.index + (rel === -1 ? 0 : rel);
609
+ atoms.push({ start: at, end: at + 3, kind: "bulk", inData: inRanges(at, dataRanges) });
610
+ }
611
+ }
612
+ const envRe = /\bos\.environ\b/g;
613
+ let em;
614
+ while ((em = envRe.exec(text)) !== null) {
615
+ const after = text.slice(em.index + em[0].length, em.index + em[0].length + 30);
616
+ if (/^\s*(?:\.get|\[|\.setdefault|\.pop|\.update)/.test(after))
617
+ continue;
618
+ const before = text.slice(Math.max(0, em.index - 24), em.index);
619
+ if (/env\s*=\s*$/.test(before))
620
+ continue;
621
+ atoms.push({ start: em.index, end: em.index + em[0].length, kind: "bulk", inData: inRanges(em.index, dataRanges) });
622
+ }
623
+ return atoms;
624
+ }
625
+ /**
626
+ * Spans where a credential is used as authentication for the destination
627
+ * it belongs to: the userinfo of a URL (x-access-token pushes and clones
628
+ * to the repo's own host), the value of an Authorization header, and the
629
+ * value of a CLI auth flag (--token, --auth, --api-key). A token in one
630
+ * of these positions authenticates the send; it is not the payload, and
631
+ * Round 2 showed it driving false exfil verdicts on langfuse, PostHog,
632
+ * and cas release automation (P-018).
633
+ */
634
+ function authRanges(text) {
635
+ const ranges = [];
636
+ for (const m of text.matchAll(/https?:\/\/[^\s"'`]+/g)) {
637
+ const url = m[0];
638
+ const schemeEnd = url.indexOf("://") + 3;
639
+ const authority = url.slice(schemeEnd).split("/")[0];
640
+ const at = authority.lastIndexOf("@");
641
+ if (at > 0)
642
+ ranges.push({ start: m.index + schemeEnd, end: m.index + schemeEnd + at });
643
+ }
644
+ const valueAfter = (from) => {
645
+ let end = from;
646
+ while (end < text.length && end - from < 200 && !/[\s"'`,;]/.test(text[end]))
647
+ end++;
648
+ return { start: from, end };
649
+ };
650
+ for (const m of text.matchAll(/authorization\s*["']?\s*[:=]\s*["']?\s*(?:bearer|basic|token)\s+/gi)) {
651
+ ranges.push(valueAfter(m.index + m[0].length));
652
+ }
653
+ for (const m of text.matchAll(/--(?:token|auth|api[-_]?key|password|secret)(?:\s*=\s*|\s+)["']?/gi)) {
654
+ ranges.push(valueAfter(m.index + m[0].length));
655
+ }
656
+ return ranges;
657
+ }
658
+ /** Variables assigned (transitively) from a credential value expression. */
659
+ function taintedVars(text, atoms) {
660
+ const tainted = new Set();
661
+ const assignRe = /(?:^|[;\n,])\s*(?:export\s+|const\s+|let\s+|var\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*=(?![=<>])\s*/g;
662
+ const assigns = [];
663
+ let m;
664
+ while ((m = assignRe.exec(text)) !== null) {
665
+ const rhsStart = m.index + m[0].length;
666
+ let rhsEnd = rhsStart;
667
+ while (rhsEnd < text.length && text[rhsEnd] !== ";" && text[rhsEnd] !== "\n")
668
+ rhsEnd++;
669
+ assigns.push({ name: m[1], rhsStart, rhsEnd });
670
+ }
671
+ const live = atoms.filter((a) => !a.inData);
672
+ for (let pass = 0; pass < 4; pass++) {
673
+ for (const a of assigns) {
674
+ if (tainted.has(a.name))
675
+ continue;
676
+ if (live.some((x) => x.start >= a.rhsStart && x.start < a.rhsEnd)) {
677
+ tainted.add(a.name);
678
+ continue;
679
+ }
680
+ const rhs = text.slice(a.rhsStart, a.rhsEnd);
681
+ for (const v of tainted) {
682
+ if (new RegExp(`\\b${v.replace(/\$/g, "\\$")}\\b`).test(rhs)) {
683
+ tainted.add(a.name);
684
+ break;
685
+ }
686
+ }
687
+ }
688
+ }
689
+ return tainted;
690
+ }
691
+ /** Balanced paren span starting at the "(" index, quote aware. */
692
+ function callSpan(text, openIdx) {
693
+ let depth = 0;
694
+ let quote = "";
695
+ for (let j = openIdx; j < text.length; j++) {
696
+ const c = text[j];
697
+ if (quote) {
698
+ if (c === "\\") {
699
+ j++;
700
+ continue;
701
+ }
702
+ if (c === quote)
703
+ quote = "";
704
+ continue;
705
+ }
706
+ if (c === "'" || c === '"' || c === "`") {
707
+ quote = c;
708
+ continue;
709
+ }
710
+ if (c === "(")
711
+ depth++;
712
+ else if (c === ")") {
713
+ depth--;
714
+ if (depth === 0)
715
+ return { start: openIdx, end: j + 1 };
716
+ }
717
+ }
718
+ return { start: openIdx, end: text.length };
719
+ }
720
+ function spanHasTaint(text, span, atoms, tainted) {
721
+ if (atoms.some((a) => !a.inData && a.start >= span.start && a.start < span.end))
722
+ return true;
723
+ if (tainted.size === 0)
724
+ return false;
725
+ const seg = text.slice(span.start, span.end);
726
+ for (const v of tainted) {
727
+ if (new RegExp(`\\b${v.replace(/\$/g, "\\$")}\\b`).test(seg))
728
+ return true;
729
+ }
730
+ return false;
731
+ }
732
+ /**
733
+ * Exfil value flow (Round 2, P-018). ENV-EXFIL used to fire on mere
734
+ * co-occurrence: a credential shaped name and any URL inside one finding.
735
+ * Since block scalars made a finding a whole 30 to 100 line step, and one
736
+ * hop following classifies whole referenced files, co-occurrence is the
737
+ * default state of any authenticated release step. Round 2 hand-read all
738
+ * 15 verdict drivers across langfuse, PostHog/posthog-js, attune-ai,
739
+ * cas, and flowspec: genuinely exfil shaped, zero. Every one was the
740
+ * repo's own automation using its token as authentication.
741
+ *
742
+ * ENV-EXFIL now requires the credential VALUE to flow into a send:
743
+ * - an atom or a tainted variable inside a fetch, axios, requests,
744
+ * http.get, urlopen, XHR send, or sendBeacon call span;
745
+ * - an atom or tainted variable anywhere in a curl or wget statement
746
+ * (payload, query string, or a non Authorization header), after
747
+ * authentication positions have been removed;
748
+ * - a pipeline whose earlier segments produce a credential value or a
749
+ * bulk dump and whose later segment is netcat, or curl or wget
750
+ * reading its body from stdin;
751
+ * - a credential value printed or logged while a send construct is
752
+ * present in the same text (CI logs are a real leak channel).
753
+ */
754
+ function analyzeExfil(text, dataRanges) {
755
+ const auth = authRanges(text);
756
+ const atoms = findAtoms(text, dataRanges).filter((a) => !inRanges(a.start, auth));
757
+ const tainted = taintedVars(text, atoms);
758
+ const has = (span) => spanHasTaint(text, span, atoms, tainted);
759
+ const callRes = [
760
+ /\bfetch\s*\(/g,
761
+ /\baxios(?:\s*\.\s*(?:post|put|patch|get|request|delete))?\s*\(/g,
762
+ /\brequests\s*\.\s*(?:get|post|put|patch|request)\s*\(/g,
763
+ /\bhttps?\s*\.\s*(?:request|get)\s*\(/g,
764
+ /\burlopen\s*\(/g,
765
+ /\.\s*send\s*\(/g,
766
+ /\bsendBeacon\s*\(/g,
767
+ ];
768
+ for (const re of callRes) {
769
+ let m;
770
+ while ((m = re.exec(text)) !== null) {
771
+ if (has(callSpan(text, m.index + m[0].length - 1)))
772
+ return true;
773
+ }
774
+ }
775
+ const statements = splitShell(text, ["\n", ";", "&&", "||"]);
776
+ for (const stmt of statements) {
777
+ if (!/\b(?:curl|wget)\b/.test(text.slice(stmt.start, stmt.end)))
778
+ continue;
779
+ if (has(stmt))
780
+ return true;
781
+ }
782
+ for (const stmt of statements) {
783
+ const stmtText = text.slice(stmt.start, stmt.end);
784
+ const segments = splitShell(stmtText, ["|"]);
785
+ if (segments.length < 2)
786
+ continue;
787
+ for (let k = 1; k < segments.length; k++) {
788
+ const segText = stmtText.slice(segments[k].start, segments[k].end);
789
+ const word = firstShellWord(segText);
790
+ const isNc = word === "nc" || word === "netcat";
791
+ const isStdinSend = (word === "curl" || word === "wget") && /(?:-d\s+@-|--data(?:-raw|-binary)?\s+@-|-T\s+-)/.test(segText);
792
+ if (!isNc && !isStdinSend)
793
+ continue;
794
+ for (let e = 0; e < k; e++) {
795
+ const early = { start: stmt.start + segments[e].start, end: stmt.start + segments[e].end };
796
+ if (has(early))
797
+ return true;
798
+ }
799
+ }
800
+ }
801
+ // Print disclosure alongside a send construct in the same text.
802
+ const hasSendConstruct = /\b(?:fetch|axios|requests\s*\.|urlopen|https?\s*\.\s*(?:request|get)|curl|wget|nc|netcat)\b/.test(text);
803
+ if (hasSendConstruct) {
804
+ for (const m of text.matchAll(/(?:^|[;\n|&]\s*)(?:echo|printf)\s+([^\n;|]*)/g)) {
805
+ const span = { start: m.index, end: m.index + m[0].length };
806
+ if (atoms.some((a) => a.start >= span.start && a.start < span.end))
807
+ return true;
808
+ const argText = m[1] ?? "";
809
+ for (const v of tainted) {
810
+ if (new RegExp(`\\b${v.replace(/\$/g, "\\$")}\\b`).test(argText))
811
+ return true;
812
+ }
813
+ }
814
+ for (const m of text.matchAll(/\b(?:console\.(?:log|info|debug)|print)\s*\(([^)]*)\)/g)) {
815
+ const span = { start: m.index, end: m.index + m[0].length };
816
+ if (atoms.some((a) => a.start >= span.start && a.start < span.end))
817
+ return true;
818
+ const argText = m[1] ?? "";
819
+ for (const v of tainted) {
820
+ if (new RegExp(`\\b${v.replace(/\$/g, "\\$")}\\b`).test(argText))
821
+ return true;
822
+ }
823
+ }
824
+ }
825
+ return false;
826
+ }
119
827
  function isLoopbackHost(host) {
120
828
  const h = host.toLowerCase();
121
829
  return h === "localhost" || h === "127.0.0.1" || h === "0.0.0.0" || h === "::1" || h === "[::1]";
@@ -129,15 +837,28 @@ const SAFE_PATTERNS = [
129
837
  /^patch-package$/i,
130
838
  /^simple-git-hooks$/i,
131
839
  ];
132
- export function classifyCommand(command) {
840
+ export function classifyCommand(command, lang = "shell") {
133
841
  // Strip comments first (a docs URL in a comment is not a fetch), then
134
842
  // normalize shell line continuations and stray carriage returns, so a
135
843
  // payload split as `curl ... \` + newline + `| sh` classifies the same as
136
- // the one line form.
137
- const cmd = stripComments(command).replace(/\\\r?\n/g, " ").replace(/\r/g, " ").replace(/\n/g, " ").trim();
844
+ // the one line form. GitHub ${{ }} expressions are whitespace collapsed
845
+ // so URLs containing them parse as one token. Newlines are kept: the
846
+ // exfil value flow analysis works statement by statement.
847
+ const cmd = stripComments(command)
848
+ .replace(/\\\r?\n/g, " ")
849
+ .replace(/\r/g, " ")
850
+ .replace(/\$\{\{([\s\S]*?)\}\}/g, (_m, inner) => "${{" + inner.replace(/\s+/g, "") + "}}")
851
+ .trim();
852
+ // Behavior rules match the code view (P-019): quoted pattern text in a
853
+ // script file is data, not behavior. REMOTE-URL is the exception: a URL
854
+ // is a reference wherever it appears, so it matches the full text.
855
+ const views = buildViews(cmd, lang);
856
+ const joined = cmd.replace(/\n/g, " ");
857
+ const codeJoined = views.codeText.replace(/\n/g, " ");
138
858
  const fired = [];
139
859
  for (const rule of RISKY_RULES) {
140
- if (rule.pattern.test(cmd))
860
+ const hay = rule.id === "REMOTE-URL" ? joined : codeJoined;
861
+ if (rule.pattern.test(hay))
141
862
  fired.push({ id: rule.id, evidence: rule.evidence });
142
863
  }
143
864
  // Loopback downgrade (P-004): a fetch whose every URL is loopback is a
@@ -150,20 +871,31 @@ export function classifyCommand(command) {
150
871
  fired.splice(i, 1);
151
872
  }
152
873
  }
153
- // Credential store access, content and path based (P-004).
154
- if (detectCredAccess(cmd)) {
874
+ // Credential store access, content and path based (P-004), matched on
875
+ // the credential view so paths quoted as data in a script file (a
876
+ // guard's blocklist) do not count, while paths handed to file access
877
+ // calls do.
878
+ const credAccess = detectCredAccess(views.credText);
879
+ if (credAccess) {
155
880
  fired.push({ id: "CRED-ACCESS", evidence: "touches a credential store path or credential file" });
156
881
  }
157
- // Env access, credential shaped names or bulk only (P-005).
158
- const env = detectEnvAccess(cmd);
882
+ // Env access, credential shaped names or bulk only (P-005, P-020).
883
+ const env = detectEnvAccess(cmd, views.dataRanges);
159
884
  if (env.cred) {
160
885
  fired.push({ id: "ENV-ACCESS", evidence: "reads an environment variable whose name is credential shaped (token, secret, password, api key)" });
161
886
  }
162
887
  if (env.bulk) {
163
888
  fired.push({ id: "ENV-BULK", evidence: "accesses the whole environment at once (serialization, printenv, or env dump)" });
164
889
  }
165
- if ((env.cred || env.bulk) && (fired.some((f) => f.id === "NET-FETCH" || f.id === "REMOTE-URL") || /\b(nc|netcat)\b/.test(cmd))) {
166
- fired.push({ id: "ENV-EXFIL", evidence: "combines credential shaped or bulk environment access with an outbound send pattern" });
890
+ // Exfil is a value flow, not a co-occurrence (P-018): the credential
891
+ // value must reach a send. Token as authentication does not count.
892
+ if (analyzeExfil(cmd, views.dataRanges)) {
893
+ fired.push({ id: "ENV-EXFIL", evidence: "a credential shaped or bulk environment value flows into an outbound send (payload, query string, printed output next to a send, or a pipe into a network tool); a token used as authentication does not count" });
894
+ }
895
+ if (credAccess && (/\|\s*(?:nc|netcat)\b/.test(joined) ||
896
+ /\|\s*(?:curl|wget)\b[^|\n]*?(?:-d\s+@-|--data(?:-raw|-binary)?\s+@-)/.test(joined) ||
897
+ /(?:-d|--data(?:-raw|-binary)?)\s+@[^\s"']*(?:\.ssh|\.aws|\.gnupg|id_rsa|credentials)/.test(joined))) {
898
+ fired.push({ id: "CRED-EXFIL", evidence: "credential store contents are piped or posted straight into an outbound network send" });
167
899
  }
168
900
  if (fired.length > 0) {
169
901
  return {
@@ -221,7 +953,7 @@ function escalateWithLocalFile(root, finding) {
221
953
  catch {
222
954
  continue;
223
955
  }
224
- const inner = classifyCommand(content);
956
+ const inner = classifyCommand(content, langForFile(token));
225
957
  if (inner.classification === "RISKY") {
226
958
  const rules = Array.from(new Set([...out.rules, ...inner.rules]));
227
959
  out = {
@@ -272,13 +1004,22 @@ function escalateWithScriptChain(root, scripts, currentName, finding) {
272
1004
  return out;
273
1005
  }
274
1006
  /**
275
- * Verdict calibration (Round 1, P-003 and P-004). COMPROMISED-PATTERN
276
- * requires strong evidence inside a single finding:
1007
+ * Verdict calibration (Round 1, P-003 and P-004; Round 2, P-018).
1008
+ * COMPROMISED-PATTERN requires strong evidence inside a single finding:
277
1009
  * (a) a severe rule: fetched or decoded content executed directly as
278
1010
  * code (curl piped to an interpreter, base64 decode piped to an
279
- * interpreter, eval of fetched content), or
280
- * (b) credential evidence (credential store access, a credential shaped
281
- * env name, or bulk env access) combined with network behavior.
1011
+ * interpreter, eval of fetched content), or a verified exfil flow
1012
+ * (ENV-EXFIL, CRED-EXFIL), or
1013
+ * (b) credential STORE access (CRED-ACCESS: actual credential files
1014
+ * and stores) combined with network behavior.
1015
+ * Round 2 narrowed path (b): in v0.1.2 and v0.1.3 any credential evidence
1016
+ * counted, including a credential shaped env NAME next to a URL. On real
1017
+ * estates that is the normal shape of a repo's own authenticated
1018
+ * automation (a release step reading its token and pushing to its own
1019
+ * repo), and it drove every one of the 15 false COMPROMISED drivers
1020
+ * hand-read in Round 2. Env references plus network behavior are RISKY
1021
+ * and verdict REVIEW; the verdict now needs the value to actually move
1022
+ * (path a) or an actual credential store to be touched (path b).
282
1023
  * Weak signals alone (a remote URL, a localhost fetch, an ordinary env
283
1024
  * read, a download by itself) classify RISKY at most, and a RISKY-only
284
1025
  * estate verdicts REVIEW, never COMPROMISED-PATTERN.
@@ -289,9 +1030,9 @@ export function computeVerdict(findings) {
289
1030
  if (f.classification === "RISKY") {
290
1031
  const metas = f.rules.map(ruleMeta);
291
1032
  const hasSevere = metas.some((x) => x.severe);
292
- const hasCred = metas.some((x) => x.credential);
1033
+ const hasCredStore = f.rules.includes("CRED-ACCESS");
293
1034
  const hasNet = metas.some((x) => x.remote);
294
- if (hasSevere || (hasCred && hasNet))
1035
+ if (hasSevere || (hasCredStore && hasNet))
295
1036
  return "COMPROMISED-PATTERN";
296
1037
  review = true;
297
1038
  }
@@ -723,6 +1464,13 @@ function scanWorkflows(root, findings) {
723
1464
  function scanGitHooks(root, findings) {
724
1465
  scanDirFiles(root, ".githooks", "githooks", findings);
725
1466
  }
1467
+ /**
1468
+ * Empty estate note (P-014, hookcheck half). A scan that inventories
1469
+ * nothing must say so next to the verdict, in JSON as well as in the
1470
+ * human output: CLEAN with total 0 is vacuously true and otherwise reads
1471
+ * as an all-clear.
1472
+ */
1473
+ export const EMPTY_ESTATE_NOTE = "Nothing was found to audit: no auto-exec hooks were inventoried under this path, so the verdict covers an empty estate. This can mean a hook-free repo, an empty directory, or a path that is not a repo root.";
726
1474
  export function scanHooks(root) {
727
1475
  const findings = [];
728
1476
  const warnings = [];
@@ -739,6 +1487,7 @@ export function scanHooks(root) {
739
1487
  path: root, verdict: "REVIEW", findings, warnings,
740
1488
  counts: { total: 0, safe: 0, risky: 0, unknown: 0 },
741
1489
  scannedAt: new Date().toISOString(),
1490
+ note: EMPTY_ESTATE_NOTE,
742
1491
  };
743
1492
  }
744
1493
  if (!rootStat.isDirectory()) {
@@ -747,6 +1496,7 @@ export function scanHooks(root) {
747
1496
  path: root, verdict: "REVIEW", findings, warnings,
748
1497
  counts: { total: 0, safe: 0, risky: 0, unknown: 0 },
749
1498
  scannedAt: new Date().toISOString(),
1499
+ note: EMPTY_ESTATE_NOTE,
750
1500
  };
751
1501
  }
752
1502
  scanPackageJson(root, findings, warnings);
@@ -782,6 +1532,7 @@ export function scanHooks(root) {
782
1532
  counts,
783
1533
  warnings,
784
1534
  scannedAt: new Date().toISOString(),
1535
+ ...(counts.total === 0 ? { note: EMPTY_ESTATE_NOTE } : {}),
785
1536
  };
786
1537
  }
787
1538
  export function explainHook(root, idOrFile) {
@@ -807,6 +1558,8 @@ export function renderReport(report) {
807
1558
  const lines = [];
808
1559
  lines.push(`hookcheck ${report.path}: ${report.verdict}`);
809
1560
  lines.push(` ${report.counts.total} hook(s): ${report.counts.safe} SAFE, ${report.counts.risky} RISKY, ${report.counts.unknown} UNKNOWN`);
1561
+ if (report.note)
1562
+ lines.push(` note: ${report.note}`);
810
1563
  for (const f of report.findings) {
811
1564
  lines.push(` [${f.classification}] ${f.id} (${f.source}${f.line !== undefined ? `, line ${f.line}` : ""})`);
812
1565
  lines.push(` command: ${f.command.slice(0, 200)}`);
@@ -821,7 +1574,7 @@ export function renderReport(report) {
821
1574
  else if (report.verdict === "REVIEW")
822
1575
  lines.push("REVIEW: at least one hook needs manual review.");
823
1576
  else
824
- lines.push("COMPROMISED-PATTERN: at least one risky hook shows strong evidence: fetched or decoded content executed as code, or credential access combined with network behavior. Treat as a Shai-Hulud style persistence pattern until proven otherwise.");
1577
+ lines.push("COMPROMISED-PATTERN: at least one risky hook shows strong evidence: fetched or decoded content executed as code, credential values flowing into an outbound send, or credential store access combined with network behavior. Treat as a Shai-Hulud style persistence pattern until proven otherwise.");
825
1578
  return lines.join("\n");
826
1579
  }
827
1580
  export { relative };