fix(adapters): block residual kill bypasses on graphical computers (#81)
python -m, stdin programs, and deno/bun eval or kill-named scripts could still tear down a managed graphical desktop after the shell guard tokenized the command. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
155a42eef2
commit
5a03ae014c
@ -136,6 +136,108 @@ describe("computer lifecycle command guard", () => {
|
||||
expect(isProtectedComputerLifecycleCommand("python -c 'print(\"hello\")'")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`python -m timeit 'os.kill(1, 9)'`,
|
||||
`python3 -m timeit 'import os; os.system("pkill chromium")'`,
|
||||
`python -m runpy -c 'import os; os.kill(1, 9)'`,
|
||||
`python3 -m trace -c 'os.system("pkill chromium")'`,
|
||||
`python -m timeit 'subprocess.call(["pkill", "chromium"])'`,
|
||||
`python -m timeit 'subprocess.call(["kill", "-9", "1"])'`,
|
||||
"python -m scripts.kill",
|
||||
"python -m kill.main",
|
||||
"python3 -m pkg.pkill",
|
||||
"/usr/bin/python3 -m os.kill",
|
||||
`python -W ignore -m timeit 'os.kill(1, 9)'`,
|
||||
`python3.12 -O -m timeit 'os.kill(1, 9)'`,
|
||||
"command python3 -m os.kill",
|
||||
"FOO=1 python -m os.kill",
|
||||
`echo 'import os; os.kill(1, 9)' | python -`,
|
||||
`echo 'pkill chromium' | python3 -`,
|
||||
`printf '%s\\n' 'import os; os.system("pkill chromium")' | python3 -`,
|
||||
`echo 'process.kill(1)' | node -`,
|
||||
`echo 'process.kill(1)' | node --require preload.js -`,
|
||||
"python - <<'PY'\np = \"pkill chromium\"\nPY",
|
||||
`echo 'os.kill(1)' | python > /tmp/out -`,
|
||||
`echo 'system("pkill chromium")' | perl -`,
|
||||
`echo 'system("pkill chromium")' | perl -I lib -`,
|
||||
`echo 'system("pkill chromium")' | ruby -`,
|
||||
`deno eval 'Deno.kill(1, "SIGKILL")'`,
|
||||
`deno eval 'process.kill(1, "SIGKILL")'`,
|
||||
`deno eval --ext=ts 'Deno.kill(1, "SIGKILL")'`,
|
||||
`echo 'Deno.kill(1)' | deno run -`,
|
||||
`bun -e 'process.kill(1, "SIGKILL")'`,
|
||||
`bun --eval 'require("child_process").execSync("pkill chromium")'`,
|
||||
`bun --eval='process.kill(1)'`,
|
||||
`bun -e'process.kill(1)'`,
|
||||
"deno run kill.ts",
|
||||
"deno run --allow-net kill.ts",
|
||||
"deno run --config deno.json kill.ts",
|
||||
"deno --quiet run kill.ts",
|
||||
"deno run -- kill.ts",
|
||||
"deno test kill.ts",
|
||||
"bun /tmp/pkill.ts",
|
||||
"bun run ./kill.ts",
|
||||
"bun src/xkill.js",
|
||||
`eval "pkill chromium"`,
|
||||
`bash -c 'python -m timeit "os.kill(1, 9)"'`,
|
||||
`bash -c 'echo "os.kill(1)" | python -'`,
|
||||
`bash -c 'deno run kill.ts'`,
|
||||
])("rejects kill-family python -m, stdin, and deno/bun bypasses: %s", (command) => {
|
||||
expect(isProtectedComputerLifecycleCommand(command)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"python -m pip",
|
||||
"python3 -m pip install",
|
||||
"python -O -m http.server",
|
||||
"python3 -q -m json.tool",
|
||||
"python -m os",
|
||||
"python -m os.path",
|
||||
"python -Ompip install",
|
||||
"python -W ignore -m pip",
|
||||
"python3.12 -m pip",
|
||||
"python -m pytest kill.py",
|
||||
`python -m timeit 'print("kill switch")'`,
|
||||
`python -m timeit 'print("process.killer")'`,
|
||||
"python -m myapp.kill_switch",
|
||||
"python -",
|
||||
`echo 'print("hello")' | python -`,
|
||||
`echo 'print("kill switch")' | python3 -`,
|
||||
"python - <<'PY'\nprint hello\nPY",
|
||||
`echo 'os.kill(1)' | python -m pip`,
|
||||
`echo 'os.kill(1)' | python script.py`,
|
||||
`python -m pip && echo 'os.kill(1)'`,
|
||||
"python3 scripts/train.py",
|
||||
"node scripts/check.js",
|
||||
`echo 'console.log(1)' | node -`,
|
||||
`node -e 'console.log("kill switch")'`,
|
||||
`python -c 'print("kill switch")'`,
|
||||
`deno eval 'console.log(1)'`,
|
||||
`deno eval 'eval(1)'`,
|
||||
`deno eval 'console.log("kill switch")'`,
|
||||
`deno eval --ext=ts 'console.log("kill switch")'`,
|
||||
"deno run src/main.ts",
|
||||
"deno run --allow-net=localhost src/main.ts",
|
||||
"deno run src/main.ts -- kill.ts",
|
||||
"deno fmt kill.ts",
|
||||
"deno fmt src/kill.ts",
|
||||
"bun src/index.ts",
|
||||
"bun run dev",
|
||||
"bun run src/index.ts -- kill.ts",
|
||||
`bun -e 'console.log(1)'`,
|
||||
`bun -e 'console.log("kill switch")'`,
|
||||
"bun -p '1+1'",
|
||||
"bun install",
|
||||
"echo python -m os.kill",
|
||||
"echo deno run kill.ts",
|
||||
`echo 'puts "hello"' | ruby -`,
|
||||
"ruby scripts/check.rb",
|
||||
`perl -e 'print "kill switch"'`,
|
||||
"command python3 -m http.server",
|
||||
])("keeps ordinary python -m, stdin, and deno/bun work: %s", (command) => {
|
||||
expect(isProtectedComputerLifecycleCommand(command)).toBe(false);
|
||||
});
|
||||
|
||||
it("maps interpreter scripts onto the workspace file API", () => {
|
||||
expect(protectedScriptWorkspacePath("./scripts/stop.py", undefined)).toBe("scripts/stop.py");
|
||||
expect(protectedScriptWorkspacePath("evil.js", "notes")).toBe("notes/evil.js");
|
||||
|
||||
@ -16,7 +16,87 @@ const SCRIPT_INLINE_INTERPRETER_NAMES = /^(?:python3?|node|nodejs|perl|ruby|php)
|
||||
* command-like forms so prose such as "kill switch" stays allowed.
|
||||
*/
|
||||
const SCRIPT_LIFECYCLE_KILL =
|
||||
/\b(?:pkill|killall|xkill)\b|(?:os|process)\.kill\b|(['"`])kill(?:\s+-\w+|\s+\d+|\1)|\[\s*['"]kill['"]/i;
|
||||
/\b(?:pkill|killall|xkill)\b|(?:os|process|deno)\.kill\b|(['"`])kill(?:\s+-\w+|\s+\d+|\1)|\[\s*['"]kill['"]/i;
|
||||
const KILL_MODULE_PART = /^(?:kill|pkill|killall|xkill)$/;
|
||||
const PYTHON_COMMAND = /^python(?:3(?:\.\d+)?)?$/;
|
||||
/** Python short options that do not take a value, after the guard lowercases argv. */
|
||||
const PYTHON_NO_ARG = new Set(["b", "d", "e", "h", "i", "o", "p", "q", "s", "u", "v", "x"]);
|
||||
const NODE_COMMAND = /^(?:node|nodejs)$/;
|
||||
const DENO_VALUE_FLAGS = new Set([
|
||||
"--config",
|
||||
"-c",
|
||||
"--import-map",
|
||||
"--lock",
|
||||
"--cert",
|
||||
"--location",
|
||||
"--seed",
|
||||
"--env-file",
|
||||
"--preload",
|
||||
"--ext",
|
||||
"--coverage",
|
||||
]);
|
||||
const DENO_RUNNERS = new Set(["run", "test", "bench", "serve"]);
|
||||
const DENO_NON_EXEC = new Set([
|
||||
"fmt",
|
||||
"lint",
|
||||
"check",
|
||||
"cache",
|
||||
"doc",
|
||||
"completions",
|
||||
"upgrade",
|
||||
"install",
|
||||
"uninstall",
|
||||
"compile",
|
||||
"repl",
|
||||
"help",
|
||||
"task",
|
||||
"info",
|
||||
"init",
|
||||
"publish",
|
||||
"coverage",
|
||||
]);
|
||||
const BUN_VALUE_FLAGS = new Set([
|
||||
"--cwd",
|
||||
"--config",
|
||||
"--bunfig",
|
||||
"--preload",
|
||||
"-r",
|
||||
"--env-file",
|
||||
"--define",
|
||||
"--target",
|
||||
"--outfile",
|
||||
"--outdir",
|
||||
"--port",
|
||||
"--filter",
|
||||
"--conditions",
|
||||
"--tsconfig-override",
|
||||
"-d",
|
||||
]);
|
||||
const BUN_RUNNERS = new Set(["run", "test"]);
|
||||
const PERL_STDIN_VALUE_LETTERS = new Set(["i", "m"]);
|
||||
const RUBY_STDIN_VALUE_LETTERS = new Set(["r", "i"]);
|
||||
const BUN_NON_EXEC = new Set([
|
||||
"install",
|
||||
"add",
|
||||
"remove",
|
||||
"rm",
|
||||
"update",
|
||||
"link",
|
||||
"unlink",
|
||||
"pm",
|
||||
"why",
|
||||
"patch",
|
||||
"outdated",
|
||||
"init",
|
||||
"create",
|
||||
"upgrade",
|
||||
"x",
|
||||
"repl",
|
||||
"help",
|
||||
"info",
|
||||
"publish",
|
||||
"completions",
|
||||
]);
|
||||
/** Cap for workspace script reads. Larger files are not content-scanned. */
|
||||
export const PROTECTED_SCRIPT_SCAN_BYTES = 65_536;
|
||||
|
||||
@ -488,6 +568,288 @@ export function protectedScriptWorkspacePath(
|
||||
return safeWorkspacePath(base ? `${base}/${slash}` : slash);
|
||||
}
|
||||
|
||||
interface ProtectedShellCommand {
|
||||
/** Argv for one simple command, without redirect targets. */
|
||||
argv: string[];
|
||||
/** Index of the command name after prefixes and assignments. */
|
||||
commandAt: number | null;
|
||||
}
|
||||
|
||||
type PythonInvocation =
|
||||
| { kind: "module"; payload: string[] }
|
||||
| { kind: "stdin" }
|
||||
| { kind: "other" };
|
||||
|
||||
function flagName(word: string): string {
|
||||
const equals = word.indexOf("=");
|
||||
return equals === -1 ? word : word.slice(0, equals);
|
||||
}
|
||||
|
||||
function modulePayloadTargetsKill(payload: string[]): boolean {
|
||||
const moduleName = payload[0] ?? "";
|
||||
if (moduleName.split(".").some((part) => KILL_MODULE_PART.test(part))) return true;
|
||||
return SCRIPT_LIFECYCLE_KILL.test(payload.join(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* `python -m` runs the module (and tools such as runpy/timeit can run the
|
||||
* following arguments). Inline `-c` is not scanned here. `-` reads the
|
||||
* program from stdin.
|
||||
*/
|
||||
function pythonInvocation(args: string[]): PythonInvocation {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const word = args[index] ?? "";
|
||||
if (word === "--") return args[index + 1] === "-" ? { kind: "stdin" } : { kind: "other" };
|
||||
if (word === "-") return { kind: "stdin" };
|
||||
if (!word.startsWith("-")) return { kind: "other" };
|
||||
if (word.startsWith("--")) {
|
||||
if (word === "--check-hash-based-pycs") index += 1;
|
||||
continue;
|
||||
}
|
||||
const body = word.slice(1);
|
||||
for (let cursor = 0; cursor < body.length; cursor += 1) {
|
||||
const letter = body[cursor] ?? "";
|
||||
const rest = body.slice(cursor + 1);
|
||||
if (letter === "c") return { kind: "other" };
|
||||
if (letter === "m") {
|
||||
const payload = rest.length > 0 ? [rest, ...args.slice(index + 1)] : args.slice(index + 1);
|
||||
if (!payload[0]) return { kind: "other" };
|
||||
return { kind: "module", payload };
|
||||
}
|
||||
if (letter === "w") {
|
||||
if (rest.length === 0) index += 1;
|
||||
break;
|
||||
}
|
||||
if (!PYTHON_NO_ARG.has(letter)) return { kind: "other" };
|
||||
}
|
||||
}
|
||||
return { kind: "other" };
|
||||
}
|
||||
|
||||
function pythonTargetsKill(args: string[], command: string): boolean {
|
||||
const invocation = pythonInvocation(args);
|
||||
switch (invocation.kind) {
|
||||
case "module":
|
||||
return modulePayloadTargetsKill(invocation.payload);
|
||||
case "stdin":
|
||||
return SCRIPT_LIFECYCLE_KILL.test(command);
|
||||
case "other":
|
||||
return false;
|
||||
default: {
|
||||
const unreachable: never = invocation;
|
||||
return unreachable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True when `node` reads the program from stdin (`-`), not from `-e` / `--eval`. */
|
||||
function nodeReadsProgramStdin(args: string[]): boolean {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const word = args[index] ?? "";
|
||||
if (word === "--") return args[index + 1] === "-";
|
||||
if (word === "-") return true;
|
||||
if (!word.startsWith("-")) return false;
|
||||
const name = flagName(word);
|
||||
if (
|
||||
NODE_INLINE_FLAGS.has(name) ||
|
||||
word.startsWith("--eval=") ||
|
||||
word.startsWith("--print=") ||
|
||||
/^-[ep]/.test(word)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!word.includes("=") && NODE_VALUE_FLAGS.has(name)) index += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function perlOrRubyReadsProgramStdin(args: string[], valueLetters: ReadonlySet<string>): boolean {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const word = args[index] ?? "";
|
||||
if (word === "--") return args[index + 1] === "-";
|
||||
if (word === "-") return true;
|
||||
if (!word.startsWith("-")) return false;
|
||||
if (word.startsWith("--")) continue;
|
||||
const body = word.slice(1);
|
||||
for (let cursor = 0; cursor < body.length; cursor += 1) {
|
||||
const letter = body[cursor] ?? "";
|
||||
const rest = body.slice(cursor + 1);
|
||||
if (letter === "e") return false;
|
||||
if (!valueLetters.has(letter)) continue;
|
||||
if (rest.length === 0) {
|
||||
const next = args[index + 1] ?? "";
|
||||
if (next && next !== "-" && !next.startsWith("-")) index += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function nextPositional(
|
||||
args: string[],
|
||||
start: number,
|
||||
separateValue: ReadonlySet<string>,
|
||||
): { word: string; next: number } | undefined {
|
||||
let index = start;
|
||||
while (index < args.length) {
|
||||
const word = args[index] ?? "";
|
||||
if (word === "--") {
|
||||
const operand = args[index + 1];
|
||||
return operand === undefined ? undefined : { word: operand, next: index + 2 };
|
||||
}
|
||||
if (word !== "-" && word.startsWith("-")) {
|
||||
const name = flagName(word);
|
||||
index += !word.includes("=") && separateValue.has(name) ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
return { word, next: index + 1 };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function denoEvalProgram(args: string[]): string | undefined {
|
||||
const first = nextPositional(args, 0, DENO_VALUE_FLAGS);
|
||||
if (first?.word !== "eval") return undefined;
|
||||
const program = nextPositional(args, first.next, DENO_VALUE_FLAGS);
|
||||
if (!program || program.word === "-") return undefined;
|
||||
return program.word;
|
||||
}
|
||||
|
||||
function denoReadsProgramStdin(args: string[]): boolean {
|
||||
const first = nextPositional(args, 0, DENO_VALUE_FLAGS);
|
||||
if (!first || DENO_NON_EXEC.has(first.word)) return false;
|
||||
if (first.word === "eval" || DENO_RUNNERS.has(first.word)) {
|
||||
return nextPositional(args, first.next, DENO_VALUE_FLAGS)?.word === "-";
|
||||
}
|
||||
return first.word === "-";
|
||||
}
|
||||
|
||||
function looksLikeScriptPath(path: string): boolean {
|
||||
return (
|
||||
!path.startsWith("-") && (path.includes("/") || /\.(?:[cm]?js|jsx|tsx?|mts|cts)$/.test(path))
|
||||
);
|
||||
}
|
||||
|
||||
function denoBunKillFamilyScriptStem(path: string): boolean {
|
||||
if (!looksLikeScriptPath(path)) return false;
|
||||
const base = path.split(/[/\\]/).at(-1) ?? "";
|
||||
const dot = base.lastIndexOf(".");
|
||||
const stem = (dot > 0 ? base.slice(0, dot) : base).toLowerCase();
|
||||
return KILL_MODULE_PART.test(stem);
|
||||
}
|
||||
|
||||
function denoKillScript(args: string[]): boolean {
|
||||
const first = nextPositional(args, 0, DENO_VALUE_FLAGS);
|
||||
if (!first || DENO_NON_EXEC.has(first.word) || first.word === "eval") return false;
|
||||
const operand = DENO_RUNNERS.has(first.word)
|
||||
? nextPositional(args, first.next, DENO_VALUE_FLAGS)?.word
|
||||
: first.word;
|
||||
return operand !== undefined && denoBunKillFamilyScriptStem(operand);
|
||||
}
|
||||
|
||||
function denoTargetsKill(args: string[], command: string): boolean {
|
||||
const program = denoEvalProgram(args);
|
||||
if (program !== undefined) return SCRIPT_LIFECYCLE_KILL.test(program);
|
||||
if (denoReadsProgramStdin(args)) return SCRIPT_LIFECYCLE_KILL.test(command);
|
||||
return denoKillScript(args);
|
||||
}
|
||||
|
||||
function bunInlineProgram(
|
||||
args: string[],
|
||||
): { present: true; program: string | undefined } | { present: false } {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const word = args[index] ?? "";
|
||||
if (word === "-" || word === "--" || !word.startsWith("-")) return { present: false };
|
||||
if (word.startsWith("--eval=")) return { present: true, program: word.slice("--eval=".length) };
|
||||
if (word.startsWith("--print="))
|
||||
return { present: true, program: word.slice("--print=".length) };
|
||||
if (word === "--eval" || word === "-e" || word === "--print" || word === "-p") {
|
||||
return { present: true, program: args[index + 1] };
|
||||
}
|
||||
const glued = /^-([ep])(.+)$/.exec(word);
|
||||
if (glued?.[2]) return { present: true, program: glued[2] };
|
||||
const name = flagName(word);
|
||||
if (!word.includes("=") && BUN_VALUE_FLAGS.has(name)) index += 1;
|
||||
}
|
||||
return { present: false };
|
||||
}
|
||||
|
||||
function bunOperand(args: string[]): string | undefined {
|
||||
const first = nextPositional(args, 0, BUN_VALUE_FLAGS);
|
||||
if (!first || BUN_NON_EXEC.has(first.word)) return undefined;
|
||||
if (BUN_RUNNERS.has(first.word)) return nextPositional(args, first.next, BUN_VALUE_FLAGS)?.word;
|
||||
return first.word;
|
||||
}
|
||||
|
||||
function bunTargetsKill(args: string[], command: string): boolean {
|
||||
const inline = bunInlineProgram(args);
|
||||
if (inline.present) return inline.program !== undefined && SCRIPT_LIFECYCLE_KILL.test(inline.program);
|
||||
const operand = bunOperand(args);
|
||||
if (operand === "-") return SCRIPT_LIFECYCLE_KILL.test(command);
|
||||
return operand !== undefined && denoBunKillFamilyScriptStem(operand);
|
||||
}
|
||||
|
||||
function interpreterArgs(
|
||||
simple: ProtectedShellCommand,
|
||||
): { name: string; args: string[] } | undefined {
|
||||
if (simple.commandAt === null) return undefined;
|
||||
const name = simple.argv[simple.commandAt]?.split("/").at(-1) ?? "";
|
||||
return { name, args: simple.argv.slice(simple.commandAt + 1) };
|
||||
}
|
||||
|
||||
function exactCommandWordCount(words: readonly string[], name: string): number {
|
||||
return words.filter((word) => word.split("/").at(-1) === name).length;
|
||||
}
|
||||
|
||||
/** `deno eval` is Deno's subcommand. Other `eval` words are the shell builtin. */
|
||||
function denoEvalSubcommandCount(commands: readonly ProtectedShellCommand[]): number {
|
||||
let count = 0;
|
||||
for (const simple of commands) {
|
||||
const invocation = interpreterArgs(simple);
|
||||
if (invocation?.name !== "deno") continue;
|
||||
if (nextPositional(invocation.args, 0, DENO_VALUE_FLAGS)?.word === "eval") count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Residual kill-family payloads: `python -m`, stdin programs embedded in the
|
||||
* same shell line, and deno/bun eval or kill-named script paths. Inline
|
||||
* `-c`/`-e` for python/node/perl/ruby/php is handled separately.
|
||||
*/
|
||||
function interpreterTargetsKill(
|
||||
commands: readonly ProtectedShellCommand[],
|
||||
command: string,
|
||||
): boolean {
|
||||
for (const simple of commands) {
|
||||
const invocation = interpreterArgs(simple);
|
||||
if (!invocation) continue;
|
||||
const { name, args } = invocation;
|
||||
if (PYTHON_COMMAND.test(name) && pythonTargetsKill(args, command)) return true;
|
||||
if (NODE_COMMAND.test(name) && nodeReadsProgramStdin(args) && SCRIPT_LIFECYCLE_KILL.test(command)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
name === "perl" &&
|
||||
perlOrRubyReadsProgramStdin(args, PERL_STDIN_VALUE_LETTERS) &&
|
||||
SCRIPT_LIFECYCLE_KILL.test(command)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
name === "ruby" &&
|
||||
perlOrRubyReadsProgramStdin(args, RUBY_STDIN_VALUE_LETTERS) &&
|
||||
SCRIPT_LIFECYCLE_KILL.test(command)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (name === "deno" && denoTargetsKill(args, command)) return true;
|
||||
if (name === "bun" && bunTargetsKill(args, command)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function preserveShellCommandBoundaries(command: string): string {
|
||||
let quote: "'" | '"' | undefined;
|
||||
let result = "";
|
||||
@ -521,6 +883,8 @@ interface ProtectedShellTokens {
|
||||
original: string[];
|
||||
/** Indexes of command names after prefixes and assignments. */
|
||||
commandIndexes: number[];
|
||||
/** One simple command per shell operator boundary. */
|
||||
commands: ProtectedShellCommand[];
|
||||
}
|
||||
|
||||
function tokenizeProtectedShellCommand(command: string): ProtectedShellTokens | "dynamic" {
|
||||
@ -537,6 +901,8 @@ function tokenizeProtectedShellCommand(command: string): ProtectedShellTokens |
|
||||
const words: string[] = [];
|
||||
const original: string[] = [];
|
||||
const commandIndexes: number[] = [];
|
||||
const commands: ProtectedShellCommand[] = [];
|
||||
let current: ProtectedShellCommand = { argv: [], commandAt: null };
|
||||
let commandPosition = true;
|
||||
let redirectTarget = false;
|
||||
for (const [index, entry] of parsed.entries()) {
|
||||
@ -554,6 +920,7 @@ function tokenizeProtectedShellCommand(command: string): ProtectedShellTokens |
|
||||
redirectTarget = false;
|
||||
continue;
|
||||
}
|
||||
current.argv.push(word);
|
||||
const next = parsed[index + 1];
|
||||
if (
|
||||
commandPosition &&
|
||||
@ -574,7 +941,10 @@ function tokenizeProtectedShellCommand(command: string): ProtectedShellTokens |
|
||||
) {
|
||||
// Shell prefixes and assignments leave the command word pending.
|
||||
} else {
|
||||
if (commandPosition) commandIndexes.push(words.length - 1);
|
||||
if (commandPosition) {
|
||||
commandIndexes.push(words.length - 1);
|
||||
current.commandAt = current.argv.length - 1;
|
||||
}
|
||||
commandPosition = false;
|
||||
}
|
||||
continue;
|
||||
@ -585,12 +955,16 @@ function tokenizeProtectedShellCommand(command: string): ProtectedShellTokens |
|
||||
return "dynamic";
|
||||
}
|
||||
if ("op" in entry && entry.op === "glob") {
|
||||
words.push(entry.pattern.toLowerCase());
|
||||
const pattern = entry.pattern.toLowerCase();
|
||||
words.push(pattern);
|
||||
original.push(entry.pattern);
|
||||
current.argv.push(pattern);
|
||||
continue;
|
||||
}
|
||||
if ("op" in entry && SAFE_SHELL_CONTROL_OPS.has(entry.op)) {
|
||||
if (["&&", "||", ";", "|", "&"].includes(entry.op)) {
|
||||
commands.push(current);
|
||||
current = { argv: [], commandAt: null };
|
||||
commandPosition = true;
|
||||
redirectTarget = false;
|
||||
} else redirectTarget = true;
|
||||
@ -598,7 +972,8 @@ function tokenizeProtectedShellCommand(command: string): ProtectedShellTokens |
|
||||
}
|
||||
return "dynamic";
|
||||
}
|
||||
return { words, original, commandIndexes };
|
||||
commands.push(current);
|
||||
return { words, original, commandIndexes, commands };
|
||||
} catch {
|
||||
return "dynamic";
|
||||
}
|
||||
@ -610,7 +985,7 @@ export function isProtectedComputerLifecycleCommand(
|
||||
): boolean {
|
||||
const tokens = tokenizeProtectedShellCommand(command);
|
||||
if (tokens === "dynamic") return true;
|
||||
const { words } = tokens;
|
||||
const { words, commands } = tokens;
|
||||
|
||||
const commandNames = words.map((word) => word.split("/").at(-1));
|
||||
if (commandNames.some((word) => /^(?:kill|pkill|killall|xkill)$/.test(word ?? ""))) {
|
||||
@ -618,7 +993,12 @@ export function isProtectedComputerLifecycleCommand(
|
||||
}
|
||||
// eval/source/. can hide protected commands inside an expansion string that the
|
||||
// outer tokenizer keeps as a single word (e.g. eval "pkill chromium").
|
||||
if (words.includes(".") || commandNames.some((word) => /^(?:eval|source)$/.test(word ?? ""))) {
|
||||
// A `deno eval` subcommand is not that builtin; extra `eval` words still are.
|
||||
if (
|
||||
words.includes(".") ||
|
||||
commandNames.some((word) => word === "source") ||
|
||||
exactCommandWordCount(words, "eval") > denoEvalSubcommandCount(commands)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
@ -649,7 +1029,7 @@ export function isProtectedComputerLifecycleCommand(
|
||||
for (const scriptPath of executedScriptPaths(tokens)) {
|
||||
if (scriptTargetsProtectedLifecycle(scriptPath, readScript)) return true;
|
||||
}
|
||||
return false;
|
||||
return interpreterTargetsKill(commands, command);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user