diff --git a/README.md b/README.md index c18be58..176a6b0 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, or `pip3` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. If any malware is detected, it will prompt you to exit the command. You can check the installed version by running: + ```shell safe-chain --version ``` @@ -77,17 +78,16 @@ To uninstall the Aikido Safe Chain, you can run the following command: # Configuration -## Malware Action +## Logging -You can control how Aikido Safe Chain responds when malware is detected using the `--safe-chain-malware-action` flag: +You can control the output from Aikido Safe Chain using the `--safe-chain-logging` flag: -- `--safe-chain-malware-action=block` (**default**) - Automatically blocks installation and exits with an error when malware is detected -- `--safe-chain-malware-action=prompt` - Prompts the user to decide whether to continue despite the malware detection +- `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit. Example usage: ```shell -npm install suspicious-package --safe-chain-malware-action=prompt +npm install express --safe-chain-logging=silent ``` # Usage in CI/CD diff --git a/packages/safe-chain/src/config/cliArguments.js b/packages/safe-chain/src/config/cliArguments.js index 87abb7b..f234bbb 100644 --- a/packages/safe-chain/src/config/cliArguments.js +++ b/packages/safe-chain/src/config/cliArguments.js @@ -1,12 +1,12 @@ const state = { - malwareAction: undefined, + loggingLevel: undefined, }; const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-"; export function initializeCliArguments(args) { // Reset state on each call - state.malwareAction = undefined; + state.loggingLevel = undefined; const safeChainArgs = []; const remainingArgs = []; @@ -19,21 +19,11 @@ export function initializeCliArguments(args) { } } - setMalwareAction(safeChainArgs); + setLoggingLevel(safeChainArgs); return remainingArgs; } -function setMalwareAction(args) { - const safeChainMalwareActionArg = SAFE_CHAIN_ARG_PREFIX + "malware-action="; - - const action = getLastArgEqualsValue(args, safeChainMalwareActionArg); - if (!action) { - return; - } - state.malwareAction = action.toLowerCase(); -} - function getLastArgEqualsValue(args, prefix) { for (var i = args.length - 1; i >= 0; i--) { const arg = args[i]; @@ -45,6 +35,16 @@ function getLastArgEqualsValue(args, prefix) { return undefined; } -export function getMalwareAction() { - return state.malwareAction; +function setLoggingLevel(args) { + const safeChainLoggingArg = SAFE_CHAIN_ARG_PREFIX + "logging="; + + const level = getLastArgEqualsValue(args, safeChainLoggingArg); + if (!level) { + return; + } + state.loggingLevel = level.toLowerCase(); +} + +export function getLoggingLevel() { + return state.loggingLevel; } diff --git a/packages/safe-chain/src/config/cliArguments.spec.js b/packages/safe-chain/src/config/cliArguments.spec.js index 9d5c0ba..415d34a 100644 --- a/packages/safe-chain/src/config/cliArguments.spec.js +++ b/packages/safe-chain/src/config/cliArguments.spec.js @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert"; -import { initializeCliArguments, getMalwareAction } from "./cliArguments.js"; +import { initializeCliArguments, getLoggingLevel } from "./cliArguments.js"; describe("initializeCliArguments", () => { it("should return all args when no safe-chain args are present", () => { @@ -57,52 +57,65 @@ describe("initializeCliArguments", () => { assert.deepEqual(result, ["install", "my--safe-chain-package", "--save"]); }); - it("should not set malwareAction when no safe-chain arguments are passed", () => { + it("should not set loggingLevel when no logging argument is passed", () => { const args = ["install", "express", "--save"]; - const result = initializeCliArguments(args); + initializeCliArguments(args); - assert.deepEqual(result, ["install", "express", "--save"]); - assert.strictEqual(getMalwareAction(), undefined); + assert.strictEqual(getLoggingLevel(), undefined); }); - it("should parse malware-action=block and set state", () => { - const args = ["--safe-chain-malware-action=block", "install", "package"]; + it("should parse logging=silent and set state", () => { + const args = ["--safe-chain-logging=silent", "install", "package"]; const result = initializeCliArguments(args); assert.deepEqual(result, ["install", "package"]); - assert.strictEqual(getMalwareAction(), "block"); + assert.strictEqual(getLoggingLevel(), "silent"); }); - it("should parse malware-action=prompt and set state", () => { - const args = ["--safe-chain-malware-action=prompt", "install", "package"]; + it("should parse logging=normal and set state", () => { + const args = ["--safe-chain-logging=normal", "install", "package"]; const result = initializeCliArguments(args); assert.deepEqual(result, ["install", "package"]); - assert.strictEqual(getMalwareAction(), "prompt"); + assert.strictEqual(getLoggingLevel(), "normal"); }); - it("should handle multiple malware-action args, using the last valid one", () => { + it("should handle multiple logging args, using the last one", () => { const args = [ - "--safe-chain-malware-action=block", - "--safe-chain-malware-action=prompt", + "--safe-chain-logging=normal", + "--safe-chain-logging=silent", "install", ]; const result = initializeCliArguments(args); assert.deepEqual(result, ["install"]); - assert.strictEqual(getMalwareAction(), "prompt"); + assert.strictEqual(getLoggingLevel(), "silent"); }); - it("should handle malware-action with other safe-chain args", () => { + it("should handle logging level case-insensitively", () => { + const args = ["--safe-chain-logging=SILENT", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getLoggingLevel(), "silent"); + }); + + it("should capture invalid logging level as-is (lowercased)", () => { + const args = ["--safe-chain-logging=invalid", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getLoggingLevel(), "invalid"); + }); + + it("should handle logging with other safe-chain args", () => { const args = [ "--safe-chain-debug", + "--safe-chain-logging=silent", "--safe-chain-malware-action=block", - "--safe-chain-verbose", "install", ]; const result = initializeCliArguments(args); assert.deepEqual(result, ["install"]); - assert.strictEqual(getMalwareAction(), "block"); + assert.strictEqual(getLoggingLevel(), "silent"); }); }); diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 9690ae8..7686b67 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -1,13 +1,13 @@ import * as cliArguments from "./cliArguments.js"; -export function getMalwareAction() { - const action = cliArguments.getMalwareAction(); +export function getLoggingLevel() { + const level = cliArguments.getLoggingLevel(); - if (action === MALWARE_ACTION_PROMPT) { - return MALWARE_ACTION_PROMPT; + if (level === LOGGING_SILENT) { + return LOGGING_SILENT; } - return MALWARE_ACTION_BLOCK; + return LOGGING_NORMAL; } export const MALWARE_ACTION_BLOCK = "block"; @@ -27,3 +27,6 @@ export function getEcoSystem() { export function setEcoSystem(setting) { ecosystemSettings.ecoSystem = setting; } + +export const LOGGING_SILENT = "silent"; +export const LOGGING_NORMAL = "normal"; diff --git a/packages/safe-chain/src/environment/userInteraction.js b/packages/safe-chain/src/environment/userInteraction.js index 829afa1..e1a4f93 100644 --- a/packages/safe-chain/src/environment/userInteraction.js +++ b/packages/safe-chain/src/environment/userInteraction.js @@ -1,18 +1,28 @@ // oxlint-disable no-console import chalk from "chalk"; import ora from "ora"; -import { createInterface } from "readline"; import { isCi } from "./environment.js"; +import { getLoggingLevel, LOGGING_SILENT } from "../config/settings.js"; + +function isSilentMode() { + return getLoggingLevel() === LOGGING_SILENT; +} function emptyLine() { + if (isSilentMode()) return; + writeInformation(""); } function writeInformation(message, ...optionalParams) { + if (isSilentMode()) return; + console.log(message, ...optionalParams); } function writeWarning(message, ...optionalParams) { + if (isSilentMode()) return; + if (!isCi()) { message = chalk.yellow(message); } @@ -26,7 +36,24 @@ function writeError(message, ...optionalParams) { console.error(message, ...optionalParams); } +function writeExitWithoutInstallingMaliciousPackages() { + let message = "Safe-chain: Exiting without installing malicious packages."; + if (!isCi()) { + message = chalk.red(message); + } + console.error(message); +} + function startProcess(message) { + if (isSilentMode()) { + return { + succeed: () => {}, + fail: () => {}, + stop: () => {}, + setText: () => {}, + }; + } + if (isCi()) { return { succeed: (message) => { @@ -59,39 +86,11 @@ function startProcess(message) { } } -async function confirm(config) { - if (isCi()) { - return Promise.resolve(config.default); - } - - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - const defaultText = config.default ? " (Y/n)" : " (y/N)"; - rl.question(`${config.message}${defaultText} `, (answer) => { - rl.close(); - - const normalizedAnswer = answer.trim().toLowerCase(); - - if (normalizedAnswer === "y" || normalizedAnswer === "yes") { - resolve(true); - } else if (normalizedAnswer === "n" || normalizedAnswer === "no") { - resolve(false); - } else { - resolve(config.default); - } - }); - }); -} - export const ui = { writeInformation, writeWarning, writeError, + writeExitWithoutInstallingMaliciousPackages, emptyLine, startProcess, - confirm, }; diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index f7ef83b..636878f 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -170,7 +170,7 @@ function verifyNoMaliciousPackages() { } ui.emptyLine(); - ui.writeError("Exiting without installing malicious packages."); + ui.writeExitWithoutInstallingMaliciousPackages(); ui.emptyLine(); return false; diff --git a/packages/safe-chain/src/scanning/index.js b/packages/safe-chain/src/scanning/index.js index 36f62ca..969c994 100644 --- a/packages/safe-chain/src/scanning/index.js +++ b/packages/safe-chain/src/scanning/index.js @@ -4,7 +4,6 @@ import { setTimeout } from "timers/promises"; import chalk from "chalk"; import { getPackageManager } from "../packagemanager/currentPackageManager.js"; import { ui } from "../environment/userInteraction.js"; -import { getMalwareAction, MALWARE_ACTION_PROMPT } from "../config/settings.js"; export function shouldScanCommand(args) { if (!args || args.length === 0) { @@ -65,7 +64,8 @@ export async function scanCommand(args) { return 0; } else { printMaliciousChanges(audit.disallowedChanges, spinner); - return await onMalwareFound(); + onMalwareFound(); + return 1; } } @@ -77,23 +77,8 @@ function printMaliciousChanges(changes, spinner) { } } -async function onMalwareFound() { +function onMalwareFound() { ui.emptyLine(); - - if (getMalwareAction() === MALWARE_ACTION_PROMPT) { - const continueInstall = await ui.confirm({ - message: - "Malicious packages were found. Do you want to continue with the installation?", - default: false, - }); - - if (continueInstall) { - ui.writeWarning("Continuing with the installation despite the risks..."); - return 0; - } - } - - ui.writeError("Exiting without installing malicious packages."); + ui.writeExitWithoutInstallingMaliciousPackages(); ui.emptyLine(); - return 1; } diff --git a/packages/safe-chain/src/scanning/index.scanCommand.spec.js b/packages/safe-chain/src/scanning/index.scanCommand.spec.js index 1858d10..c47555f 100644 --- a/packages/safe-chain/src/scanning/index.scanCommand.spec.js +++ b/packages/safe-chain/src/scanning/index.scanCommand.spec.js @@ -1,10 +1,6 @@ import assert from "node:assert/strict"; -import { beforeEach, describe, it, mock } from "node:test"; +import { describe, it, mock } from "node:test"; import { setTimeout } from "node:timers/promises"; -import { - MALWARE_ACTION_PROMPT, - MALWARE_ACTION_BLOCK, -} from "../config/settings.js"; describe("scanCommand", async () => { const getScanTimeoutMock = mock.fn(() => 1000); @@ -15,8 +11,6 @@ describe("scanCommand", async () => { fail: () => {}, stop: () => {}, })); - const mockConfirm = mock.fn(() => true); - let malwareAction = MALWARE_ACTION_PROMPT; // import { getPackageManager } from "../packagemanager/currentPackageManager.js"; mock.module("../packagemanager/currentPackageManager.js", { @@ -46,20 +40,12 @@ describe("scanCommand", async () => { writeError: () => {}, writeInformation: () => {}, writeWarning: () => {}, + writeExitWithoutInstallingMaliciousPackages: () => {}, emptyLine: () => {}, - confirm: mockConfirm, }, }, }); - mock.module("../config/settings.js", { - namedExports: { - getMalwareAction: () => malwareAction, - MALWARE_ACTION_PROMPT, - MALWARE_ACTION_BLOCK, - }, - }); - // import { auditChanges, MAX_LENGTH_EXCEEDED } from "./audit/index.js"; mock.module("./audit/index.js", { namedExports: { @@ -88,11 +74,6 @@ describe("scanCommand", async () => { const { scanCommand } = await import("./index.js"); - beforeEach(() => { - // Reset malware action back to prompt mode for other tests - malwareAction = MALWARE_ACTION_PROMPT; - }); - it("should succeed when there are no changes", async () => { let progressWasStopped = false; mockStartProcess.mock.mockImplementationOnce(() => ({ @@ -150,7 +131,7 @@ describe("scanCommand", async () => { assert.equal(failureMessageWasSet, true); }); - it("should fail and prompt the user when malicious changes are detected", async () => { + it("should fail and return 1 malicious changes are detected", async () => { let failureMessageWasSet = false; mockStartProcess.mock.mockImplementationOnce(() => ({ setText: () => {}, @@ -163,16 +144,11 @@ describe("scanCommand", async () => { mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ { name: "malicious", version: "1.0.0" }, ]); - let userWasPrompted = false; - mockConfirm.mock.mockImplementationOnce(() => { - userWasPrompted = true; - return true; // Simulate user accepting the risk, otherwise the process would exit - }); - await scanCommand(["install", "malicious"]); + const result = await scanCommand(["install", "malicious"]); assert.equal(failureMessageWasSet, true); - assert.equal(userWasPrompted, true); + assert.equal(result, 1); }); it("should not report a timeout when the user takes a long time to respond (it should not affect the timeout)", async () => { @@ -189,10 +165,6 @@ describe("scanCommand", async () => { mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => { return [{ name: "malicious", version: "4.17.21" }]; }); - mockConfirm.mock.mockImplementationOnce(async () => { - await setTimeout(200); - return true; // Simulate user accepting the risk, otherwise the process would exit - }); await scanCommand(["install", "malicious"]); @@ -203,12 +175,6 @@ describe("scanCommand", async () => { }); it("should exit immediately when malicious changes are detected in block mode", async () => { - // Set malware action to block mode for this test - malwareAction = MALWARE_ACTION_BLOCK; - - // Reset mock call count - mockConfirm.mock.resetCalls(); - let failureMessageWasSet = false; mockStartProcess.mock.mockImplementationOnce(() => ({ @@ -228,19 +194,9 @@ describe("scanCommand", async () => { assert.equal(failureMessageWasSet, true); assert.equal(result, 1); - // Confirm should not have been called in block mode - assert.equal(mockConfirm.mock.callCount(), 0); }); it("should exit immediately when malicious changes are detected in block mode without prompting", async () => { - // Set malware action to block mode for this test - malwareAction = MALWARE_ACTION_BLOCK; - - // Reset mock call count - mockConfirm.mock.resetCalls(); - - let userWasPrompted = false; - mockStartProcess.mock.mockImplementationOnce(() => ({ setText: () => {}, succeed: () => {}, @@ -252,14 +208,8 @@ describe("scanCommand", async () => { { name: "malicious", version: "1.0.0" }, ]); - mockConfirm.mock.mockImplementationOnce(() => { - userWasPrompted = true; - return false; - }); - const result = await scanCommand(["install", "malicious"]); assert.equal(result, 1); - assert.equal(userWasPrompted, false); }); }); diff --git a/packages/safe-chain/src/utils/safeSpawn.js b/packages/safe-chain/src/utils/safeSpawn.js index 1fcaef8..b88a3b1 100644 --- a/packages/safe-chain/src/utils/safeSpawn.js +++ b/packages/safe-chain/src/utils/safeSpawn.js @@ -1,22 +1,77 @@ -import { spawn } from "child_process"; +import { spawn, execSync } from "child_process"; +import os from "os"; -function escapeArg(arg) { - // If argument contains spaces or quotes, wrap in double quotes and escape double quotes - if (arg.includes(" ") || arg.includes('"') || arg.includes("'")) { - return '"' + arg.replaceAll('"', '\\"') + '"'; +function sanitizeShellArgument(arg) { + // If argument contains shell metacharacters, wrap in double quotes + // and escape characters that are special even inside double quotes + if (hasShellMetaChars(arg)) { + // Inside double quotes, we need to escape: " $ ` \ + return '"' + escapeDoubleQuoteContent(arg) + '"'; } return arg; } +function hasShellMetaChars(arg) { + // Shell metacharacters that need escaping + // These characters have special meaning in shells and need to be quoted + // Whenever one of these characters is present, we should quote the argument + // Characters: space, ", &, ', |, ;, <, >, (, ), $, `, \, !, *, ?, [, ], {, }, ~, # + const shellMetaChars = /[ "&'|;<>()$`\\!*?[\]{}~#]/; + return shellMetaChars.test(arg); +} + +function escapeDoubleQuoteContent(arg) { + // Escape special characters for shell safety + // This escapes ", $, `, and \ by prefixing them with a backslash + return arg.replace(/(["`$\\])/g, "\\$1"); +} + function buildCommand(command, args) { - const escapedArgs = args.map(escapeArg); + if (args.length === 0) { + return command; + } + + const escapedArgs = args.map(sanitizeShellArgument); + return `${command} ${escapedArgs.join(" ")}`; } +function resolveCommandPath(command) { + // command will be "npm", "yarn", etc. + // Use 'command -v' to find the full path + const fullPath = execSync(`command -v ${command}`, { + encoding: "utf8", + shell: true, + }).trim(); + + if (!fullPath) { + throw new Error(`Command not found: ${command}`); + } + + return fullPath; +} + export async function safeSpawn(command, args, options = {}) { - const fullCommand = buildCommand(command, args); + // The command is always one of our supported package managers. + // It should always be alphanumeric or _ or - + // Reject any command names with suspicious characters + if (!/^[a-zA-Z0-9_-]+$/.test(command)) { + throw new Error(`Invalid command name: ${command}`); + } + return new Promise((resolve, reject) => { - const child = spawn(fullCommand, { ...options, shell: true }); + // Windows requires shell: true because .bat and .cmd files are not executable + // without a terminal. On Unix/macOS, we resolve the full path first, then use + // array args (safer, no escaping needed). + // See: https://nodejs.org/api/child_process.html#child_processspawncommand-args-options + let child; + if (os.platform() === "win32") { + const fullCommand = buildCommand(command, args); + child = spawn(fullCommand, { ...options, shell: true }); + } else { + const fullPath = resolveCommandPath(command); + child = spawn(fullPath, args, options); + } // When stdio is piped, we need to collect the output let stdout = ""; diff --git a/packages/safe-chain/src/utils/safeSpawn.spec.js b/packages/safe-chain/src/utils/safeSpawn.spec.js index 847a009..a07907d 100644 --- a/packages/safe-chain/src/utils/safeSpawn.spec.js +++ b/packages/safe-chain/src/utils/safeSpawn.spec.js @@ -4,9 +4,11 @@ import assert from "node:assert"; describe("safeSpawn", () => { let safeSpawn; let spawnCalls = []; + let os; beforeEach(async () => { spawnCalls = []; + os = "win32"; // Test Windows behavior by default // Mock child_process module to capture what command string gets built mock.module("child_process", { @@ -15,13 +17,27 @@ describe("safeSpawn", () => { spawnCalls.push({ command, options }); return { on: (event, callback) => { - if (event === 'close') { + if (event === "close") { // Simulate immediate success setTimeout(() => callback(0), 0); } - } + }, }; }, + execSync: (cmd) => { + // Simulate 'command -v' returning full path + const match = cmd.match(/command -v (.+)/); + if (match) { + return `/usr/bin/${match[1]}\n`; + } + return ""; + }, + }, + }); + + mock.module("os", { + namedExports: { + platform: () => os, }, }); @@ -86,6 +102,115 @@ describe("safeSpawn", () => { assert.strictEqual(spawnCalls[0].command, "npm install axios --save"); assert.strictEqual(spawnCalls[0].options.shell, true); }); + + it(`should escape ampersand character`, async () => { + await safeSpawn("npx", ["cypress", "run", "--env", "password=foo&bar"]); + + assert.strictEqual(spawnCalls.length, 1); + // & should be escaped by wrapping the arg in quotes + assert.strictEqual( + spawnCalls[0].command, + 'npx cypress run --env "password=foo&bar"' + ); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); + + it("should escape dollar signs to prevent variable expansion", async () => { + await safeSpawn("echo", ["$HOME/test"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'echo "\\$HOME/test"'); + }); + + it("should escape backticks to prevent command substitution", async () => { + await safeSpawn("echo", ["file`whoami`.txt"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'echo "file\\`whoami\\`.txt"'); + }); + + it("should escape backslashes properly", async () => { + await safeSpawn("echo", ["path\\with\\backslash"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual( + spawnCalls[0].command, + 'echo "path\\\\with\\\\backslash"' + ); + }); + + it("should handle multiple special characters in one argument", async () => { + await safeSpawn("cmd", ['test "quoted" $var `cmd` & more']); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual( + spawnCalls[0].command, + 'cmd "test \\"quoted\\" \\$var \\`cmd\\` & more"' + ); + }); + + it("should handle pipe character", async () => { + await safeSpawn("echo", ["foo|bar"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'echo "foo|bar"'); + }); + + it("should handle parentheses", async () => { + await safeSpawn("echo", ["(test)"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'echo "(test)"'); + }); + + it("should handle angle brackets for redirection", async () => { + await safeSpawn("echo", ["foo>output.txt"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'echo "foo>output.txt"'); + }); + + it("should handle wildcard characters", async () => { + await safeSpawn("echo", ["*.txt"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'echo "*.txt"'); + }); + + it("should handle multiple arguments with mixed escaping needs", async () => { + await safeSpawn("cmd", ["safe", "needs space", "$dangerous", "also-safe"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual( + spawnCalls[0].command, + 'cmd safe "needs space" "\\$dangerous" also-safe' + ); + }); + + it("should reject command names with special characters", async () => { + await assert.rejects(async () => await safeSpawn("npm; echo hacked", []), { + message: "Invalid command name: npm; echo hacked", + }); + }); + + it("should reject command names with spaces", async () => { + await assert.rejects(async () => await safeSpawn("npm install", []), { + message: "Invalid command name: npm install", + }); + }); + + it("should reject command names with slashes", async () => { + await assert.rejects(async () => await safeSpawn("../../malicious", []), { + message: "Invalid command name: ../../malicious", + }); + }); + + it("should accept valid command names with letters, numbers, underscores and hyphens", async () => { + await safeSpawn("valid_command-123", []); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, "valid_command-123"); + }); }); describe("safeSpawnPy", () => {