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

@ -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;
}

View file

@ -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");
});
});

View file

@ -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";

View file

@ -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,
};

View file

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

View file

@ -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;
}

View file

@ -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);
});
});

View file

@ -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 = "";

View file

@ -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", () => {