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