mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge main into feature
This commit is contained in:
commit
e25146a2d2
10 changed files with 288 additions and 158 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ function verifyNoMaliciousPackages() {
|
|||
}
|
||||
|
||||
ui.emptyLine();
|
||||
ui.writeError("Exiting without installing malicious packages.");
|
||||
ui.writeExitWithoutInstallingMaliciousPackages();
|
||||
ui.emptyLine();
|
||||
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue