Merge main into feature

This commit is contained in:
Reinier Criel 2025-10-27 09:27:51 -07:00
commit e25146a2d2
10 changed files with 288 additions and 158 deletions

View file

@ -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. 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: You can check the installed version by running:
```shell ```shell
safe-chain --version safe-chain --version
``` ```
@ -77,17 +78,16 @@ To uninstall the Aikido Safe Chain, you can run the following command:
# Configuration # 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-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.
- `--safe-chain-malware-action=prompt` - Prompts the user to decide whether to continue despite the malware detection
Example usage: Example usage:
```shell ```shell
npm install suspicious-package --safe-chain-malware-action=prompt npm install express --safe-chain-logging=silent
``` ```
# Usage in CI/CD # Usage in CI/CD

View file

@ -1,12 +1,12 @@
const state = { const state = {
malwareAction: undefined, loggingLevel: undefined,
}; };
const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-"; const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
export function initializeCliArguments(args) { export function initializeCliArguments(args) {
// Reset state on each call // Reset state on each call
state.malwareAction = undefined; state.loggingLevel = undefined;
const safeChainArgs = []; const safeChainArgs = [];
const remainingArgs = []; const remainingArgs = [];
@ -19,21 +19,11 @@ export function initializeCliArguments(args) {
} }
} }
setMalwareAction(safeChainArgs); setLoggingLevel(safeChainArgs);
return remainingArgs; 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) { function getLastArgEqualsValue(args, prefix) {
for (var i = args.length - 1; i >= 0; i--) { for (var i = args.length - 1; i >= 0; i--) {
const arg = args[i]; const arg = args[i];
@ -45,6 +35,16 @@ function getLastArgEqualsValue(args, prefix) {
return undefined; return undefined;
} }
export function getMalwareAction() { function setLoggingLevel(args) {
return state.malwareAction; 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;
} }

View file

@ -1,6 +1,6 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
import { initializeCliArguments, getMalwareAction } from "./cliArguments.js"; import { initializeCliArguments, getLoggingLevel } from "./cliArguments.js";
describe("initializeCliArguments", () => { describe("initializeCliArguments", () => {
it("should return all args when no safe-chain args are present", () => { 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"]); 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 args = ["install", "express", "--save"];
const result = initializeCliArguments(args); initializeCliArguments(args);
assert.deepEqual(result, ["install", "express", "--save"]); assert.strictEqual(getLoggingLevel(), undefined);
assert.strictEqual(getMalwareAction(), undefined);
}); });
it("should parse malware-action=block and set state", () => { it("should parse logging=silent and set state", () => {
const args = ["--safe-chain-malware-action=block", "install", "package"]; const args = ["--safe-chain-logging=silent", "install", "package"];
const result = initializeCliArguments(args); const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "package"]); assert.deepEqual(result, ["install", "package"]);
assert.strictEqual(getMalwareAction(), "block"); assert.strictEqual(getLoggingLevel(), "silent");
}); });
it("should parse malware-action=prompt and set state", () => { it("should parse logging=normal and set state", () => {
const args = ["--safe-chain-malware-action=prompt", "install", "package"]; const args = ["--safe-chain-logging=normal", "install", "package"];
const result = initializeCliArguments(args); const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "package"]); 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 = [ const args = [
"--safe-chain-malware-action=block", "--safe-chain-logging=normal",
"--safe-chain-malware-action=prompt", "--safe-chain-logging=silent",
"install", "install",
]; ];
const result = initializeCliArguments(args); const result = initializeCliArguments(args);
assert.deepEqual(result, ["install"]); 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 = [ const args = [
"--safe-chain-debug", "--safe-chain-debug",
"--safe-chain-logging=silent",
"--safe-chain-malware-action=block", "--safe-chain-malware-action=block",
"--safe-chain-verbose",
"install", "install",
]; ];
const result = initializeCliArguments(args); const result = initializeCliArguments(args);
assert.deepEqual(result, ["install"]); assert.deepEqual(result, ["install"]);
assert.strictEqual(getMalwareAction(), "block"); assert.strictEqual(getLoggingLevel(), "silent");
}); });
}); });

View file

