@oked/sdk 0.1.7 → 0.1.8
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/classify.js +136 -10
- package/package.json +1 -1
package/dist/classify.js
CHANGED
|
@@ -101,16 +101,34 @@ const SAFE_COMMANDS = [
|
|
|
101
101
|
// high-stakes scan, which runs on the full command first.
|
|
102
102
|
/^(for|while|until|do|done|then|else|elif|fi|case|esac|if|select)\b/,
|
|
103
103
|
/^(done|fi|esac|\}|\{|:|true|false)\s*$/,
|
|
104
|
+
// Inert shell builtins / flow helpers with no side effects.
|
|
105
|
+
/^sleep\b/,
|
|
106
|
+
/^exit\b/,
|
|
107
|
+
/^return\b/,
|
|
108
|
+
/^seq\b/,
|
|
109
|
+
/^test\b/,
|
|
110
|
+
/^\[\[?\s/, // [ ... ] and [[ ... ]] conditionals
|
|
111
|
+
// `<cmd> --help` / `--version` just prints usage. (Dangerous verbs like
|
|
112
|
+
// `git push` are matched by HIGH_STAKES on the full command first.)
|
|
113
|
+
/\s--(help|version)\b/,
|
|
114
|
+
// Read-only npm subcommands beyond the install/version ones above.
|
|
115
|
+
/^npm\s+(prefix|root|bin|whoami|ping|docs|repo|why|explain|pkg\s+get|config\s+get)\b/,
|
|
104
116
|
];
|
|
105
|
-
//
|
|
106
|
-
|
|
117
|
+
// File deletion (rm/rmdir/trash). Checked PER-STAGE (not on the full command)
|
|
118
|
+
// so the ephemeral-temp downgrade can run first: `something; rm /tmp/x` must be
|
|
119
|
+
// warning, but `rm /tmp/x` mixed with a non-temp delete in another stage stays
|
|
120
|
+
// high_stakes. Matching the bare word also catches it inside a loop body or a
|
|
121
|
+
// $(...) substitution stage.
|
|
122
|
+
const DELETE_PATTERNS = [
|
|
107
123
|
/\brm\b/,
|
|
108
|
-
/\brm\b\s+(?:-[^\s]*[rf][^\s]*\s+)*-[^\s]*[rf][^\s]*\b/,
|
|
109
|
-
/\brm\s+--recursive\b/,
|
|
110
|
-
/\brm\b.*\s+\//, // rm with absolute path
|
|
111
124
|
/\brmdir\b/,
|
|
112
125
|
/\btrash\b/,
|
|
113
126
|
/\btrash-put\b/,
|
|
127
|
+
];
|
|
128
|
+
// Bash commands classified as high stakes (destructive, irreversible, external).
|
|
129
|
+
// Scanned on the FULL command (some patterns, e.g. download|shell, span a pipe).
|
|
130
|
+
// File deletion lives in DELETE_PATTERNS (per-stage) instead, see above.
|
|
131
|
+
const HIGH_STAKES_COMMANDS = [
|
|
114
132
|
/\bgit\s+push\b/,
|
|
115
133
|
/\bgit\s+reset\s+--hard\b/,
|
|
116
134
|
/\bgit\s+clean\s+-f/,
|
|
@@ -310,6 +328,7 @@ function splitTopLevel(cmd) {
|
|
|
310
328
|
const out = [];
|
|
311
329
|
let cur = "";
|
|
312
330
|
let quote = null;
|
|
331
|
+
let subDepth = 0; // depth inside $( ... ) / `...` command substitutions
|
|
313
332
|
let i = 0;
|
|
314
333
|
while (i < cmd.length) {
|
|
315
334
|
const ch = cmd[i];
|
|
@@ -326,6 +345,31 @@ function splitTopLevel(cmd) {
|
|
|
326
345
|
i++;
|
|
327
346
|
continue;
|
|
328
347
|
}
|
|
348
|
+
// Command substitution: keep `$( ... )` / backticks intact so a pipe or `;`
|
|
349
|
+
// inside (e.g. `V=$(curl … | sed …)`) isn't treated as a top-level operator.
|
|
350
|
+
if (ch === "$" && cmd[i + 1] === "(") {
|
|
351
|
+
subDepth++;
|
|
352
|
+
cur += "$(";
|
|
353
|
+
i += 2;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (subDepth > 0 && ch === "(") {
|
|
357
|
+
subDepth++;
|
|
358
|
+
cur += ch;
|
|
359
|
+
i++;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (subDepth > 0 && ch === ")") {
|
|
363
|
+
subDepth--;
|
|
364
|
+
cur += ch;
|
|
365
|
+
i++;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (subDepth > 0) {
|
|
369
|
+
cur += ch;
|
|
370
|
+
i++;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
329
373
|
// Heredoc: consume the opener and the entire body (up to the closing
|
|
330
374
|
// delimiter line) as part of the current segment, so operators inside a
|
|
331
375
|
// heredoc fed to an interpreter (psql/node/…) aren't treated as separators.
|
|
@@ -379,21 +423,86 @@ function isEphemeralOnlyDeletion(command) {
|
|
|
379
423
|
return false;
|
|
380
424
|
return targets.every((a) => isEphemeralPath(unquote(a)));
|
|
381
425
|
}
|
|
426
|
+
/**
|
|
427
|
+
* Classifies a single stage that begins with one or more `NAME=value`
|
|
428
|
+
* assignments (a pure assignment like `TARGET=abc`, an env prefix like
|
|
429
|
+
* `FOO=bar cmd`, or a capture like `V=$(cmd)`). Strips the leading assignments,
|
|
430
|
+
* then takes the worst tier of: each command-substitution inner found in the
|
|
431
|
+
* values, plus any trailing command. A purely literal assignment is `safe`.
|
|
432
|
+
* Returns null if the stage is not assignment-prefixed.
|
|
433
|
+
*/
|
|
434
|
+
function classifyAssignmentStage(stage) {
|
|
435
|
+
if (!/^\w+=/.test(stage))
|
|
436
|
+
return null;
|
|
437
|
+
const inners = [];
|
|
438
|
+
let i = 0;
|
|
439
|
+
while (i < stage.length) {
|
|
440
|
+
const m = stage.slice(i).match(/^(\w+)=/);
|
|
441
|
+
if (!m)
|
|
442
|
+
break; // next token isn't an assignment → it's the command
|
|
443
|
+
i += m[0].length;
|
|
444
|
+
let quote = null;
|
|
445
|
+
while (i < stage.length) {
|
|
446
|
+
const ch = stage[i];
|
|
447
|
+
if (quote) {
|
|
448
|
+
if (ch === quote)
|
|
449
|
+
quote = null;
|
|
450
|
+
i++;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (ch === '"' || ch === "'") {
|
|
454
|
+
quote = ch;
|
|
455
|
+
i++;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (ch === "$" && stage[i + 1] === "(") {
|
|
459
|
+
let d = 1, j = i + 2;
|
|
460
|
+
while (j < stage.length && d > 0) {
|
|
461
|
+
if (stage[j] === "(")
|
|
462
|
+
d++;
|
|
463
|
+
else if (stage[j] === ")")
|
|
464
|
+
d--;
|
|
465
|
+
j++;
|
|
466
|
+
}
|
|
467
|
+
inners.push(stage.slice(i + 2, j - 1));
|
|
468
|
+
i = j;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (ch === "`") {
|
|
472
|
+
let j = i + 1;
|
|
473
|
+
while (j < stage.length && stage[j] !== "`")
|
|
474
|
+
j++;
|
|
475
|
+
inners.push(stage.slice(i + 1, j));
|
|
476
|
+
i = j + 1;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (/\s/.test(ch))
|
|
480
|
+
break; // end of this value
|
|
481
|
+
i++;
|
|
482
|
+
}
|
|
483
|
+
while (i < stage.length && /\s/.test(stage[i]))
|
|
484
|
+
i++;
|
|
485
|
+
}
|
|
486
|
+
const rest = stage.slice(i).trim();
|
|
487
|
+
let tier = "safe";
|
|
488
|
+
for (const inner of inners)
|
|
489
|
+
tier = maxTier(tier, classifyBashCommand(inner));
|
|
490
|
+
if (rest)
|
|
491
|
+
tier = maxTier(tier, classifyBashCommand(rest));
|
|
492
|
+
return tier;
|
|
493
|
+
}
|
|
382
494
|
function classifyBashCommand(command) {
|
|
383
495
|
if (!command)
|
|
384
496
|
return "safe";
|
|
385
497
|
// Strip heredoc bodies up front: their contents are literal data, not shell,
|
|
386
498
|
// and must not be scanned for high-stakes tokens, operators, or redirects.
|
|
387
499
|
const trimmed = stripHeredocBodies(command).trim();
|
|
388
|
-
// rm/trash of only ephemeral temp files → warning (before the high-stakes
|
|
389
|
-
// scan, which would otherwise match the bare `rm`).
|
|
390
|
-
if (isEphemeralOnlyDeletion(trimmed))
|
|
391
|
-
return "warning";
|
|
392
500
|
// High-stakes scan on the FULL command, before any splitting. These patterns
|
|
393
501
|
// use \b and several intentionally span an operator (e.g. `curl … | bash`,
|
|
394
502
|
// `wget … | sh` — download-and-execute), so they have to be matched against
|
|
395
503
|
// the whole string. Most-restrictive-wins: a high-stakes match anywhere in a
|
|
396
|
-
// compound command takes the whole command to high_stakes.
|
|
504
|
+
// compound command takes the whole command to high_stakes. (File deletion is
|
|
505
|
+
// NOT here — it's per-stage below so the ephemeral-temp downgrade can run.)
|
|
397
506
|
for (const pattern of HIGH_STAKES_COMMANDS) {
|
|
398
507
|
if (pattern.test(trimmed))
|
|
399
508
|
return "high_stakes";
|
|
@@ -407,6 +516,23 @@ function classifyBashCommand(command) {
|
|
|
407
516
|
if (stages.length > 1) {
|
|
408
517
|
return stages.reduce((worst, stage) => maxTier(worst, classifyBashCommand(stage)), "safe");
|
|
409
518
|
}
|
|
519
|
+
// ---- single stage from here ----
|
|
520
|
+
// File deletion, per-stage so the ephemeral-temp carve-out can win: deleting
|
|
521
|
+
// only throwaway temp files (/tmp, $TMPDIR, /var/folders, …) → warning; any
|
|
522
|
+
// non-temp target (or the temp root itself) → high_stakes.
|
|
523
|
+
if (isEphemeralOnlyDeletion(trimmed))
|
|
524
|
+
return "warning";
|
|
525
|
+
for (const pattern of DELETE_PATTERNS) {
|
|
526
|
+
if (pattern.test(trimmed))
|
|
527
|
+
return "high_stakes";
|
|
528
|
+
}
|
|
529
|
+
// Variable assignments: `NAME=value` (pure) or `NAME=$(cmd) rest` (env prefix
|
|
530
|
+
// / capture). Classify the command-substitution inners and any trailing
|
|
531
|
+
// command; a purely literal assignment is safe. Dangerous substitutions were
|
|
532
|
+
// already caught by the high-stakes/delete scans above.
|
|
533
|
+
const assignTier = classifyAssignmentStage(trimmed);
|
|
534
|
+
if (assignTier !== null)
|
|
535
|
+
return assignTier;
|
|
410
536
|
// sudo: privilege escalation. A high-stakes inner command was already caught
|
|
411
537
|
// by the full-string scan above (\b patterns match through the `sudo` prefix);
|
|
412
538
|
// anything else still warrants review.
|