@ai-dossier/core 1.12.1 → 1.14.0
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/dry-run-langs.d.ts +37 -0
- package/dist/dry-run-langs.d.ts.map +1 -0
- package/dist/dry-run-langs.js +390 -0
- package/dist/dry-run-langs.js.map +1 -0
- package/dist/dry-run.d.ts +14 -0
- package/dist/dry-run.d.ts.map +1 -1
- package/dist/dry-run.js +1185 -143
- package/dist/dry-run.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/trusted-author.d.ts +21 -0
- package/dist/trusted-author.d.ts.map +1 -0
- package/dist/trusted-author.js +31 -0
- package/dist/trusted-author.js.map +1 -0
- package/package.json +1 -1
package/dist/dry-run.js
CHANGED
|
@@ -13,6 +13,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
13
13
|
exports.LEVEL_CRITICAL_MIN = exports.LEVEL_HIGH_MIN = exports.LEVEL_MEDIUM_MIN = exports.SCORE_COMMAND_CAP = exports.SCORE_PER_COMMAND = exports.SCORE_DESTRUCTIVE_DECLARATION_CAP = exports.SCORE_PER_DESTRUCTIVE_DECLARATION = exports.SCORE_RISK_FACTOR_CAP = exports.SCORE_PER_RISK_FACTOR = exports.SCORE_BASE = exports.DRY_RUN_DISCLAIMER = exports.DRY_RUN_SCHEMA_VERSION = void 0;
|
|
14
14
|
exports.levelForScore = levelForScore;
|
|
15
15
|
exports.analyzeDryRun = analyzeDryRun;
|
|
16
|
+
const dry_run_langs_1 = require("./dry-run-langs");
|
|
16
17
|
const parser_1 = require("./parser");
|
|
17
18
|
exports.DRY_RUN_SCHEMA_VERSION = 1;
|
|
18
19
|
exports.DRY_RUN_DISCLAIMER = "Static preview - derived from the dossier's declared metadata and code blocks; the executing agent may take other actions. The risk score is a heuristic, not a safety guarantee.";
|
|
@@ -51,7 +52,72 @@ function levelForScore(score) {
|
|
|
51
52
|
return 'low';
|
|
52
53
|
}
|
|
53
54
|
const SHELL_LANGS = new Set(['sh', 'bash', 'shell', 'zsh', 'console', 'shell-session', 'terminal']);
|
|
54
|
-
const
|
|
55
|
+
const CODE_LANGS = {
|
|
56
|
+
js: 'js',
|
|
57
|
+
javascript: 'js',
|
|
58
|
+
ts: 'js',
|
|
59
|
+
typescript: 'js',
|
|
60
|
+
mjs: 'js',
|
|
61
|
+
cjs: 'js',
|
|
62
|
+
node: 'js',
|
|
63
|
+
jsx: 'js',
|
|
64
|
+
tsx: 'js',
|
|
65
|
+
python: 'python',
|
|
66
|
+
python3: 'python',
|
|
67
|
+
py: 'python',
|
|
68
|
+
py3: 'python',
|
|
69
|
+
powershell: 'powershell',
|
|
70
|
+
pwsh: 'powershell',
|
|
71
|
+
ps1: 'powershell',
|
|
72
|
+
ps: 'powershell',
|
|
73
|
+
posh: 'powershell',
|
|
74
|
+
bat: 'powershell',
|
|
75
|
+
batch: 'powershell',
|
|
76
|
+
cmd: 'powershell',
|
|
77
|
+
dockerfile: 'dockerfile',
|
|
78
|
+
containerfile: 'dockerfile',
|
|
79
|
+
ruby: 'ruby',
|
|
80
|
+
rb: 'ruby',
|
|
81
|
+
perl: 'perl',
|
|
82
|
+
pl: 'perl',
|
|
83
|
+
sql: 'sql',
|
|
84
|
+
psql: 'sql',
|
|
85
|
+
mysql: 'sql',
|
|
86
|
+
pgsql: 'sql',
|
|
87
|
+
sqlite: 'sql',
|
|
88
|
+
yaml: 'yaml',
|
|
89
|
+
yml: 'yaml',
|
|
90
|
+
};
|
|
91
|
+
/** Fence languages that hold data / prose / diagrams, not something an agent executes. */
|
|
92
|
+
const INERT_LANGS = new Set([
|
|
93
|
+
'json',
|
|
94
|
+
'jsonc',
|
|
95
|
+
'json5',
|
|
96
|
+
'toml',
|
|
97
|
+
'ini',
|
|
98
|
+
'xml',
|
|
99
|
+
'html',
|
|
100
|
+
'css',
|
|
101
|
+
'md',
|
|
102
|
+
'markdown',
|
|
103
|
+
'text',
|
|
104
|
+
'txt',
|
|
105
|
+
'plaintext',
|
|
106
|
+
'diff',
|
|
107
|
+
'patch',
|
|
108
|
+
'mermaid',
|
|
109
|
+
'csv',
|
|
110
|
+
'http',
|
|
111
|
+
'env',
|
|
112
|
+
'dotenv',
|
|
113
|
+
'output',
|
|
114
|
+
'log',
|
|
115
|
+
'graphql',
|
|
116
|
+
'gql',
|
|
117
|
+
'svg',
|
|
118
|
+
'tree',
|
|
119
|
+
'math',
|
|
120
|
+
]);
|
|
55
121
|
function extractFences(body) {
|
|
56
122
|
const fences = [];
|
|
57
123
|
const lines = body.split('\n');
|
|
@@ -102,13 +168,271 @@ function endsInsideQuote(s) {
|
|
|
102
168
|
}
|
|
103
169
|
/** Longest quoted span joined into one logical line before giving up (stray apostrophes in prose). */
|
|
104
170
|
const MAX_QUOTED_LINES = 40;
|
|
171
|
+
/** Blank `((...))` / `$((...))` arithmetic so a `<<` shift inside it is not read as a heredoc. */
|
|
172
|
+
function blankArithmetic(s) {
|
|
173
|
+
const chars = s.split('');
|
|
174
|
+
let i = 0;
|
|
175
|
+
while (i < chars.length - 1) {
|
|
176
|
+
if (chars[i] === '(' && chars[i + 1] === '(') {
|
|
177
|
+
let depth = 0;
|
|
178
|
+
let j = i;
|
|
179
|
+
for (; j < chars.length; j++) {
|
|
180
|
+
if (chars[j] === '(')
|
|
181
|
+
depth++;
|
|
182
|
+
else if (chars[j] === ')' && --depth === 0)
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
if (j >= chars.length)
|
|
186
|
+
break;
|
|
187
|
+
for (let k = i; k <= j; k++)
|
|
188
|
+
chars[k] = ' ';
|
|
189
|
+
i = j + 1;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
i++;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return chars.join('');
|
|
196
|
+
}
|
|
197
|
+
const SQL_CLIENTS = new Set([
|
|
198
|
+
'psql',
|
|
199
|
+
'mysql',
|
|
200
|
+
'mariadb',
|
|
201
|
+
'sqlite3',
|
|
202
|
+
'mongosh',
|
|
203
|
+
'mongo',
|
|
204
|
+
'redis-cli',
|
|
205
|
+
]);
|
|
206
|
+
/** Tools that run a remote / contained command line, so `tool ... <<EOF` feeds that shell. */
|
|
207
|
+
const EXEC_HOSTS = new Set([
|
|
208
|
+
'docker',
|
|
209
|
+
'podman',
|
|
210
|
+
'kubectl',
|
|
211
|
+
'nsenter',
|
|
212
|
+
'chroot',
|
|
213
|
+
'lxc',
|
|
214
|
+
'incus',
|
|
215
|
+
'fly',
|
|
216
|
+
'flyctl',
|
|
217
|
+
'oc',
|
|
218
|
+
'su',
|
|
219
|
+
]);
|
|
220
|
+
function interpKind(exe) {
|
|
221
|
+
const e = exe.toLowerCase();
|
|
222
|
+
if (/^(?:sh|bash|zsh|dash|ksh|ash|fish|csh|tcsh|mksh|rbash)$/.test(e))
|
|
223
|
+
return 'shell';
|
|
224
|
+
if (/^python[\d.]*$/.test(e))
|
|
225
|
+
return 'python';
|
|
226
|
+
if (/^(?:node|nodejs|deno|bun|tsx|ts-node)$/.test(e))
|
|
227
|
+
return 'js';
|
|
228
|
+
if (/^ruby[\d.]*$/.test(e))
|
|
229
|
+
return 'ruby';
|
|
230
|
+
if (/^perl[\d.]*$/.test(e))
|
|
231
|
+
return 'perl';
|
|
232
|
+
if (/^(?:pwsh|powershell)(?:\.exe)?$/.test(e))
|
|
233
|
+
return 'powershell';
|
|
234
|
+
if (/^(?:php[\d.]*|lua[\d.]*|rscript|osascript|tclsh|expect|julia|groovy)$/.test(e))
|
|
235
|
+
return 'other';
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const CODE_FLAG = {
|
|
239
|
+
shell: /^-[A-Za-z]*c$/,
|
|
240
|
+
python: /^-c$/,
|
|
241
|
+
js: /^(?:-e|--eval|-p|--print)$/,
|
|
242
|
+
ruby: /^-[eE]$/,
|
|
243
|
+
perl: /^-[eE]$/,
|
|
244
|
+
powershell: /^-(?:c|command|e|enc|encodedcommand)$/i,
|
|
245
|
+
other: /^-r$/,
|
|
246
|
+
};
|
|
247
|
+
const v = (...f) => new Set(f);
|
|
248
|
+
const WRAPPERS = {
|
|
249
|
+
sudo: {
|
|
250
|
+
valued: v('-u', '-g', '-h', '-p', '-C', '-r', '-t', '-U', '-D', '-R', '--user', '--group', '--host', '--prompt', '--role', '--type', '--chdir', '--close-from'),
|
|
251
|
+
},
|
|
252
|
+
doas: { valued: v('-u', '-C') },
|
|
253
|
+
runuser: { valued: v('-u', '-g', '-l', '-c') },
|
|
254
|
+
env: { valued: v('-u', '-C', '--unset', '--chdir') },
|
|
255
|
+
nohup: { valued: v() },
|
|
256
|
+
time: { valued: v('-f', '-o', '--format', '--output') },
|
|
257
|
+
exec: { valued: v('-a') },
|
|
258
|
+
command: { valued: v(), noExec: v('-v', '-V') },
|
|
259
|
+
builtin: { valued: v() },
|
|
260
|
+
busybox: { valued: v() },
|
|
261
|
+
nice: { valued: v('-n', '--adjustment') },
|
|
262
|
+
ionice: { valued: v('-c', '-n', '-p', '-P', '-u') },
|
|
263
|
+
timeout: { valued: v('-s', '-k', '--signal', '--kill-after'), positional: 1 },
|
|
264
|
+
stdbuf: { valued: v('-i', '-o', '-e') },
|
|
265
|
+
setsid: { valued: v() },
|
|
266
|
+
fakeroot: { valued: v() },
|
|
267
|
+
proxychains: { valued: v('-f') },
|
|
268
|
+
proxychains4: { valued: v('-f') },
|
|
269
|
+
torsocks: { valued: v() },
|
|
270
|
+
unbuffer: { valued: v() },
|
|
271
|
+
chronic: { valued: v() },
|
|
272
|
+
unshare: { valued: v() },
|
|
273
|
+
chroot: { valued: v(), positional: 1 },
|
|
274
|
+
flock: { valued: v('-w', '-E', '-c'), positional: 1 },
|
|
275
|
+
watch: { valued: v('-n', '--interval') },
|
|
276
|
+
xargs: {
|
|
277
|
+
valued: v('-a', '-d', '-E', '-I', '-L', '-n', '-P', '-s', '--arg-file', '--delimiter', '--eof', '--replace', '--max-lines', '--max-args', '--max-procs', '--max-chars'),
|
|
278
|
+
},
|
|
279
|
+
parallel: { valued: v('-j', '-n', '-N', '-a', '-S', '--jobs', '--max-args'), stopAt: ':::' },
|
|
280
|
+
};
|
|
281
|
+
/** Tokens of the command wrapped by `sudo` / `env` / `xargs` / ..., or null when tokens[0] is no wrapper. */
|
|
282
|
+
function unwrap(tokens) {
|
|
283
|
+
const exe = tokens[0]?.replace(/^.*\//, '');
|
|
284
|
+
const spec = exe ? WRAPPERS[exe] : undefined;
|
|
285
|
+
if (!spec)
|
|
286
|
+
return null;
|
|
287
|
+
let i = 1;
|
|
288
|
+
let positional = spec.positional ?? 0;
|
|
289
|
+
while (i < tokens.length) {
|
|
290
|
+
const t = tokens[i];
|
|
291
|
+
if (spec.noExec?.has(t))
|
|
292
|
+
return [];
|
|
293
|
+
if (t === '--') {
|
|
294
|
+
i++;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
if (t.length > 1 && t.startsWith('-')) {
|
|
298
|
+
const combined = /^-[A-Za-z]{2,}$/.test(t) && spec.valued.has(`-${t[t.length - 1]}`);
|
|
299
|
+
i += spec.valued.has(t) || combined ? 2 : 1;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (exe === 'env' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
|
|
303
|
+
i++;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (positional > 0) {
|
|
307
|
+
positional--;
|
|
308
|
+
i++;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
let inner = tokens.slice(i);
|
|
314
|
+
if (spec.stopAt) {
|
|
315
|
+
const stop = inner.findIndex((t) => t === spec.stopAt || t === '::::');
|
|
316
|
+
if (stop >= 0)
|
|
317
|
+
inner = inner.slice(0, stop);
|
|
318
|
+
}
|
|
319
|
+
return inner;
|
|
320
|
+
}
|
|
321
|
+
/** Unwrap wrapper chains (`sudo -E env nohup bash -`) down to the real command. */
|
|
322
|
+
function unwrapAll(tokens) {
|
|
323
|
+
let cur = tokens;
|
|
324
|
+
for (let n = 0; n < 8; n++) {
|
|
325
|
+
const inner = unwrap(cur);
|
|
326
|
+
if (!inner)
|
|
327
|
+
break;
|
|
328
|
+
cur = inner;
|
|
329
|
+
}
|
|
330
|
+
return cur;
|
|
331
|
+
}
|
|
332
|
+
/** The interpreter a command feeds from stdin (`bash`, `sudo -E bash -`, `python3 -`), or null. */
|
|
333
|
+
function stdinTargetOf(tokensIn) {
|
|
334
|
+
const tokens = unwrapAll(tokensIn);
|
|
335
|
+
if (tokens.length === 0)
|
|
336
|
+
return null;
|
|
337
|
+
const first = tokens[0].replace(/^.*\//, '');
|
|
338
|
+
if ((first === 'source' || first === '.') && tokens[1] === '/dev/stdin')
|
|
339
|
+
return 'shell';
|
|
340
|
+
const ik = interpKind(first);
|
|
341
|
+
if (!ik)
|
|
342
|
+
return null;
|
|
343
|
+
const target = ik === 'other' ? 'shell' : ik;
|
|
344
|
+
const args = [];
|
|
345
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
346
|
+
// Redirections (`< file`, `> out`, `2>&1`) are not arguments.
|
|
347
|
+
if (/^\d*[<>]/.test(tokens[i])) {
|
|
348
|
+
if (/^\d*[<>]+$/.test(tokens[i]))
|
|
349
|
+
i++;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
args.push(tokens[i]);
|
|
353
|
+
}
|
|
354
|
+
const codeIdx = args.findIndex((a) => CODE_FLAG[ik].test(a));
|
|
355
|
+
if (codeIdx >= 0) {
|
|
356
|
+
// `pwsh -Command -`, or `xargs sh -c` (the wrapper supplies the code): the program comes from stdin.
|
|
357
|
+
return args[codeIdx + 1] === undefined || args[codeIdx + 1] === '-' ? target : null;
|
|
358
|
+
}
|
|
359
|
+
if (args.some((a) => a === '-s' || a === '-' || a === '/dev/stdin' || a === '/proc/self/fd/0'))
|
|
360
|
+
return target;
|
|
361
|
+
return args.every((a) => a.startsWith('-')) ? target : null;
|
|
362
|
+
}
|
|
363
|
+
/** What a heredoc/pipe fed into this command line ends up executing, if anything. */
|
|
364
|
+
function execTargetOf(cmdText) {
|
|
365
|
+
const tokens = unwrapAll(stripAssignments(tokenize(cmdText)));
|
|
366
|
+
if (tokens.length === 0)
|
|
367
|
+
return null;
|
|
368
|
+
const exe = tokens[0].replace(/^.*\//, '');
|
|
369
|
+
const direct = stdinTargetOf(tokens);
|
|
370
|
+
if (direct)
|
|
371
|
+
return direct;
|
|
372
|
+
if (SQL_CLIENTS.has(exe))
|
|
373
|
+
return 'sql';
|
|
374
|
+
if (exe === 'ssh')
|
|
375
|
+
return 'shell';
|
|
376
|
+
if (/^(?:g?make|gmake)$/.test(exe) &&
|
|
377
|
+
tokens.some((t, i) => i > 0 && (t === '/dev/stdin' || t === '-') && /^-\w*f$|^--file$/.test(tokens[i - 1])))
|
|
378
|
+
return 'shell';
|
|
379
|
+
if (EXEC_HOSTS.has(exe)) {
|
|
380
|
+
for (const t of tokens.slice(1)) {
|
|
381
|
+
const ik = interpKind(t.replace(/^.*\//, ''));
|
|
382
|
+
if (ik && ik !== 'other')
|
|
383
|
+
return ik;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
function findHeredocs(text) {
|
|
389
|
+
const masked = maskQuotes(blankArithmetic(text));
|
|
390
|
+
const out = [];
|
|
391
|
+
for (let i = masked.indexOf('<<'); i !== -1; i = masked.indexOf('<<', i + 2)) {
|
|
392
|
+
if (masked[i + 2] === '<' || masked[i - 1] === '<')
|
|
393
|
+
continue; // here-string `<<<`
|
|
394
|
+
const m = /^-?\s*(?:'([A-Za-z_]\w*)'|"([A-Za-z_]\w*)"|\\?([A-Za-z_]\w*))/.exec(text.slice(i + 2, i + 202));
|
|
395
|
+
if (!m)
|
|
396
|
+
continue;
|
|
397
|
+
out.push({ id: (m[1] ?? m[2] ?? m[3]), index: i, end: i + 2 + m[0].length });
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
/** Decide whether the heredoc opened at `h` feeds an interpreter (owner command or a later pipe stage). */
|
|
402
|
+
function heredocExec(text, masked, h) {
|
|
403
|
+
let s = h.index;
|
|
404
|
+
while (s > 0 && !';|&(`'.includes(masked[s - 1]))
|
|
405
|
+
s--;
|
|
406
|
+
const owner = execTargetOf(text.slice(s, h.index));
|
|
407
|
+
if (owner)
|
|
408
|
+
return owner;
|
|
409
|
+
let e = h.end;
|
|
410
|
+
while (e < masked.length && !';&)'.includes(masked[e]))
|
|
411
|
+
e++;
|
|
412
|
+
const tailMasked = masked.slice(h.end, e);
|
|
413
|
+
const tail = text.slice(h.end, e);
|
|
414
|
+
let from = 0;
|
|
415
|
+
for (let p = tailMasked.indexOf('|'); p !== -1; p = tailMasked.indexOf('|', from)) {
|
|
416
|
+
let next = tailMasked.indexOf('|', p + 1);
|
|
417
|
+
if (next === p + 1)
|
|
418
|
+
next = tailMasked.indexOf('|', p + 2);
|
|
419
|
+
const seg = tail.slice(p + 1, next === -1 ? undefined : next).replace(/^[|&]+/, '');
|
|
420
|
+
from = p + 1;
|
|
421
|
+
const t = execTargetOf(seg);
|
|
422
|
+
if (t)
|
|
423
|
+
return t;
|
|
424
|
+
}
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
105
427
|
/** Join `\`-continued lines and multi-line quoted strings, keeping the number of the first physical line. */
|
|
106
428
|
function logicalLines(fence) {
|
|
107
429
|
const out = [];
|
|
108
430
|
let acc = '';
|
|
109
431
|
let accLine = 0;
|
|
110
432
|
let accCount = 0;
|
|
111
|
-
fence.lines
|
|
433
|
+
const lines = fence.lines;
|
|
434
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
435
|
+
const raw = lines[idx];
|
|
112
436
|
const lineNo = fence.startLine + idx;
|
|
113
437
|
if (!acc) {
|
|
114
438
|
accLine = lineNo;
|
|
@@ -116,19 +440,41 @@ function logicalLines(fence) {
|
|
|
116
440
|
}
|
|
117
441
|
accCount++;
|
|
118
442
|
if (/\\\s*$/.test(raw)) {
|
|
119
|
-
acc +=
|
|
120
|
-
|
|
443
|
+
acc += raw.replace(/\\\s*$/, '');
|
|
444
|
+
continue;
|
|
121
445
|
}
|
|
122
446
|
const joined = acc + raw;
|
|
123
447
|
if (endsInsideQuote(joined) && accCount < MAX_QUOTED_LINES) {
|
|
124
|
-
acc = `${joined}
|
|
125
|
-
|
|
448
|
+
acc = `${joined}\n`;
|
|
449
|
+
continue;
|
|
126
450
|
}
|
|
127
|
-
|
|
451
|
+
const text = joined.trim();
|
|
128
452
|
acc = '';
|
|
129
|
-
|
|
453
|
+
const heredocs = [];
|
|
454
|
+
if (text && !text.startsWith('#')) {
|
|
455
|
+
const found = findHeredocs(text);
|
|
456
|
+
const masked = found.length > 0 ? maskQuotes(blankArithmetic(text)) : '';
|
|
457
|
+
for (const h of found) {
|
|
458
|
+
// A heredoc already closed inside a joined multi-line quote needs no body.
|
|
459
|
+
if (new RegExp(`\\b${h.id}\\b`).test(text.slice(h.end)))
|
|
460
|
+
continue;
|
|
461
|
+
const exec = heredocs.length >= MAX_HEREDOCS_PER_LINE ? 'shell' : heredocExec(text, masked, h);
|
|
462
|
+
const body = [];
|
|
463
|
+
const bodyLine = fence.startLine + idx + 1;
|
|
464
|
+
let j = idx + 1;
|
|
465
|
+
for (; j < lines.length; j++) {
|
|
466
|
+
if (lines[j].trim() === h.id)
|
|
467
|
+
break;
|
|
468
|
+
body.push(lines[j]);
|
|
469
|
+
}
|
|
470
|
+
heredocs.push({ exec, body, bodyLine });
|
|
471
|
+
idx = Math.min(j, lines.length - 1);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
out.push({ text, line: accLine, heredocs });
|
|
475
|
+
}
|
|
130
476
|
if (acc.trim())
|
|
131
|
-
out.push({ text: acc.trim(), line: accLine });
|
|
477
|
+
out.push({ text: acc.trim(), line: accLine, heredocs: [] });
|
|
132
478
|
return out;
|
|
133
479
|
}
|
|
134
480
|
/** Replace quoted string contents with spaces so operators inside quotes are ignored. */
|
|
@@ -158,20 +504,101 @@ function maskQuotes(input) {
|
|
|
158
504
|
}
|
|
159
505
|
return out;
|
|
160
506
|
}
|
|
161
|
-
/** Split a shell line into simple commands on `&&`, `||`, `;`,
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
507
|
+
/** Split a shell line into simple commands on `&&`, `||`, `;`, `|`, `&`, `$(`, backticks, parens (quote-aware). */
|
|
508
|
+
/** Blank `[[ ... ]]` test expressions: `&&` / `||` / `<` inside them are operators, not command separators. */
|
|
509
|
+
function blankTests(s) {
|
|
510
|
+
let out = s;
|
|
511
|
+
let from = 0;
|
|
512
|
+
for (let n = 0; n < 200; n++) {
|
|
513
|
+
const open = out.indexOf('[[', from);
|
|
514
|
+
if (open < 0)
|
|
515
|
+
break;
|
|
516
|
+
const close = out.indexOf(']]', open + 2);
|
|
517
|
+
if (close < 0)
|
|
518
|
+
break;
|
|
519
|
+
out = out.slice(0, open + 2) + ' '.repeat(close - open - 2) + out.slice(close);
|
|
520
|
+
from = close + 2;
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
function splitSegments(line) {
|
|
525
|
+
const masked = blankTests(maskQuotes(line));
|
|
526
|
+
const out = [];
|
|
165
527
|
let last = 0;
|
|
166
|
-
|
|
528
|
+
let sep = '';
|
|
529
|
+
let group = 0;
|
|
530
|
+
const re = /&&|\|\||\|&|\||;|(?<![<>&\d])&(?![>&\d-])|\$\(|`|(?<!=)\(|\)/g;
|
|
531
|
+
const push = (end) => {
|
|
532
|
+
const text = line.slice(last, end).trim();
|
|
533
|
+
if (text)
|
|
534
|
+
out.push({ text, sep, group });
|
|
535
|
+
};
|
|
167
536
|
let m = re.exec(masked);
|
|
168
537
|
while (m !== null) {
|
|
169
|
-
|
|
538
|
+
push(m.index);
|
|
170
539
|
last = m.index + m[0].length;
|
|
540
|
+
sep = m[0];
|
|
541
|
+
if (sep !== '|' && sep !== '|&')
|
|
542
|
+
group++;
|
|
171
543
|
m = re.exec(masked);
|
|
172
544
|
}
|
|
173
|
-
|
|
174
|
-
return
|
|
545
|
+
push(line.length);
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
548
|
+
/** Index of the `)` matching the `(` at `open` (naive depth count), or text.length. */
|
|
549
|
+
function matchParen(text, open) {
|
|
550
|
+
let depth = 0;
|
|
551
|
+
for (let i = open; i < text.length; i++) {
|
|
552
|
+
if (text[i] === '(')
|
|
553
|
+
depth++;
|
|
554
|
+
else if (text[i] === ')' && --depth === 0)
|
|
555
|
+
return i;
|
|
556
|
+
}
|
|
557
|
+
return text.length;
|
|
558
|
+
}
|
|
559
|
+
/** `$(...)` and backtick command substitutions inside double quotes, which quote-masking hides. */
|
|
560
|
+
function dqSubstitutions(text) {
|
|
561
|
+
const out = [];
|
|
562
|
+
let quote = null;
|
|
563
|
+
let i = 0;
|
|
564
|
+
while (i < text.length && out.length < 50) {
|
|
565
|
+
const c = text[i];
|
|
566
|
+
if (quote === "'") {
|
|
567
|
+
if (c === "'")
|
|
568
|
+
quote = null;
|
|
569
|
+
i++;
|
|
570
|
+
}
|
|
571
|
+
else if (c === '\\') {
|
|
572
|
+
i += 2;
|
|
573
|
+
}
|
|
574
|
+
else if (quote === '"') {
|
|
575
|
+
if (c === '"') {
|
|
576
|
+
quote = null;
|
|
577
|
+
i++;
|
|
578
|
+
}
|
|
579
|
+
else if (c === '$' && text[i + 1] === '(' && text[i + 2] !== '(') {
|
|
580
|
+
const end = matchParen(text, i + 1);
|
|
581
|
+
out.push(text.slice(i + 2, end));
|
|
582
|
+
i = end + 1;
|
|
583
|
+
}
|
|
584
|
+
else if (c === '`') {
|
|
585
|
+
const end = text.indexOf('`', i + 1);
|
|
586
|
+
if (end < 0)
|
|
587
|
+
break;
|
|
588
|
+
out.push(text.slice(i + 1, end));
|
|
589
|
+
i = end + 1;
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
i++;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
if (c === '"' || c === "'")
|
|
597
|
+
quote = c;
|
|
598
|
+
i++;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return out;
|
|
175
602
|
}
|
|
176
603
|
function tokenize(cmd) {
|
|
177
604
|
const tokens = [];
|
|
@@ -211,6 +638,19 @@ const SHELL_KEYWORDS = new Set([
|
|
|
211
638
|
')',
|
|
212
639
|
'!',
|
|
213
640
|
]);
|
|
641
|
+
/** Keywords that introduce a command on the same segment (`then rm -rf ~`, `do rm $f`). */
|
|
642
|
+
const LEADING_KEYWORDS = new Set([
|
|
643
|
+
'then',
|
|
644
|
+
'do',
|
|
645
|
+
'else',
|
|
646
|
+
'elif',
|
|
647
|
+
'if',
|
|
648
|
+
'while',
|
|
649
|
+
'until',
|
|
650
|
+
'!',
|
|
651
|
+
'{',
|
|
652
|
+
'coproc',
|
|
653
|
+
]);
|
|
214
654
|
const READ_ONLY = new Set([
|
|
215
655
|
'ls',
|
|
216
656
|
'cat',
|
|
@@ -232,7 +672,6 @@ const READ_ONLY = new Set([
|
|
|
232
672
|
'tr',
|
|
233
673
|
'jq',
|
|
234
674
|
'yq',
|
|
235
|
-
'awk',
|
|
236
675
|
'diff',
|
|
237
676
|
'pwd',
|
|
238
677
|
'cd',
|
|
@@ -246,7 +685,6 @@ const READ_ONLY = new Set([
|
|
|
246
685
|
'realpath',
|
|
247
686
|
'stat',
|
|
248
687
|
'file',
|
|
249
|
-
'env',
|
|
250
688
|
'export',
|
|
251
689
|
'set',
|
|
252
690
|
'unset',
|
|
@@ -254,7 +692,6 @@ const READ_ONLY = new Set([
|
|
|
254
692
|
'true',
|
|
255
693
|
'false',
|
|
256
694
|
'sleep',
|
|
257
|
-
'xargs',
|
|
258
695
|
'tree',
|
|
259
696
|
'du',
|
|
260
697
|
'df',
|
|
@@ -263,8 +700,6 @@ const READ_ONLY = new Set([
|
|
|
263
700
|
'uname',
|
|
264
701
|
'id',
|
|
265
702
|
'nproc',
|
|
266
|
-
'source',
|
|
267
|
-
'.',
|
|
268
703
|
'local',
|
|
269
704
|
'declare',
|
|
270
705
|
'shift',
|
|
@@ -300,6 +735,22 @@ const REMOTE_TOOLS = new Set([
|
|
|
300
735
|
'dig',
|
|
301
736
|
'nslookup',
|
|
302
737
|
]);
|
|
738
|
+
/** Commands whose stdout is content fetched from elsewhere (a pipe from them into a shell is remote code execution). */
|
|
739
|
+
const FETCH_TOOLS = new Set([
|
|
740
|
+
'curl',
|
|
741
|
+
'wget',
|
|
742
|
+
'fetch',
|
|
743
|
+
'aria2c',
|
|
744
|
+
'nc',
|
|
745
|
+
'ncat',
|
|
746
|
+
'socat',
|
|
747
|
+
'ssh',
|
|
748
|
+
'lynx',
|
|
749
|
+
'w3m',
|
|
750
|
+
'http',
|
|
751
|
+
'https',
|
|
752
|
+
'xh',
|
|
753
|
+
]);
|
|
303
754
|
const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'npx', 'pnpx', 'pip', 'pip3', 'cargo']);
|
|
304
755
|
const LOCAL_WRITE_TOOLS = new Set([
|
|
305
756
|
'mkdir',
|
|
@@ -334,7 +785,25 @@ const LOCAL_WRITE_TOOLS = new Set([
|
|
|
334
785
|
'ai-dossier',
|
|
335
786
|
'dossier',
|
|
336
787
|
]);
|
|
337
|
-
|
|
788
|
+
/** Always-destructive executables (disk / system level). */
|
|
789
|
+
const DESTRUCTIVE_TOOLS = new Set([
|
|
790
|
+
'shred',
|
|
791
|
+
'wipefs',
|
|
792
|
+
'fdisk',
|
|
793
|
+
'sfdisk',
|
|
794
|
+
'parted',
|
|
795
|
+
'sgdisk',
|
|
796
|
+
'mkswap',
|
|
797
|
+
'shutdown',
|
|
798
|
+
'reboot',
|
|
799
|
+
'halt',
|
|
800
|
+
'poweroff',
|
|
801
|
+
'userdel',
|
|
802
|
+
'groupdel',
|
|
803
|
+
'killall',
|
|
804
|
+
]);
|
|
805
|
+
const DESTRUCTIVE_VERB = /^(?:delete|destroy|terminate|rm|rb|remove|drop|purge|prune|uninstall|deregister|revoke|unpublish|wipe|erase|truncate|flush)(?:-[a-z0-9-]+)?$/i;
|
|
806
|
+
const DB_DESTRUCTIVE = /\bdrop\s+(?:table|database|schema|user|index|collection)\b|\btruncate\b|\bdelete\s+from\b|dropDatabase|\.drop\s*\(|\bflush(?:all|db)\b|deleteMany\s*\(\s*\{\s*\}\s*\)/i;
|
|
338
807
|
const GH_READ_VERBS = new Set([
|
|
339
808
|
'view',
|
|
340
809
|
'list',
|
|
@@ -376,6 +845,16 @@ const GIT_READ = new Set([
|
|
|
376
845
|
'range-diff',
|
|
377
846
|
]);
|
|
378
847
|
const GIT_REMOTE_READ = new Set(['fetch', 'pull', 'clone', 'ls-remote']);
|
|
848
|
+
/** Options whose value is a shell command (`tar --to-command=...`, `git --upload-pack=...`). */
|
|
849
|
+
const CMD_VALUED_OPTION = /^--(?:to-command|checkpoint-action|use-compress-program|upload-pack|receive-pack|rsh|ssh-command|editor|pager|exec)=(.+)$/;
|
|
850
|
+
/** Tokens that plausibly start a command line run inside `ssh` / `docker exec` / `kubectl exec`. */
|
|
851
|
+
const EXEC_HINT = /^(?:rm|rmdir|shred|dd|mkfs\S*|sh|bash|zsh|dash|ash|python[\d.]*|node|perl|ruby|curl|wget|git|kill|pkill|chmod|chown|truncate|mv|find|sudo|env|xargs|eval|nohup|systemctl|service|apt|apt-get|yum|dnf|apk|npm|pnpm|yarn|docker|kubectl|psql|mysql|mongosh|redis-cli|crontab|iptables|tar|cp)$/;
|
|
852
|
+
function stripAssignments(tokens) {
|
|
853
|
+
let i = 0;
|
|
854
|
+
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))
|
|
855
|
+
i++;
|
|
856
|
+
return tokens.slice(i);
|
|
857
|
+
}
|
|
379
858
|
function stripEnvAssignments(tokens) {
|
|
380
859
|
let i = 0;
|
|
381
860
|
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))
|
|
@@ -391,40 +870,128 @@ function hostOf(url) {
|
|
|
391
870
|
function nonFlags(tokens) {
|
|
392
871
|
return tokens.filter((t) => !t.startsWith('-'));
|
|
393
872
|
}
|
|
873
|
+
const FETCH_SUBST = /(?:\$\(|`|<\()\s*(?:[\w./-]+\/)?(?:curl|wget|fetch|aria2c)\b|(?:\$\(|`)[^)`]*\b(?:base64\s+(?:-d|-D|--decode)|xxd\s+-r)\b/;
|
|
874
|
+
const REMOTE_EXEC_REASON = 'remote code execution: fetched content is executed';
|
|
394
875
|
/** Classify a single simple command (already split from pipelines). */
|
|
395
876
|
function classifyCommand(cmd, rawLine) {
|
|
396
|
-
|
|
877
|
+
let tokens = stripAssignments(tokenize(cmd));
|
|
878
|
+
while (tokens.length > 0 && LEADING_KEYWORDS.has(tokens[0]))
|
|
879
|
+
tokens = tokens.slice(1);
|
|
880
|
+
if (tokens[0] === 'function') {
|
|
881
|
+
const brace = tokens.indexOf('{');
|
|
882
|
+
tokens = brace >= 0 ? tokens.slice(brace + 1) : [];
|
|
883
|
+
}
|
|
397
884
|
if (tokens.length === 0)
|
|
398
885
|
return null;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (
|
|
886
|
+
if (/\s/.test(tokens[0]))
|
|
887
|
+
tokens = tokenize(tokens.join(' '));
|
|
888
|
+
if (tokens.length === 0)
|
|
889
|
+
return null;
|
|
890
|
+
// `\rm`, `r\m`: backslashes in the command name are only quoting.
|
|
891
|
+
tokens = [tokens[0].replace(/\\(.)/g, '$1'), ...tokens.slice(1)];
|
|
892
|
+
const exe0 = tokens[0].replace(/^.*\//, '');
|
|
893
|
+
if (SHELL_KEYWORDS.has(exe0) || exe0.startsWith('#'))
|
|
402
894
|
return null;
|
|
403
895
|
// Pure variable assignment (`X=$(...)`) has no executable of its own.
|
|
404
896
|
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))
|
|
405
897
|
return null;
|
|
898
|
+
// Classify what the wrapper (`sudo`, `env`, `xargs`, ...) really runs.
|
|
899
|
+
let inner = tokens;
|
|
900
|
+
for (let n = 0; n < 8; n++) {
|
|
901
|
+
const u = unwrap(inner);
|
|
902
|
+
if (!u)
|
|
903
|
+
break;
|
|
904
|
+
inner = u;
|
|
905
|
+
while (inner.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(inner[0]))
|
|
906
|
+
inner = inner.slice(1);
|
|
907
|
+
if (inner.length > 0 && /\s/.test(inner[0]))
|
|
908
|
+
inner = tokenize(inner.join(' '));
|
|
909
|
+
if (inner.length > 0)
|
|
910
|
+
inner = [inner[0].replace(/\\(.)/g, '$1'), ...inner.slice(1)];
|
|
911
|
+
}
|
|
912
|
+
if (inner.length === 0) {
|
|
913
|
+
return { kind: 'read', recognized: true, files: [], nested: [], inner, stdinTarget: null };
|
|
914
|
+
}
|
|
915
|
+
const exe = inner[0].replace(/^.*\//, '');
|
|
916
|
+
const args = inner.slice(1);
|
|
406
917
|
const files = [];
|
|
918
|
+
const nested = [];
|
|
407
919
|
let kind = 'read';
|
|
408
920
|
let recognized = true;
|
|
409
921
|
let network;
|
|
922
|
+
let reason;
|
|
923
|
+
let execFile;
|
|
924
|
+
let opaque;
|
|
925
|
+
const opq = (why) => {
|
|
926
|
+
opaque ??= why;
|
|
927
|
+
};
|
|
410
928
|
const bump = (k) => {
|
|
411
929
|
const order = ['read', 'local_write', 'remote', 'destructive'];
|
|
412
930
|
if (order.indexOf(k) > order.indexOf(kind))
|
|
413
931
|
kind = k;
|
|
414
932
|
};
|
|
415
|
-
|
|
933
|
+
const destructive = (why) => {
|
|
934
|
+
bump('destructive');
|
|
935
|
+
if (why && !reason)
|
|
936
|
+
reason = why;
|
|
937
|
+
};
|
|
938
|
+
// Output redirection (`> file`, `>> file`), ignoring quoted operators and /dev/null-style sinks.
|
|
416
939
|
const masked = maskQuotes(rawLine);
|
|
417
940
|
const redir = /(?:^|[^<>&\d-])\d?>>?\s*([^\s|&;<>()]+)/g;
|
|
418
941
|
let rm = redir.exec(masked);
|
|
419
942
|
while (rm !== null) {
|
|
420
943
|
// Recover the unmasked target from the original line.
|
|
421
944
|
const target = rawLine.slice(rm.index + rm[0].length - rm[1].length, rm.index + rm[0].length);
|
|
422
|
-
if (
|
|
945
|
+
if (/^\/dev\/(?:sd|hd|nvme|vd|xvd|disk|mmcblk|mapper)/.test(target)) {
|
|
946
|
+
files.push({ path: target, operation: 'write' });
|
|
947
|
+
destructive('overwrites a block device');
|
|
948
|
+
}
|
|
949
|
+
else if (!target.startsWith('/dev/') && !target.startsWith('&')) {
|
|
423
950
|
files.push({ path: target.replace(/^["']|["']$/g, ''), operation: 'write' });
|
|
424
951
|
bump('local_write');
|
|
425
952
|
}
|
|
426
953
|
rm = redir.exec(masked);
|
|
427
954
|
}
|
|
955
|
+
// Command word we cannot resolve statically: `$X -rf ~`, `${X}`, `$'\x72m'`, `{rm,-rf,~}`, `rm${IFS}-rf`.
|
|
956
|
+
if (/^\$|^\{[^}]*,/.test(inner[0]) || /\$\{?IFS\b/.test(cmd)) {
|
|
957
|
+
opq('command name comes from an expansion (variable, ANSI-C quote, brace expansion or $IFS)');
|
|
958
|
+
}
|
|
959
|
+
const inRedir = /(?:^|[^<>&\d])<\s*([^\s|&;<>()]+)/.exec(masked);
|
|
960
|
+
const inputFile = inRedir
|
|
961
|
+
? rawLine.slice(inRedir.index + inRedir[0].length - inRedir[1].length, inRedir.index + inRedir[0].length)
|
|
962
|
+
: undefined;
|
|
963
|
+
for (let ai = 0; ai < args.length; ai++) {
|
|
964
|
+
const a = args[ai];
|
|
965
|
+
const m = CMD_VALUED_OPTION.exec(a);
|
|
966
|
+
if (m)
|
|
967
|
+
nested.push({ text: m[1].replace(/^exec=/, ''), lang: 'shell' });
|
|
968
|
+
else if (/^--(?:to-command|checkpoint-action|use-compress-program|upload-pack|receive-pack|rsh|ssh-command|editor|pager|exec)$/.test(a) ||
|
|
969
|
+
(exe === 'rsync' && a === '-e')) {
|
|
970
|
+
if (args[ai + 1])
|
|
971
|
+
nested.push({ text: args[ai + 1].replace(/^exec=/, ''), lang: 'shell' });
|
|
972
|
+
}
|
|
973
|
+
else if (exe === 'ssh' || exe === 'scp' || exe === 'sftp') {
|
|
974
|
+
const om = /^-o(?:\s*(.*))?$/.exec(a);
|
|
975
|
+
const val = om ? om[1] || args[ai + 1] || '' : '';
|
|
976
|
+
const pm = /^(?:ProxyCommand|LocalCommand)\s*[= ]\s*(.+)$/i.exec(val);
|
|
977
|
+
if (pm)
|
|
978
|
+
nested.push({ text: pm[1], lang: 'shell' });
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
const nestedFrom = (text, lang = 'shell') => {
|
|
982
|
+
if (text.trim())
|
|
983
|
+
nested.push({ text, lang });
|
|
984
|
+
};
|
|
985
|
+
/** First token of an exec host's trailing command line (`ssh h "rm -rf /"`, `kubectl exec p -- rm -rf /`). */
|
|
986
|
+
const nestedAfterHost = () => {
|
|
987
|
+
const dd = args.indexOf('--');
|
|
988
|
+
let start = dd >= 0 ? dd + 1 : -1;
|
|
989
|
+
if (start < 0) {
|
|
990
|
+
start = args.findIndex((a, i) => i > 0 || dd < 0 ? EXEC_HINT.test(a.split(/\s+/)[0].replace(/^.*\//, '')) : false);
|
|
991
|
+
}
|
|
992
|
+
if (start >= 0)
|
|
993
|
+
nestedFrom(args.slice(start).join(' '));
|
|
994
|
+
};
|
|
428
995
|
switch (exe) {
|
|
429
996
|
case 'rm':
|
|
430
997
|
case 'rmdir':
|
|
@@ -432,13 +999,18 @@ function classifyCommand(cmd, rawLine) {
|
|
|
432
999
|
case 'shred':
|
|
433
1000
|
for (const p of nonFlags(args))
|
|
434
1001
|
files.push({ path: p, operation: 'delete' });
|
|
435
|
-
|
|
1002
|
+
destructive();
|
|
436
1003
|
break;
|
|
437
|
-
case 'mv':
|
|
438
|
-
|
|
1004
|
+
case 'mv': {
|
|
1005
|
+
const paths = nonFlags(args);
|
|
1006
|
+
for (const p of paths)
|
|
439
1007
|
files.push({ path: p, operation: 'move' });
|
|
440
|
-
|
|
1008
|
+
if (paths.length > 1 && paths[paths.length - 1] === '/dev/null')
|
|
1009
|
+
destructive();
|
|
1010
|
+
else
|
|
1011
|
+
bump('local_write');
|
|
441
1012
|
break;
|
|
1013
|
+
}
|
|
442
1014
|
case 'tee':
|
|
443
1015
|
for (const p of nonFlags(args))
|
|
444
1016
|
files.push({ path: p, operation: 'write' });
|
|
@@ -459,6 +1031,42 @@ function classifyCommand(cmd, rawLine) {
|
|
|
459
1031
|
files.push({ path: p, operation: 'write' });
|
|
460
1032
|
bump('local_write');
|
|
461
1033
|
break;
|
|
1034
|
+
case 'dd': {
|
|
1035
|
+
const of = args.find((a) => a.startsWith('of='));
|
|
1036
|
+
if (of) {
|
|
1037
|
+
files.push({ path: of.slice(3), operation: 'write' });
|
|
1038
|
+
if (of.slice(3).startsWith('/dev/'))
|
|
1039
|
+
destructive('overwrites a block device');
|
|
1040
|
+
else
|
|
1041
|
+
bump('local_write');
|
|
1042
|
+
}
|
|
1043
|
+
else
|
|
1044
|
+
bump('local_write');
|
|
1045
|
+
break;
|
|
1046
|
+
}
|
|
1047
|
+
case 'truncate':
|
|
1048
|
+
if (args.some((a) => a === '-s0' || a === '--size=0' || a === '0'))
|
|
1049
|
+
destructive();
|
|
1050
|
+
else
|
|
1051
|
+
bump('local_write');
|
|
1052
|
+
break;
|
|
1053
|
+
case 'chmod':
|
|
1054
|
+
case 'chown':
|
|
1055
|
+
case 'chgrp': {
|
|
1056
|
+
const recursive = args.some((a) => a === '--recursive' || /^-[A-Za-z]*R[A-Za-z]*$/.test(a));
|
|
1057
|
+
const targets = nonFlags(args).slice(1);
|
|
1058
|
+
if (recursive && targets.some((t) => /^(?:\/|~|\$HOME|\/\*|\*|\.|\.\.)$/.test(t)))
|
|
1059
|
+
destructive('recursive permission change on a broad path');
|
|
1060
|
+
else
|
|
1061
|
+
bump('local_write');
|
|
1062
|
+
break;
|
|
1063
|
+
}
|
|
1064
|
+
case 'crontab':
|
|
1065
|
+
if (args.includes('-r'))
|
|
1066
|
+
destructive();
|
|
1067
|
+
else
|
|
1068
|
+
bump('local_write');
|
|
1069
|
+
break;
|
|
462
1070
|
case 'sed':
|
|
463
1071
|
if (args.some((a) => /^-[a-zA-Z]*i/.test(a) || a === '--in-place')) {
|
|
464
1072
|
const paths = nonFlags(args);
|
|
@@ -466,19 +1074,110 @@ function classifyCommand(cmd, rawLine) {
|
|
|
466
1074
|
files.push({ path: paths[paths.length - 1], operation: 'write' });
|
|
467
1075
|
bump('local_write');
|
|
468
1076
|
}
|
|
1077
|
+
for (const a of args) {
|
|
1078
|
+
const em = /(?:^|;)\s*(?:\d+|\$)?\s*e\s+(\S.*)$/.exec(a.slice(0, 2000));
|
|
1079
|
+
if (em) {
|
|
1080
|
+
nestedFrom(em[1]);
|
|
1081
|
+
bump('local_write');
|
|
1082
|
+
}
|
|
1083
|
+
const m = /(?:^|;)\s*s(.)(?:(?!\1).){0,500}\1((?:(?!\1).){0,500})\1[gpiImM0-9]*e/.exec(a.slice(0, 2000));
|
|
1084
|
+
if (m) {
|
|
1085
|
+
nestedFrom(m[2]);
|
|
1086
|
+
bump('local_write');
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
break;
|
|
1090
|
+
case 'awk':
|
|
1091
|
+
case 'gawk':
|
|
1092
|
+
case 'mawk':
|
|
1093
|
+
case 'nawk': {
|
|
1094
|
+
const prog = args.join(' ').slice(0, 20000);
|
|
1095
|
+
let found = false;
|
|
1096
|
+
for (const m of prog.matchAll(/system\s*\(\s*"((?:[^"\\]|\\.)*)"/g)) {
|
|
1097
|
+
nestedFrom(m[1].replace(/\\(.)/g, '$1'));
|
|
1098
|
+
found = true;
|
|
1099
|
+
}
|
|
1100
|
+
for (const m of prog.matchAll(/\|\s*"((?:[^"\\]|\\.)*)"|"((?:[^"\\]|\\.)*)"\s*\|\s*getline/g)) {
|
|
1101
|
+
const target = (m[1] ?? m[2]).replace(/\\(.)/g, '$1');
|
|
1102
|
+
nestedFrom(target);
|
|
1103
|
+
if (interpKind(target.trim().split(/\s+/)[0]) === 'shell')
|
|
1104
|
+
opq('awk output is piped into a shell');
|
|
1105
|
+
found = true;
|
|
1106
|
+
}
|
|
1107
|
+
if (found || /\bsystem\s*\(|\|\s*getline|print[^;}]*\|\s*[^;}\s]/.test(prog))
|
|
1108
|
+
bump('local_write');
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
case 'eval': {
|
|
1112
|
+
const code = args.join(' ');
|
|
1113
|
+
nestedFrom(code);
|
|
1114
|
+
bump('local_write');
|
|
1115
|
+
if (/\$|`/.test(code))
|
|
1116
|
+
opq('eval of a string built from expansions');
|
|
1117
|
+
if (FETCH_SUBST.test(code))
|
|
1118
|
+
destructive(REMOTE_EXEC_REASON);
|
|
1119
|
+
break;
|
|
1120
|
+
}
|
|
1121
|
+
case 'alias':
|
|
1122
|
+
if (args.some((a) => a.includes('=')))
|
|
1123
|
+
opq('alias definition (later use is not tracked)');
|
|
1124
|
+
break;
|
|
1125
|
+
case 'source':
|
|
1126
|
+
case '.':
|
|
1127
|
+
bump('local_write');
|
|
1128
|
+
if (args[0])
|
|
1129
|
+
execFile = args[0];
|
|
1130
|
+
if (args[0] && /[$`<]/.test(args[0]))
|
|
1131
|
+
opq('sourced path is not a literal');
|
|
1132
|
+
break;
|
|
1133
|
+
case 'trap': {
|
|
1134
|
+
const action = nonFlags(args)[0];
|
|
1135
|
+
if (action && action !== '-')
|
|
1136
|
+
nestedFrom(action);
|
|
469
1137
|
break;
|
|
470
|
-
|
|
1138
|
+
}
|
|
1139
|
+
case 'su': {
|
|
1140
|
+
const ci = args.findIndex((a) => a === '-c' || a === '--command');
|
|
1141
|
+
if (ci >= 0 && args[ci + 1])
|
|
1142
|
+
nestedFrom(args[ci + 1]);
|
|
1143
|
+
bump('local_write');
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
case 'find': {
|
|
1147
|
+
const ei = args.findIndex((a) => a === '-exec' || a === '-execdir' || a === '-ok' || a === '-okdir');
|
|
471
1148
|
if (args.includes('-delete'))
|
|
472
|
-
|
|
473
|
-
|
|
1149
|
+
destructive();
|
|
1150
|
+
if (ei >= 0) {
|
|
1151
|
+
let end = args.findIndex((a, i) => i > ei && (a === ';' || a === ';' || a === '+'));
|
|
1152
|
+
if (end < 0)
|
|
1153
|
+
end = args.length;
|
|
1154
|
+
nestedFrom(args.slice(ei + 1, end).join(' '));
|
|
474
1155
|
bump('local_write');
|
|
1156
|
+
}
|
|
475
1157
|
break;
|
|
1158
|
+
}
|
|
476
1159
|
case 'git': {
|
|
477
|
-
|
|
478
|
-
|
|
1160
|
+
let a = args;
|
|
1161
|
+
while (a.length > 0 && a[0].startsWith('-')) {
|
|
1162
|
+
if (a[0] === '-c' && a[1]) {
|
|
1163
|
+
const m = /^(?:alias\.[\w-]+|core\.(?:sshCommand|pager|editor|fsmonitor))=(.+)$/.exec(a[1]);
|
|
1164
|
+
if (m)
|
|
1165
|
+
nestedFrom(m[1].replace(/^!/, ''));
|
|
1166
|
+
if (/^core\.hooksPath=/i.test(a[1]))
|
|
1167
|
+
opq('git hooksPath runs arbitrary hooks');
|
|
1168
|
+
}
|
|
1169
|
+
a = ['-C', '-c', '--git-dir', '--work-tree', '--namespace'].includes(a[0])
|
|
1170
|
+
? a.slice(2)
|
|
1171
|
+
: a.slice(1);
|
|
1172
|
+
}
|
|
1173
|
+
const sub = a[0] ?? '';
|
|
1174
|
+
const rest = a.slice(1);
|
|
479
1175
|
if (sub === 'push') {
|
|
480
|
-
const forced = rest.some((
|
|
481
|
-
|
|
1176
|
+
const forced = rest.some((x) => x === '--mirror' ||
|
|
1177
|
+
x.startsWith('--force') ||
|
|
1178
|
+
/^-[A-Za-z]*f[A-Za-z]*$/.test(x) ||
|
|
1179
|
+
/^\+\S/.test(x));
|
|
1180
|
+
const deleting = rest.includes('--delete') || rest.includes('-d') || rest.some((x) => /^:\S/.test(x));
|
|
482
1181
|
network = { tool: 'git', target: 'push', mutates: true };
|
|
483
1182
|
bump(forced || deleting ? 'destructive' : 'remote');
|
|
484
1183
|
}
|
|
@@ -488,20 +1187,40 @@ function classifyCommand(cmd, rawLine) {
|
|
|
488
1187
|
if (sub !== 'ls-remote')
|
|
489
1188
|
bump('local_write');
|
|
490
1189
|
}
|
|
1190
|
+
else if (sub === 'remote' && ['rm', 'remove', 'prune'].includes(rest[0])) {
|
|
1191
|
+
destructive();
|
|
1192
|
+
}
|
|
491
1193
|
else if (GIT_READ.has(sub)) {
|
|
492
1194
|
// read-only (git config/remote may write, but bare invocations here are inspection)
|
|
493
1195
|
}
|
|
494
1196
|
else if ((sub === 'reset' && rest.includes('--hard')) ||
|
|
495
1197
|
sub === 'clean' ||
|
|
496
|
-
|
|
497
|
-
|
|
1198
|
+
sub === 'rm' ||
|
|
1199
|
+
sub === 'filter-branch' ||
|
|
1200
|
+
sub === 'filter-repo' ||
|
|
1201
|
+
(sub === 'reflog' && (rest[0] === 'expire' || rest[0] === 'delete')) ||
|
|
1202
|
+
(sub === 'gc' && rest.some((x) => x.startsWith('--prune'))) ||
|
|
1203
|
+
(sub === 'update-ref' && rest.includes('-d')) ||
|
|
1204
|
+
(sub === 'tag' && rest.some((x) => x === '-d' || x === '--delete')) ||
|
|
1205
|
+
(sub === 'branch' && rest.some((x) => x === '-D' || x === '-d' || x === '--delete')) ||
|
|
1206
|
+
(sub === 'checkout' &&
|
|
1207
|
+
(rest.includes('-f') || rest.includes('--force') || rest.includes('.'))) ||
|
|
1208
|
+
(sub === 'restore' && (rest.includes('.') || rest.includes('--worktree'))) ||
|
|
1209
|
+
(sub === 'switch' &&
|
|
1210
|
+
rest.some((x) => x === '-f' || x === '--force' || x === '--discard-changes')) ||
|
|
1211
|
+
(sub === 'submodule' && rest[0] === 'deinit') ||
|
|
498
1212
|
(sub === 'worktree' && rest[0] === 'remove') ||
|
|
499
1213
|
(sub === 'stash' && (rest[0] === 'drop' || rest[0] === 'clear'))) {
|
|
500
|
-
|
|
1214
|
+
destructive();
|
|
501
1215
|
}
|
|
502
1216
|
else {
|
|
503
1217
|
bump('local_write');
|
|
504
1218
|
}
|
|
1219
|
+
if (sub === 'rebase') {
|
|
1220
|
+
const xi = rest.findIndex((x) => x === '--exec' || x === '-x');
|
|
1221
|
+
if (xi >= 0 && rest[xi + 1])
|
|
1222
|
+
nestedFrom(rest[xi + 1]);
|
|
1223
|
+
}
|
|
505
1224
|
break;
|
|
506
1225
|
}
|
|
507
1226
|
case 'gh': {
|
|
@@ -510,10 +1229,10 @@ function classifyCommand(cmd, rawLine) {
|
|
|
510
1229
|
const verb = words[1] ?? '';
|
|
511
1230
|
if (group === 'api') {
|
|
512
1231
|
const method = (() => {
|
|
513
|
-
const i = args.findIndex((
|
|
1232
|
+
const i = args.findIndex((x) => x === '-X' || x === '--method');
|
|
514
1233
|
return i >= 0 ? (args[i + 1] ?? '').toUpperCase() : '';
|
|
515
1234
|
})();
|
|
516
|
-
const hasBody = args.some((
|
|
1235
|
+
const hasBody = args.some((x) => x === '-f' || x === '-F' || x === '--field' || x === '--raw-field' || x === '--input');
|
|
517
1236
|
const mutates = (method !== '' && method !== 'GET') || (method === '' && hasBody);
|
|
518
1237
|
network = { tool: 'gh', target: `api ${words[1] ?? ''}`.trim(), mutates };
|
|
519
1238
|
bump(method === 'DELETE' ? 'destructive' : 'remote');
|
|
@@ -527,41 +1246,105 @@ function classifyCommand(cmd, rawLine) {
|
|
|
527
1246
|
}
|
|
528
1247
|
case 'curl':
|
|
529
1248
|
case 'wget': {
|
|
530
|
-
const url = args.find((
|
|
1249
|
+
const url = args.find((x) => /^[a-z][a-z0-9+.-]*:\/\//i.test(x)) ?? '';
|
|
531
1250
|
const method = (() => {
|
|
532
|
-
const i = args.findIndex((
|
|
1251
|
+
const i = args.findIndex((x) => x === '-X' || x === '--request');
|
|
533
1252
|
return i >= 0 ? (args[i + 1] ?? '').toUpperCase() : '';
|
|
534
1253
|
})();
|
|
535
|
-
const hasBody = args.some((
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
1254
|
+
const hasBody = args.some((x) => x === '-d' ||
|
|
1255
|
+
x === '--data' ||
|
|
1256
|
+
x === '--data-raw' ||
|
|
1257
|
+
x === '--json' ||
|
|
1258
|
+
x === '-F' ||
|
|
1259
|
+
x === '-T');
|
|
541
1260
|
const mutates = (method !== '' && method !== 'GET' && method !== 'HEAD') || hasBody;
|
|
542
1261
|
network = { tool: exe, target: hostOf(url) ?? (url || 'unknown host'), mutates };
|
|
543
1262
|
bump(method === 'DELETE' ? 'destructive' : 'remote');
|
|
544
|
-
const oi = args.findIndex((
|
|
545
|
-
if (oi >= 0 && args[oi + 1]) {
|
|
1263
|
+
const oi = args.findIndex((x) => exe === 'curl' ? x === '-o' || x === '--output' : x === '-O' || x === '--output-document');
|
|
1264
|
+
if (oi >= 0 && args[oi + 1] && args[oi + 1] !== '-') {
|
|
546
1265
|
files.push({ path: args[oi + 1], operation: 'write' });
|
|
547
1266
|
bump('remote');
|
|
548
1267
|
}
|
|
1268
|
+
else if (url &&
|
|
1269
|
+
(exe === 'wget'
|
|
1270
|
+
? oi < 0
|
|
1271
|
+
: args.some((x) => x === '-O' || x === '--remote-name' || /^-[A-Za-z]*O[A-Za-z]*$/.test(x)))) {
|
|
1272
|
+
// `wget URL` / `curl -O URL` save under the URL's last path segment.
|
|
1273
|
+
const name = url
|
|
1274
|
+
.replace(/[?#].*$/, '')
|
|
1275
|
+
.replace(/\/+$/, '')
|
|
1276
|
+
.replace(/^.*\//, '');
|
|
1277
|
+
if (name && !/^[a-z][a-z0-9+.-]*:$/i.test(name)) {
|
|
1278
|
+
files.push({ path: name, operation: 'write' });
|
|
1279
|
+
bump('remote');
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
549
1282
|
break;
|
|
550
1283
|
}
|
|
551
1284
|
default: {
|
|
552
|
-
|
|
1285
|
+
const ik = interpKind(exe);
|
|
1286
|
+
if (ik) {
|
|
1287
|
+
bump('local_write');
|
|
1288
|
+
const codeIdx = args.findIndex((x) => CODE_FLAG[ik].test(x));
|
|
1289
|
+
if (codeIdx >= 0 && args[codeIdx + 1] !== undefined) {
|
|
1290
|
+
if (ik === 'powershell') {
|
|
1291
|
+
if (/^-(?:e|enc|encodedcommand)$/i.test(args[codeIdx]))
|
|
1292
|
+
destructive('encoded PowerShell command (opaque payload)');
|
|
1293
|
+
else
|
|
1294
|
+
nestedFrom(args.slice(codeIdx + 1).join(' '), 'powershell');
|
|
1295
|
+
}
|
|
1296
|
+
else if (ik !== 'other') {
|
|
1297
|
+
const code = args[codeIdx + 1];
|
|
1298
|
+
nestedFrom(code, ik);
|
|
1299
|
+
if (FETCH_SUBST.test(code))
|
|
1300
|
+
destructive(REMOTE_EXEC_REASON);
|
|
1301
|
+
if (ik === 'shell' && /^\s*["']?\$\{?\w/.test(code))
|
|
1302
|
+
opq('script string comes from a variable');
|
|
1303
|
+
}
|
|
1304
|
+
else {
|
|
1305
|
+
opq(`${exe} script string is not analysed`);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
else {
|
|
1309
|
+
const script = args.find((x) => !x.startsWith('-') && x !== '-');
|
|
1310
|
+
if (script)
|
|
1311
|
+
execFile = script;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
else if (/^mkfs(?:\..+)?$/.test(exe) || DESTRUCTIVE_TOOLS.has(exe)) {
|
|
1315
|
+
destructive();
|
|
1316
|
+
}
|
|
1317
|
+
else if (REMOTE_TOOLS.has(exe)) {
|
|
553
1318
|
const words = nonFlags(args);
|
|
554
|
-
const
|
|
555
|
-
|
|
1319
|
+
const isDestructive = words.some((w) => w.split(':').some((part) => DESTRUCTIVE_VERB.test(part))) ||
|
|
1320
|
+
(exe === 'rsync' && args.some((x) => /^--(?:delete|remove-source-files)/.test(x))) ||
|
|
1321
|
+
(['psql', 'mysql', 'mongosh', 'mongo', 'redis-cli'].includes(exe) &&
|
|
1322
|
+
args.some((x) => DB_DESTRUCTIVE.test(x.slice(0, 5000)))) ||
|
|
1323
|
+
(exe === 'aws' && words[0] === 's3' && (words[1] === 'rm' || words[1] === 'rb'));
|
|
1324
|
+
const mutates = isDestructive ||
|
|
556
1325
|
words.some((w) => /^(apply|create|patch|scale|put|push|deploy|update|run|start|stop|cp|sync|replace|set|annotate|label|rollout|exec|copy|upgrade|install)$/.test(w));
|
|
557
1326
|
network = { tool: exe, target: words.slice(0, 2).join(' ') || exe, mutates };
|
|
558
|
-
bump(
|
|
1327
|
+
bump(isDestructive ? 'destructive' : 'remote');
|
|
1328
|
+
if (exe === 'ssh' ||
|
|
1329
|
+
(exe === 'kubectl' && words[0] === 'exec') ||
|
|
1330
|
+
exe === 'fly' ||
|
|
1331
|
+
exe === 'flyctl') {
|
|
1332
|
+
const quoted = args[args.length - 1];
|
|
1333
|
+
if (exe === 'ssh' && quoted && /\s/.test(quoted))
|
|
1334
|
+
nestedFrom(quoted);
|
|
1335
|
+
else
|
|
1336
|
+
nestedAfterHost();
|
|
1337
|
+
}
|
|
559
1338
|
}
|
|
560
1339
|
else if (PACKAGE_MANAGERS.has(exe)) {
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
1340
|
+
const words = nonFlags(args);
|
|
1341
|
+
const pub = ['run', 'run-script', 'exec', 'test'].includes(words[0] ?? '')
|
|
1342
|
+
? undefined
|
|
1343
|
+
: words.slice(0, 3).find((w) => w === 'publish' || w === 'unpublish');
|
|
1344
|
+
const sub = pub ?? words[0] ?? '';
|
|
1345
|
+
if (pub) {
|
|
1346
|
+
network = { tool: exe, target: sub, mutates: true };
|
|
1347
|
+
destructive();
|
|
565
1348
|
}
|
|
566
1349
|
else if (/^(install|i|add|ci|update|upgrade|dlx|exec)$/.test(sub) ||
|
|
567
1350
|
exe === 'npx' ||
|
|
@@ -570,31 +1353,43 @@ function classifyCommand(cmd, rawLine) {
|
|
|
570
1353
|
bump('remote');
|
|
571
1354
|
bump('local_write');
|
|
572
1355
|
}
|
|
573
|
-
else if (/^(run|test|build|lint|check|start|dev)$/.test(sub) || sub === '') {
|
|
574
|
-
bump('local_write');
|
|
575
|
-
}
|
|
576
1356
|
else {
|
|
577
1357
|
bump('local_write');
|
|
578
1358
|
}
|
|
579
1359
|
}
|
|
580
|
-
else if (exe === '
|
|
1360
|
+
else if ((exe === 'twine' && args.includes('upload')) ||
|
|
1361
|
+
(exe === 'gem' && args.includes('push')) ||
|
|
1362
|
+
(['poetry', 'uv', 'flit', 'hatch'].includes(exe) && args.includes('publish'))) {
|
|
1363
|
+
network = { tool: exe, target: 'publish', mutates: true };
|
|
1364
|
+
destructive();
|
|
1365
|
+
}
|
|
1366
|
+
else if (exe === 'docker' || exe === 'podman' || exe === 'docker-compose') {
|
|
581
1367
|
const sub = nonFlags(args)[0] ?? '';
|
|
582
1368
|
if (sub === 'push' || sub === 'pull' || sub === 'login') {
|
|
583
1369
|
network = { tool: exe, target: sub, mutates: sub === 'push' };
|
|
584
1370
|
bump('remote');
|
|
585
1371
|
}
|
|
586
1372
|
else if (/^(rm|rmi|prune|kill|system)$/.test(sub)) {
|
|
587
|
-
|
|
1373
|
+
destructive();
|
|
1374
|
+
}
|
|
1375
|
+
else if ((sub === 'down' || (sub === 'compose' && nonFlags(args)[1] === 'down')) &&
|
|
1376
|
+
args.some((x) => x === '-v' || x === '--volumes')) {
|
|
1377
|
+
destructive();
|
|
588
1378
|
}
|
|
589
1379
|
else {
|
|
590
1380
|
bump('local_write');
|
|
591
1381
|
}
|
|
1382
|
+
if (sub === 'exec' || sub === 'run')
|
|
1383
|
+
nestedAfterHost();
|
|
592
1384
|
}
|
|
593
1385
|
else if (exe === 'ai-dossier' || exe === 'dossier') {
|
|
594
1386
|
const sub = nonFlags(args)[0] ?? '';
|
|
595
1387
|
if (sub === 'publish' || sub === 'login') {
|
|
596
1388
|
network = { tool: exe, target: sub, mutates: sub === 'publish' };
|
|
597
|
-
|
|
1389
|
+
if (sub === 'publish')
|
|
1390
|
+
destructive();
|
|
1391
|
+
else
|
|
1392
|
+
bump('remote');
|
|
598
1393
|
}
|
|
599
1394
|
else if (sub === 'run' || sub === 'pull' || sub === 'get') {
|
|
600
1395
|
network = { tool: exe, target: sub, mutates: false };
|
|
@@ -614,11 +1409,33 @@ function classifyCommand(cmd, rawLine) {
|
|
|
614
1409
|
else {
|
|
615
1410
|
recognized = false;
|
|
616
1411
|
bump('local_write');
|
|
1412
|
+
if (inner[0].includes('/') && !/^\/(?:usr\/)?s?bin\//.test(inner[0]))
|
|
1413
|
+
execFile = inner[0];
|
|
617
1414
|
}
|
|
618
1415
|
}
|
|
619
1416
|
}
|
|
620
|
-
//
|
|
621
|
-
|
|
1417
|
+
// Here-string into an interpreter (`bash <<< "rm -rf ~"`): the string is the program.
|
|
1418
|
+
const hs = inner.findIndex((t) => t.startsWith('<<<'));
|
|
1419
|
+
if (hs >= 0) {
|
|
1420
|
+
const target = execTargetOf(inner.slice(0, hs).join(' '));
|
|
1421
|
+
const text = inner[hs].length > 3 ? inner[hs].slice(3) : (inner[hs + 1] ?? '');
|
|
1422
|
+
if (target && text)
|
|
1423
|
+
nested.push({ text, lang: target });
|
|
1424
|
+
}
|
|
1425
|
+
const stdinTarget = stdinTargetOf(inner);
|
|
1426
|
+
return {
|
|
1427
|
+
kind,
|
|
1428
|
+
recognized,
|
|
1429
|
+
files: files.filter((f) => f.path.trim() !== ''),
|
|
1430
|
+
network,
|
|
1431
|
+
nested,
|
|
1432
|
+
reason,
|
|
1433
|
+
inner,
|
|
1434
|
+
stdinTarget,
|
|
1435
|
+
execFile,
|
|
1436
|
+
opaque,
|
|
1437
|
+
inputFile,
|
|
1438
|
+
};
|
|
622
1439
|
}
|
|
623
1440
|
const KNOWN_TOOL_NAMES = new Set([
|
|
624
1441
|
...REMOTE_TOOLS,
|
|
@@ -658,83 +1475,280 @@ const IGNORED_ENV = new Set([
|
|
|
658
1475
|
'EUID',
|
|
659
1476
|
]);
|
|
660
1477
|
// --- analysis --------------------------------------------------------------
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1478
|
+
/** Maximum recursion into `sh -c` strings, heredoc bodies and embedded code. */
|
|
1479
|
+
const MAX_DEPTH = 6;
|
|
1480
|
+
/** Maximum number of nested analyses per dossier (bounds pathological self-nesting input). */
|
|
1481
|
+
const MAX_NESTED = 20000;
|
|
1482
|
+
/** Heredocs analysed per line; any beyond this are conservatively treated as shell code. */
|
|
1483
|
+
const MAX_HEREDOCS_PER_LINE = 16;
|
|
1484
|
+
/**
|
|
1485
|
+
* Nested content we refuse to analyse (too deep / too much) is NOT assumed safe:
|
|
1486
|
+
* it is recorded as a destructive finding so the level cannot be lowered by padding or nesting.
|
|
1487
|
+
*/
|
|
1488
|
+
function giveUp(ctx, line, why) {
|
|
1489
|
+
if (ctx.gaveUp.has(why))
|
|
1490
|
+
return;
|
|
1491
|
+
ctx.gaveUp.add(why);
|
|
1492
|
+
ctx.commands.push({
|
|
1493
|
+
command: `(${why})`,
|
|
1494
|
+
kind: 'destructive',
|
|
1495
|
+
recognized: false,
|
|
1496
|
+
line,
|
|
1497
|
+
reason: why,
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
const normPath = (p) => p.replace(/^\.\//, '');
|
|
1501
|
+
const isFetch = (c) => !!c &&
|
|
1502
|
+
(FETCH_TOOLS.has(c.inner[0]?.replace(/^.*\//, '') ?? '') ||
|
|
1503
|
+
(c.inner[0] === 'gh' && c.inner[1] === 'api') ||
|
|
1504
|
+
(interpKind(c.inner[0]?.replace(/^.*\//, '') ?? '') !== null &&
|
|
1505
|
+
/urlopen|requests\.|urllib|fetch\s*\(|https?:\/\//.test(c.inner.slice(1).join(' '))));
|
|
1506
|
+
function isDecoder(c) {
|
|
1507
|
+
if (!c)
|
|
1508
|
+
return false;
|
|
1509
|
+
const exe = c.inner[0]?.replace(/^.*\//, '') ?? '';
|
|
1510
|
+
const args = c.inner.slice(1);
|
|
1511
|
+
if (exe === 'base64')
|
|
1512
|
+
return args.some((a) => a === '-d' || a === '-D' || a === '--decode');
|
|
1513
|
+
if (exe === 'xxd')
|
|
1514
|
+
return args.includes('-r');
|
|
1515
|
+
if (exe === 'openssl')
|
|
1516
|
+
return args.includes('-d') || args.includes('-base64') || args.includes('-a');
|
|
1517
|
+
if (exe === 'gzip')
|
|
1518
|
+
return args.some((a) => a === '-d' || a === '--decompress');
|
|
1519
|
+
if (exe === 'unzip')
|
|
1520
|
+
return args.includes('-p');
|
|
1521
|
+
return /^(?:zcat|gzcat|gunzip|bunzip2|xzcat|unxz|uudecode|rev)$/.test(exe);
|
|
1522
|
+
}
|
|
1523
|
+
const isExecSink = (c) => {
|
|
1524
|
+
const exe = c.inner[0]?.replace(/^.*\//, '') ?? '';
|
|
1525
|
+
return exe === 'eval' || exe === 'source' || exe === '.' || interpKind(exe) !== null;
|
|
1526
|
+
};
|
|
1527
|
+
function pushHits(hits, ctx, depth) {
|
|
1528
|
+
for (const h of hits) {
|
|
1529
|
+
if (h.command) {
|
|
1530
|
+
ctx.commands.push({
|
|
1531
|
+
command: h.command,
|
|
1532
|
+
kind: h.kind,
|
|
1533
|
+
recognized: true,
|
|
1534
|
+
line: h.line,
|
|
1535
|
+
...(h.reason ? { reason: h.reason } : {}),
|
|
1536
|
+
});
|
|
1537
|
+
if (h.file)
|
|
1538
|
+
ctx.files.push({ ...h.file, command: h.command, line: h.line });
|
|
1539
|
+
if (h.network)
|
|
1540
|
+
ctx.network.push({ ...h.network, command: h.command, line: h.line });
|
|
1541
|
+
}
|
|
1542
|
+
for (const s of h.shell ?? [])
|
|
1543
|
+
analyzeShellText(s, h.line, ctx, depth + 1);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
function analyzeNested(n, line, ctx, depth) {
|
|
1547
|
+
if (n.lang === 'shell') {
|
|
1548
|
+
analyzeShellText(n.text, line, ctx, depth + 1);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
if (depth + 1 > MAX_DEPTH || ctx.budget-- <= 0) {
|
|
1552
|
+
giveUp(ctx, line, 'nested content too deep or too large to analyse');
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
const lang = n.lang === 'sql'
|
|
1556
|
+
? 'sql'
|
|
1557
|
+
: n.lang === 'js'
|
|
1558
|
+
? 'js'
|
|
1559
|
+
: n.lang === 'powershell'
|
|
1560
|
+
? 'powershell'
|
|
1561
|
+
: n.lang;
|
|
1562
|
+
pushHits((0, dry_run_langs_1.extractCodeHits)(lang, n.text.split('\n'), line), ctx, depth + 1);
|
|
1563
|
+
}
|
|
1564
|
+
/** Analyse one logical shell line (already stripped of prompts) and everything it embeds. */
|
|
1565
|
+
function analyzeShellText(clean, line, ctx, depth) {
|
|
1566
|
+
if (depth > MAX_DEPTH || (depth > 0 && ctx.budget-- <= 0)) {
|
|
1567
|
+
giveUp(ctx, line, 'nested content too deep or too large to analyse');
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
for (const sub of dqSubstitutions(clean)) {
|
|
1571
|
+
processShellFence({ lang: 'sh', startLine: line, lines: sub.split('\n') }, false, ctx, depth + 1);
|
|
1572
|
+
}
|
|
1573
|
+
const segs = splitSegments(clean);
|
|
1574
|
+
const infos = segs.map((s) => classifyCommand(s.text, s.text));
|
|
1575
|
+
// What earlier stages of the same pipeline are (incrementally, so long pipelines stay linear).
|
|
1576
|
+
let curGroup = -1;
|
|
1577
|
+
let acc = { any: false, fetch: false, decode: false, echoes: [] };
|
|
1578
|
+
segs.forEach((seg, i) => {
|
|
1579
|
+
const c = infos[i];
|
|
1580
|
+
if (seg.group !== curGroup) {
|
|
1581
|
+
curGroup = seg.group;
|
|
1582
|
+
acc = { any: false, fetch: false, decode: false, echoes: [] };
|
|
1583
|
+
}
|
|
1584
|
+
const prev = { ...acc, echoes: acc.echoes.slice() };
|
|
1585
|
+
if (c) {
|
|
1586
|
+
acc.any = true;
|
|
1587
|
+
acc.fetch ||= isFetch(c);
|
|
1588
|
+
acc.decode ||= isDecoder(c);
|
|
1589
|
+
const pe = c.inner[0]?.replace(/^.*\//, '');
|
|
1590
|
+
if ((pe === 'echo' || pe === 'printf') && acc.echoes.length < 8)
|
|
1591
|
+
acc.echoes.push(c);
|
|
1592
|
+
}
|
|
1593
|
+
if (!c)
|
|
1594
|
+
return;
|
|
1595
|
+
// Unknown words after a pipe are almost always prose (`a|b|c` alternatives), not tools.
|
|
1596
|
+
if (i > 0 && !c.recognized && c.files.length === 0 && !c.opaque)
|
|
1597
|
+
return;
|
|
1598
|
+
let kind = c.kind;
|
|
1599
|
+
let reason = c.reason;
|
|
1600
|
+
if (c.opaque) {
|
|
1601
|
+
kind = 'destructive';
|
|
1602
|
+
reason ??= `not analysable: ${c.opaque}`;
|
|
1603
|
+
}
|
|
1604
|
+
const remoteExec = (why) => {
|
|
1605
|
+
kind = 'destructive';
|
|
1606
|
+
reason ??= why;
|
|
1607
|
+
};
|
|
1608
|
+
if (c.stdinTarget && prev.any) {
|
|
1609
|
+
if (prev.fetch) {
|
|
1610
|
+
remoteExec('remote code execution: fetched content piped into an interpreter');
|
|
1611
|
+
}
|
|
1612
|
+
else if (prev.decode) {
|
|
1613
|
+
remoteExec('decoded payload piped into an interpreter');
|
|
1614
|
+
}
|
|
1615
|
+
else {
|
|
1616
|
+
for (const p of prev.echoes) {
|
|
1617
|
+
const pe = p.inner[0]?.replace(/^.*\//, '');
|
|
1618
|
+
let words = nonFlags(p.inner.slice(1));
|
|
1619
|
+
if (pe === 'printf' && words[0]?.includes('%'))
|
|
1620
|
+
words = words.slice(1);
|
|
1621
|
+
if (words.length > 0)
|
|
1622
|
+
analyzeNested({ text: words.join(' '), lang: c.stdinTarget }, line, ctx, depth);
|
|
695
1623
|
}
|
|
696
|
-
continue;
|
|
697
1624
|
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
1625
|
+
}
|
|
1626
|
+
// `bash <(curl ...)`, `eval $(curl ...)`, `sh -c $(curl ...)`: fetched content feeds an exec sink.
|
|
1627
|
+
const next = segs[i + 1];
|
|
1628
|
+
const nextInfo = infos[i + 1];
|
|
1629
|
+
if (next &&
|
|
1630
|
+
nextInfo &&
|
|
1631
|
+
isExecSink(c) &&
|
|
1632
|
+
(isFetch(nextInfo) || isDecoder(nextInfo)) &&
|
|
1633
|
+
(next.sep === '$(' || next.sep === '`' || (next.sep === '(' && /<\s*$/.test(seg.text)))) {
|
|
1634
|
+
remoteExec('remote code execution: fetched content is executed');
|
|
1635
|
+
}
|
|
1636
|
+
if (isFetch(c) || (prev.fetch && c.inner[0] === 'tee'))
|
|
1637
|
+
for (const f of c.files)
|
|
1638
|
+
ctx.downloaded.add(normPath(f.path));
|
|
1639
|
+
if (c.stdinTarget && c.inputFile && ctx.downloaded.has(normPath(c.inputFile))) {
|
|
1640
|
+
remoteExec('executes a file that was downloaded earlier in the dossier');
|
|
1641
|
+
}
|
|
1642
|
+
if (c.execFile && ctx.downloaded.has(normPath(c.execFile))) {
|
|
1643
|
+
remoteExec('executes a file that was downloaded earlier in the dossier');
|
|
1644
|
+
}
|
|
1645
|
+
ctx.commands.push({
|
|
1646
|
+
command: seg.text,
|
|
1647
|
+
kind,
|
|
1648
|
+
recognized: c.recognized,
|
|
1649
|
+
line,
|
|
1650
|
+
...(reason ? { reason } : {}),
|
|
1651
|
+
});
|
|
1652
|
+
for (const f of c.files)
|
|
1653
|
+
ctx.files.push({ ...f, command: seg.text, line });
|
|
1654
|
+
if (c.network)
|
|
1655
|
+
ctx.network.push({ ...c.network, command: seg.text, line });
|
|
1656
|
+
for (const n of c.nested)
|
|
1657
|
+
analyzeNested(n, line, ctx, depth);
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
function scanEnvShell(clean, line, ctx) {
|
|
1661
|
+
for (const m of clean.matchAll(/(?:^|[\s;&(])([A-Za-z_][A-Za-z0-9_]*)=/g))
|
|
1662
|
+
ctx.assigned.add(m[1]);
|
|
1663
|
+
for (const m of clean.matchAll(/\b(?:for|read(?:\s+-\w+)*|local|declare|export)\s+([A-Za-z_][A-Za-z0-9_]*)/g))
|
|
1664
|
+
ctx.assigned.add(m[1]);
|
|
1665
|
+
const envText = clean.replace(/'[^']*'/g, (q) => ' '.repeat(q.length));
|
|
1666
|
+
for (const m of envText.matchAll(ENV_SHELL)) {
|
|
1667
|
+
const name = m[1] ?? m[2];
|
|
1668
|
+
if (name && !ctx.assigned.has(name) && !IGNORED_ENV.has(name) && !ctx.env.has(name)) {
|
|
1669
|
+
ctx.env.set(name, { name, line });
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
function processShellFence(fence, isBare, ctx, depth) {
|
|
1674
|
+
if (depth > MAX_DEPTH) {
|
|
1675
|
+
giveUp(ctx, fence.startLine, 'nested content too deep or too large to analyse');
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
for (const { text, line, heredocs } of logicalLines(fence)) {
|
|
1679
|
+
if (!text || text.startsWith('#'))
|
|
1680
|
+
continue;
|
|
1681
|
+
// Strip a leading `$ ` prompt used in console-style blocks.
|
|
1682
|
+
const clean = text.replace(/^\$\s+/, '');
|
|
1683
|
+
// Unlabeled fences are often templates (PR bodies, commit messages); only trust known tools.
|
|
1684
|
+
const skip = isBare &&
|
|
1685
|
+
!KNOWN_TOOL_NAMES.has(stripEnvAssignments(tokenize(clean))[0]?.replace(/^.*\//, '') ?? '');
|
|
1686
|
+
if (skip)
|
|
1687
|
+
continue;
|
|
1688
|
+
scanEnvShell(clean, line, ctx);
|
|
1689
|
+
analyzeShellText(clean, line, ctx, depth);
|
|
1690
|
+
// A heredoc fed to a shell / interpreter is code; one fed to `cat > file` / `tee` is data.
|
|
1691
|
+
for (const h of heredocs) {
|
|
1692
|
+
if (!h.exec || h.body.length === 0)
|
|
703
1693
|
continue;
|
|
1694
|
+
if (h.exec === 'shell') {
|
|
1695
|
+
processShellFence({ lang: 'sh', startLine: h.bodyLine, lines: h.body }, false, ctx, depth + 1);
|
|
704
1696
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
for (const m of clean.matchAll(/\b(?:for|read(?:\s+-\w+)*|local|declare|export)\s+([A-Za-z_][A-Za-z0-9_]*)/g))
|
|
708
|
-
assigned.add(m[1]);
|
|
709
|
-
const envText = clean.replace(/'[^']*'/g, (q) => ' '.repeat(q.length));
|
|
710
|
-
for (const m of envText.matchAll(ENV_SHELL)) {
|
|
711
|
-
const name = m[1] ?? m[2];
|
|
712
|
-
if (name && !assigned.has(name) && !IGNORED_ENV.has(name) && !env.has(name)) {
|
|
713
|
-
env.set(name, { name, line });
|
|
714
|
-
}
|
|
1697
|
+
else if (ctx.budget-- > 0) {
|
|
1698
|
+
pushHits((0, dry_run_langs_1.extractCodeHits)(h.exec, h.body, h.bodyLine), ctx, depth + 1);
|
|
715
1699
|
}
|
|
716
|
-
const parts = splitCommands(clean);
|
|
717
|
-
const exes = parts.map((p) => stripEnvAssignments(tokenize(p))[0]?.replace(/^.*\//, '') ?? '');
|
|
718
|
-
const pipesToShell = /\|\s*(sudo\s+)?(sh|bash|zsh)\b/.test(maskQuotes(clean)) && /\b(curl|wget)\b/.test(clean);
|
|
719
|
-
parts.forEach((part, i) => {
|
|
720
|
-
// Redirections are attached to the segment that owns them.
|
|
721
|
-
const c = classifyCommand(part, part);
|
|
722
|
-
if (!c)
|
|
723
|
-
return;
|
|
724
|
-
// Unknown words after a pipe are almost always prose (`a|b|c` alternatives), not tools.
|
|
725
|
-
if (i > 0 && !c.recognized && c.files.length === 0)
|
|
726
|
-
return;
|
|
727
|
-
let kind = c.kind;
|
|
728
|
-
if (pipesToShell && (exes[i] === 'sh' || exes[i] === 'bash' || exes[i] === 'zsh'))
|
|
729
|
-
kind = 'destructive';
|
|
730
|
-
commands.push({ command: part, kind, recognized: c.recognized, line });
|
|
731
|
-
for (const f of c.files)
|
|
732
|
-
files.push({ ...f, command: part, line });
|
|
733
|
-
if (c.network)
|
|
734
|
-
network.push({ ...c.network, command: part, line });
|
|
735
|
-
});
|
|
736
1700
|
}
|
|
737
1701
|
}
|
|
1702
|
+
}
|
|
1703
|
+
function processFence(fence, ctx) {
|
|
1704
|
+
const lang = fence.lang;
|
|
1705
|
+
if (SHELL_LANGS.has(lang) || lang === '') {
|
|
1706
|
+
processShellFence(fence, lang === '', ctx, 0);
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
let code = CODE_LANGS[lang];
|
|
1710
|
+
if (lang === 'docker') {
|
|
1711
|
+
// ```docker holds either a Dockerfile or `docker ...` shell commands.
|
|
1712
|
+
const isDockerfile = fence.lines.some((l) => /^\s*(?:FROM|RUN|COPY|ADD|ENV|WORKDIR|CMD|ENTRYPOINT|ARG)\s/.test(l));
|
|
1713
|
+
if (!isDockerfile) {
|
|
1714
|
+
processShellFence(fence, false, ctx, 0);
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
code = 'dockerfile';
|
|
1718
|
+
}
|
|
1719
|
+
if (!code) {
|
|
1720
|
+
if (!INERT_LANGS.has(lang))
|
|
1721
|
+
ctx.unanalyzed.push({ lang, line: fence.startLine });
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
pushHits((0, dry_run_langs_1.extractCodeHits)(code, fence.lines, fence.startLine), ctx, 0);
|
|
1725
|
+
if (code === 'js') {
|
|
1726
|
+
fence.lines.forEach((text, idx) => {
|
|
1727
|
+
for (const m of text.matchAll(ENV_NODE)) {
|
|
1728
|
+
const name = m[1] ?? m[2];
|
|
1729
|
+
if (name && !ctx.env.has(name))
|
|
1730
|
+
ctx.env.set(name, { name, line: fence.startLine + idx });
|
|
1731
|
+
}
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
const LEVEL_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
1736
|
+
function analyzeDryRun(input) {
|
|
1737
|
+
const { frontmatter, body } = typeof input === 'string' ? (0, parser_1.parseDossierContent)(input) : input;
|
|
1738
|
+
const ctx = {
|
|
1739
|
+
commands: [],
|
|
1740
|
+
files: [],
|
|
1741
|
+
network: [],
|
|
1742
|
+
env: new Map(),
|
|
1743
|
+
assigned: new Set(),
|
|
1744
|
+
downloaded: new Set(),
|
|
1745
|
+
unanalyzed: [],
|
|
1746
|
+
budget: MAX_NESTED,
|
|
1747
|
+
gaveUp: new Set(),
|
|
1748
|
+
};
|
|
1749
|
+
for (const fence of extractFences(body))
|
|
1750
|
+
processFence(fence, ctx);
|
|
1751
|
+
const { commands, files, network } = ctx;
|
|
738
1752
|
// Score.
|
|
739
1753
|
const breakdown = [];
|
|
740
1754
|
const riskLevel = frontmatter.risk_level ?? null;
|
|
@@ -752,16 +1766,35 @@ function analyzeDryRun(input) {
|
|
|
752
1766
|
component: `${destructive.length} declared destructive_operations`,
|
|
753
1767
|
points: destPts,
|
|
754
1768
|
});
|
|
1769
|
+
let observedPts = 0;
|
|
755
1770
|
for (const kind of ['local_write', 'remote', 'destructive']) {
|
|
756
1771
|
const n = commands.filter((c) => c.kind === kind).length;
|
|
757
1772
|
const pts = Math.min(n * exports.SCORE_PER_COMMAND[kind], exports.SCORE_COMMAND_CAP[kind]);
|
|
1773
|
+
observedPts += pts;
|
|
758
1774
|
if (pts > 0)
|
|
759
1775
|
breakdown.push({
|
|
760
1776
|
component: `${n} ${kind} command${n === 1 ? '' : 's'} in code blocks`,
|
|
761
1777
|
points: pts,
|
|
762
1778
|
});
|
|
763
1779
|
}
|
|
1780
|
+
// Observed behaviour outranks the dossier's self-declared risk: a destructive command
|
|
1781
|
+
// floors the level at high no matter what risk_level the dossier declares.
|
|
1782
|
+
const hasDestructive = commands.some((c) => c.kind === 'destructive');
|
|
1783
|
+
if (hasDestructive) {
|
|
1784
|
+
const total = breakdown.reduce((s, b) => s + b.points, 0);
|
|
1785
|
+
if (total < exports.LEVEL_HIGH_MIN) {
|
|
1786
|
+
breakdown.push({
|
|
1787
|
+
component: 'floor: destructive command observed (raised to high regardless of declared risk)',
|
|
1788
|
+
points: exports.LEVEL_HIGH_MIN - total,
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
observedPts = Math.max(observedPts, exports.LEVEL_HIGH_MIN);
|
|
1792
|
+
}
|
|
764
1793
|
const risk_score = Math.min(100, breakdown.reduce((s, b) => s + b.points, 0));
|
|
1794
|
+
const level = levelForScore(risk_score);
|
|
1795
|
+
const observedLevel = levelForScore(Math.min(100, observedPts));
|
|
1796
|
+
const declaredRank = riskLevel && riskLevel in LEVEL_RANK ? LEVEL_RANK[riskLevel] : 0;
|
|
1797
|
+
const mismatch = LEVEL_RANK[observedLevel] > declaredRank;
|
|
765
1798
|
return {
|
|
766
1799
|
schema_version: exports.DRY_RUN_SCHEMA_VERSION,
|
|
767
1800
|
static_preview: true,
|
|
@@ -780,10 +1813,19 @@ function analyzeDryRun(input) {
|
|
|
780
1813
|
files,
|
|
781
1814
|
commands,
|
|
782
1815
|
network,
|
|
783
|
-
env: [...env.values()],
|
|
1816
|
+
env: [...ctx.env.values()],
|
|
784
1817
|
risk_score,
|
|
785
|
-
level
|
|
1818
|
+
level,
|
|
786
1819
|
score_breakdown: breakdown,
|
|
1820
|
+
declared_vs_observed: {
|
|
1821
|
+
declared_level: riskLevel,
|
|
1822
|
+
observed_level: observedLevel,
|
|
1823
|
+
mismatch,
|
|
1824
|
+
note: mismatch
|
|
1825
|
+
? `Code blocks show ${observedLevel}-risk behaviour but the dossier declares ${riskLevel ?? 'no risk_level'}.`
|
|
1826
|
+
: 'Observed behaviour is consistent with the declared risk level.',
|
|
1827
|
+
},
|
|
1828
|
+
unanalyzed_fences: ctx.unanalyzed,
|
|
787
1829
|
};
|
|
788
1830
|
}
|
|
789
1831
|
//# sourceMappingURL=dry-run.js.map
|