@ -1,13 +1,13 @@
import * as cliArguments from "./cliArguments.js"; import * as cliArguments from "./cliArguments.js";
export function getMalwareAction() { export function getLoggingLevel() {
const action = cliArguments.getMalwareAction(); const level = cliArguments.getLoggingLevel();
if (action === MALWARE_ACTION_PROMPT) { if (level === LOGGING_SILENT) {
return MALWARE_ACTION_PROMPT; return LOGGING_SILENT;
} }
return MALWARE_ACTION_BLOCK; return LOGGING_NORMAL;
} }
export const MALWARE_ACTION_BLOCK = "block"; export const MALWARE_ACTION_BLOCK = "block";
@ -27,3 +27,6 @@ export function getEcoSystem() {
export function setEcoSystem(setting) { export function setEcoSystem(setting) {
ecosystemSettings.ecoSystem = setting; ecosystemSettings.ecoSystem = setting;
} }
export const LOGGING_SILENT = "silent";
export const LOGGING_NORMAL = "normal";

View file

@ -1,18 +1,28 @@
// oxlint-disable no-console // oxlint-disable no-console
import chalk from "chalk"; import chalk from "chalk";
import ora from "ora"; import ora from "ora";
import { createInterface } from "readline";
import { isCi } from "./environment.js"; import { isCi } from "./environment.js";
import { getLoggingLevel, LOGGING_SILENT } from "../config/settings.js";
function isSilentMode() {
return getLoggingLevel() === LOGGING_SILENT;
}
function emptyLine() { function emptyLine() {
if (isSilentMode()) return;
writeInformation(""); writeInformation("");
} }
function writeInformation(message, ...optionalParams) { function writeInformation(message, ...optionalParams) {
if (isSilentMode()) return;
console.log(message, ...optionalParams); console.log(message, ...optionalParams);
} }
function writeWarning(message, ...optionalParams) { function writeWarning(message, ...optionalParams) {
if (isSilentMode()) return;
if (!isCi()) { if (!isCi()) {
message = chalk.yellow(message); message = chalk.yellow(message);
} }
@ -26,7 +36,24 @@ function writeError(message, ...optionalParams) {
console.error(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) { function startProcess(message) {
if (isSilentMode()) {
return {
succeed: () => {},
fail: () => {},
stop: () => {},
setText: () => {},
};
}
if (isCi()) { if (isCi()) {
return { return {
succeed: (message) => { 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 = { export const ui = {
writeInformation, writeInformation,
writeWarning, writeWarning,
writeError, writeError,
writeExitWithoutInstallingMaliciousPackages,
emptyLine, emptyLine,
startProcess, startProcess,
confirm,
}; };

View file

@ -170,7 +170,7 @@ function verifyNoMaliciousPackages() {
} }
ui.emptyLine(); ui.emptyLine();
ui.writeError("Exiting without installing malicious packages."); ui.writeExitWithoutInstallingMaliciousPackages();
ui.emptyLine(); ui.emptyLine();
return false; return false;

View file

@ -4,7 +4,6 @@ import { setTimeout } from "timers/promises";
import chalk from "chalk"; import chalk from "chalk";
import { getPackageManager } from "../packagemanager/currentPackageManager.js"; import { getPackageManager } from "../packagemanager/currentPackageManager.js";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import { getMalwareAction, MALWARE_ACTION_PROMPT } from "../config/settings.js";
export function shouldScanCommand(args) { export function shouldScanCommand(args) {
if (!args || args.length === 0) { if (!args || args.length === 0) {
@ -65,7 +64,8 @@ export async function scanCommand(args) {
return 0; return 0;
} else { } else {
printMaliciousChanges(audit.disallowedChanges, spinner); 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(); ui.emptyLine();
ui.writeExitWithoutInstallingMaliciousPackages();
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.emptyLine(); ui.emptyLine();
return 1;
} }

View file

@ -1,10 +1,6 @@
import assert from "node:assert/strict"; 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 { setTimeout } from "node:timers/promises";
import {
MALWARE_ACTION_PROMPT,
MALWARE_ACTION_BLOCK,
} from "../config/settings.js";
describe("scanCommand", async () => { describe("scanCommand", async () => {
const getScanTimeoutMock = mock.fn(() => 1000); const getScanTimeoutMock = mock.fn(() => 1000);
@ -15,8 +11,6 @@ describe("scanCommand", async () => {
fail: () => {}, fail: () => {},
stop: () => {}, stop: () => {},
})); }));
const mockConfirm = mock.fn(() => true);
let malwareAction = MALWARE_ACTION_PROMPT;
// import { getPackageManager } from "../packagemanager/currentPackageManager.js"; // import { getPackageManager } from "../packagemanager/currentPackageManager.js";
mock.module("../packagemanager/currentPackageManager.js", { mock.module("../packagemanager/currentPackageManager.js", {
@ -46,20 +40,12 @@ describe("scanCommand", async () => {
writeError: () => {}, writeError: () => {},
writeInformation: () => {}, writeInformation: () => {},
writeWarning: () => {}, writeWarning: () => {},
writeExitWithoutInstallingMaliciousPackages: () => {},
emptyLine: () => {}, 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"; // import { auditChanges, MAX_LENGTH_EXCEEDED } from "./audit/index.js";
mock.module("./audit/index.js", { mock.module("./audit/index.js", {
namedExports: { namedExports: {
@ -88,11 +74,6 @@ describe("scanCommand", async () => {
const { scanCommand } = await import("./index.js"); 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 () => { it("should succeed when there are no changes", async () => {
let progressWasStopped = false; let progressWasStopped = false;
mockStartProcess.mock.mockImplementationOnce(() => ({ mockStartProcess.mock.mockImplementationOnce(() => ({
@ -150,7 +131,7 @@ describe("scanCommand", async () => {
assert.equal(failureMessageWasSet, true); 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; let failureMessageWasSet = false;
mockStartProcess.mock.mockImplementationOnce(() => ({ mockStartProcess.mock.mockImplementationOnce(() => ({
setText: () => {}, setText: () => {},
@ -163,16 +144,11 @@ describe("scanCommand", async () => {
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
{ name: "malicious", version: "1.0.0" }, { 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(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 () => { 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 () => { mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
return [{ name: "malicious", version: "4.17.21" }]; 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"]); await scanCommand(["install", "malicious"]);
@ -203,12 +175,6 @@ describe("scanCommand", async () => {
}); });
it("should exit immediately when malicious changes are detected in block mode", 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; let failureMessageWasSet = false;
mockStartProcess.mock.mockImplementationOnce(() => ({ mockStartProcess.mock.mockImplementationOnce(() => ({
@ -228,19 +194,9 @@ describe("scanCommand", async () => {
assert.equal(failureMessageWasSet, true); assert.equal(failureMessageWasSet, true);
assert.equal(result, 1); 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 () => { 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(() => ({ mockStartProcess.mock.mockImplementationOnce(() => ({
setText: () => {}, setText: () => {},
succeed: () => {}, succeed: () => {},
@ -252,14 +208,8 @@ describe("scanCommand", async () => {
{ name: "malicious", version: "1.0.0" }, { name: "malicious", version: "1.0.0" },
]); ]);
mockConfirm.mock.mockImplementationOnce(() => {
userWasPrompted = true;
return false;
});
const result = await scanCommand(["install", "malicious"]); const result = await scanCommand(["install", "malicious"]);
assert.equal(result, 1); assert.equal(result, 1);
assert.equal(userWasPrompted, false);
}); });
}); });

View file

@ -1,22 +1,77 @@
import { spawn } from "child_process"; import { spawn, execSync } from "child_process";
import os from "os";
function escapeArg(arg) { function sanitizeShellArgument(arg) {
// If argument contains spaces or quotes, wrap in double quotes and escape double quotes // If argument contains shell metacharacters, wrap in double quotes
if (arg.includes(" ") || arg.includes('"') || arg.includes("'")) { // and escape characters that are special even inside double quotes
return '"' + arg.replaceAll('"', '\\"') + '"'; if (hasShellMetaChars(arg)) {
// Inside double quotes, we need to escape: " $ ` \
return '"' + escapeDoubleQuoteContent(arg) + '"';
} }
return 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) { function buildCommand(command, args) {
const escapedArgs = args.map(escapeArg); if (args.length === 0) {
return command;
}
const escapedArgs = args.map(sanitizeShellArgument);
return `${command} ${escapedArgs.join(" ")}`; 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 = {}) { 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) => { 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 // When stdio is piped, we need to collect the output
let stdout = ""; let stdout = "";

View file

@ -4,9 +4,11 @@ import assert from "node:assert";
describe("safeSpawn", () => { describe("safeSpawn", () => {
let safeSpawn; let safeSpawn;
let spawnCalls = []; let spawnCalls = [];
let os;
beforeEach(async () => { beforeEach(async () => {
spawnCalls = []; spawnCalls = [];
os = "win32"; // Test Windows behavior by default
// Mock child_process module to capture what command string gets built // Mock child_process module to capture what command string gets built
mock.module("child_process", { mock.module("child_process", {
@ -15,13 +17,27 @@ describe("safeSpawn", () => {
spawnCalls.push({ command, options }); spawnCalls.push({ command, options });
return { return {
on: (event, callback) => { on: (event, callback) => {
if (event === 'close') { if (event === "close") {
// Simulate immediate success // Simulate immediate success
setTimeout(() => callback(0), 0); 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].command, "npm install axios --save");
assert.strictEqual(spawnCalls[0].options.shell, true); 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", () => { describe("safeSpawnPy", () => {