Merge branch 'main' into verbose-logging

This commit is contained in:
Sander Declerck 2025-10-27 15:11:53 +01:00
commit 0b393eeb5f
No known key found for this signature in database
10 changed files with 288 additions and 160 deletions

View file

@ -41,6 +41,7 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps:
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, or `bunx` 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
```
@ -75,17 +76,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

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,14 +1,14 @@
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";
export const MALWARE_ACTION_PROMPT = "prompt";
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,11 +36,29 @@ 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 writeVerboseInformation(message, ...optionalParams) {
// TODO: Correctly implement verbose logging
writeInformation(message, ...optionalParams);
}
function startProcess(message) {
if (isSilentMode()) {
return {
succeed: () => {},
fail: () => {},
stop: () => {},
setText: () => {},
};
}
if (isCi()) {
return {
succeed: (message) => {
@ -63,40 +91,12 @@ 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,
writeVerboseInformation,
writeWarning,
writeError,
writeExitWithoutInstallingMaliciousPackages,
emptyLine,
startProcess,
confirm,
};

View file

@ -154,7 +154,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,4 +102,113 @@ 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");
});
});