@reneturcios/hookcheck 0.1.3 → 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/README.md +6 -4
- package/dist/core.d.ts +48 -7
- package/dist/core.js +780 -27
- package/dist/core.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/package.json +41 -1
- package/server.json +2 -2
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.
|
|
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
|
-
|
|
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
|
-
|
|
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*($|[|>;])
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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).
|
|
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
|
-
*
|
|
281
|
-
*
|
|
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
|
|
1033
|
+
const hasCredStore = f.rules.includes("CRED-ACCESS");
|
|
293
1034
|
const hasNet = metas.some((x) => x.remote);
|
|
294
|
-
if (hasSevere || (
|
|
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 };
|