From 2f1692e253aa84ee50b74d760a04d27454cdb046 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 11 Sep 2025 13:42:45 +0200 Subject: [PATCH] Exit installation instead of prompting the user --- README.md | 15 +++ .../safe-chain/src/config/cliArguments.js | 33 ++++++ .../src/config/cliArguments.spec.js | 108 ++++++++++++++++++ packages/safe-chain/src/config/settings.js | 14 +++ packages/safe-chain/src/main.js | 4 + packages/safe-chain/src/scanning/index.js | 28 ++--- .../src/scanning/index.scanCommand.spec.js | 108 ++++++++++++++++++ 7 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 packages/safe-chain/src/config/cliArguments.js create mode 100644 packages/safe-chain/src/config/cliArguments.spec.js create mode 100644 packages/safe-chain/src/config/settings.js diff --git a/README.md b/README.md index ff5d698..b717352 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,21 @@ To uninstall the Aikido Safe Chain, you can run the following command: ``` 3. **❗Restart your terminal** to remove the aliases. +# Configuration + +## Malware Action + +You can control how Aikido Safe Chain responds when malware is detected using the `--safe-chain-malware-action` 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 + +Example usage: + +```shell +npm install suspicious-package --safe-chain-malware-action=prompt +``` + # Usage in CI/CD 🚧 Support for CI/CD environments is coming soon... diff --git a/packages/safe-chain/src/config/cliArguments.js b/packages/safe-chain/src/config/cliArguments.js new file mode 100644 index 0000000..2bf546b --- /dev/null +++ b/packages/safe-chain/src/config/cliArguments.js @@ -0,0 +1,33 @@ +const state = { + malwareAction: undefined, +}; + +const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-"; + +export function initializeCliArguments(args) { + // Reset state on each call + state.malwareAction = undefined; + + const safeChainArgs = []; + const remainingArgs = []; + + for (const arg of args) { + if (arg.startsWith(SAFE_CHAIN_ARG_PREFIX)) { + safeChainArgs.push(arg); + + if (arg.startsWith(SAFE_CHAIN_ARG_PREFIX + "malware-action=")) { + state.malwareAction = arg.substring( + (SAFE_CHAIN_ARG_PREFIX + "malware-action=").length + ); + } + } else { + remainingArgs.push(arg); + } + } + + return remainingArgs; +} + +export function getMalwareAction() { + return state.malwareAction; +} diff --git a/packages/safe-chain/src/config/cliArguments.spec.js b/packages/safe-chain/src/config/cliArguments.spec.js new file mode 100644 index 0000000..9d5c0ba --- /dev/null +++ b/packages/safe-chain/src/config/cliArguments.spec.js @@ -0,0 +1,108 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { initializeCliArguments, getMalwareAction } from "./cliArguments.js"; + +describe("initializeCliArguments", () => { + it("should return all args when no safe-chain args are present", () => { + const args = ["install", "express", "--save"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "express", "--save"]); + }); + + it("should filter out safe-chain args and return remaining args", () => { + const args = ["install", "--safe-chain-debug", "express", "--save"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "express", "--save"]); + }); + + it("should handle multiple safe-chain args", () => { + const args = [ + "--safe-chain-verbose", + "install", + "--safe-chain-timeout=5000", + "express", + ]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "express"]); + }); + + it("should handle empty args array", () => { + const args = []; + const result = initializeCliArguments(args); + + assert.deepEqual(result, []); + }); + + it("should handle args with only safe-chain arguments", () => { + const args = ["--safe-chain-debug", "--safe-chain-verbose"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, []); + }); + + it("should handle args that start with safe-chain prefix but have additional content", () => { + const args = ["--safe-chain-malware-action=block", "install", "package"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "package"]); + }); + + it("should handle args that contain safe-chain prefix but don't start with it", () => { + const args = ["install", "my--safe-chain-package", "--save"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "my--safe-chain-package", "--save"]); + }); + + it("should not set malwareAction when no safe-chain arguments are passed", () => { + const args = ["install", "express", "--save"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "express", "--save"]); + assert.strictEqual(getMalwareAction(), undefined); + }); + + it("should parse malware-action=block and set state", () => { + const args = ["--safe-chain-malware-action=block", "install", "package"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "package"]); + assert.strictEqual(getMalwareAction(), "block"); + }); + + it("should parse malware-action=prompt and set state", () => { + const args = ["--safe-chain-malware-action=prompt", "install", "package"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "package"]); + assert.strictEqual(getMalwareAction(), "prompt"); + }); + + it("should handle multiple malware-action args, using the last valid one", () => { + const args = [ + "--safe-chain-malware-action=block", + "--safe-chain-malware-action=prompt", + "install", + ]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install"]); + assert.strictEqual(getMalwareAction(), "prompt"); + }); + + it("should handle malware-action with other safe-chain args", () => { + const args = [ + "--safe-chain-debug", + "--safe-chain-malware-action=block", + "--safe-chain-verbose", + "install", + ]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install"]); + assert.strictEqual(getMalwareAction(), "block"); + }); +}); diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js new file mode 100644 index 0000000..53c8fb5 --- /dev/null +++ b/packages/safe-chain/src/config/settings.js @@ -0,0 +1,14 @@ +import * as cliArguments from "./cliArguments.js"; + +export function getMalwareAction() { + const action = cliArguments.getMalwareAction(); + + if (action === MALWARE_ACTION_PROMPT) { + return MALWARE_ACTION_PROMPT; + } + + return "block"; +} + +export const MALWARE_ACTION_BLOCK = "block"; +export const MALWARE_ACTION_PROMPT = "prompt"; diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 86f404a..501dd95 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -3,9 +3,13 @@ import { scanCommand, shouldScanCommand } from "./scanning/index.js"; import { ui } from "./environment/userInteraction.js"; import { getPackageManager } from "./packagemanager/currentPackageManager.js"; +import { initializeCliArguments } from "./config/cliArguments.js"; export async function main(args) { try { + // This parses all the --safe-chain arguments and removes them from the args array + args = initializeCliArguments(args); + if (shouldScanCommand(args)) { await scanCommand(args); } diff --git a/packages/safe-chain/src/scanning/index.js b/packages/safe-chain/src/scanning/index.js index 4e4bc49..2905bd6 100644 --- a/packages/safe-chain/src/scanning/index.js +++ b/packages/safe-chain/src/scanning/index.js @@ -4,6 +4,7 @@ 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) { @@ -59,10 +60,7 @@ export async function scanCommand(args) { spinner.succeed("No malicious packages detected."); } else { printMaliciousChanges(audit.disallowedChanges, spinner); - await acceptRiskOrExit( - "Do you want to continue with the installation despite the risks?", - false - ); + await onMalwareFound(); } } @@ -74,19 +72,23 @@ function printMaliciousChanges(changes, spinner) { } } -async function acceptRiskOrExit(message, defaultValue) { +async function onMalwareFound() { ui.emptyLine(); - const continueInstall = await ui.confirm({ - message: message, - default: defaultValue, - }); - if (continueInstall) { - ui.writeInformation("Continuing with the installation..."); - return; + 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; + } } - ui.writeInformation("Exiting without installing packages."); + ui.writeError("Exiting without installing malicious packages."); ui.emptyLine(); process.exit(1); } diff --git a/packages/safe-chain/src/scanning/index.scanCommand.spec.js b/packages/safe-chain/src/scanning/index.scanCommand.spec.js index 6c2b34b..715ecfb 100644 --- a/packages/safe-chain/src/scanning/index.scanCommand.spec.js +++ b/packages/safe-chain/src/scanning/index.scanCommand.spec.js @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; 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); @@ -11,6 +15,7 @@ describe("scanCommand", async () => { fail: () => {}, })); const mockConfirm = mock.fn(() => true); + let malwareAction = MALWARE_ACTION_PROMPT; // import { getPackageManager } from "../packagemanager/currentPackageManager.js"; mock.module("../packagemanager/currentPackageManager.js", { @@ -46,6 +51,14 @@ describe("scanCommand", async () => { }, }); + 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: { @@ -177,4 +190,99 @@ describe("scanCommand", async () => { assert.equal(failureMessage.toLowerCase().includes("timeout"), false); assert.equal(failureMessage.toLowerCase().includes("malicious"), true); }); + + 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 exitCode = null; + + mockStartProcess.mock.mockImplementationOnce(() => ({ + setText: () => {}, + succeed: () => {}, + fail: () => { + failureMessageWasSet = true; + }, + })); + + mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ + { name: "malicious", version: "1.0.0" }, + ]); + + // Mock process.exit + const originalExit = process.exit; + process.exit = mock.fn((code) => { + exitCode = code; + throw new Error("Process exit called"); // Prevent actual exit + }); + + try { + await assert.rejects( + scanCommand(["install", "malicious"]), + /Process exit called/ + ); + } finally { + // Restore original process.exit + process.exit = originalExit; + // Reset malware action back to prompt mode for other tests + malwareAction = MALWARE_ACTION_PROMPT; + } + + assert.equal(failureMessageWasSet, true); + assert.equal(exitCode, 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 processExited = false; + let userWasPrompted = false; + + mockStartProcess.mock.mockImplementationOnce(() => ({ + setText: () => {}, + succeed: () => {}, + fail: () => {}, + })); + + mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ + { name: "malicious", version: "1.0.0" }, + ]); + + mockConfirm.mock.mockImplementationOnce(() => { + userWasPrompted = true; + return false; + }); + + // Mock process.exit + const originalExit = process.exit; + process.exit = mock.fn(() => { + processExited = true; + throw new Error("Process exit called"); // Prevent actual exit + }); + + try { + await assert.rejects( + scanCommand(["install", "malicious"]), + /Process exit called/ + ); + } finally { + // Restore original process.exit + process.exit = originalExit; + // Reset malware action back to prompt mode for other tests + malwareAction = MALWARE_ACTION_PROMPT; + } + + assert.equal(processExited, true); + assert.equal(userWasPrompted, false); + }); });