mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Exit installation instead of prompting the user
This commit is contained in:
parent
0cb9562857
commit
2f1692e253
7 changed files with 297 additions and 13 deletions
15
README.md
15
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.
|
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
|
# Usage in CI/CD
|
||||||
|
|
||||||
🚧 Support for CI/CD environments is coming soon...
|
🚧 Support for CI/CD environments is coming soon...
|
||||||
|
|
|
||||||
33
packages/safe-chain/src/config/cliArguments.js
Normal file
33
packages/safe-chain/src/config/cliArguments.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
108
packages/safe-chain/src/config/cliArguments.spec.js
Normal file
108
packages/safe-chain/src/config/cliArguments.spec.js
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
14
packages/safe-chain/src/config/settings.js
Normal file
14
packages/safe-chain/src/config/settings.js
Normal file
|
|
@ -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";
|
||||||
|
|
@ -3,9 +3,13 @@
|
||||||
import { scanCommand, shouldScanCommand } from "./scanning/index.js";
|
import { scanCommand, shouldScanCommand } from "./scanning/index.js";
|
||||||
import { ui } from "./environment/userInteraction.js";
|
import { ui } from "./environment/userInteraction.js";
|
||||||
import { getPackageManager } from "./packagemanager/currentPackageManager.js";
|
import { getPackageManager } from "./packagemanager/currentPackageManager.js";
|
||||||
|
import { initializeCliArguments } from "./config/cliArguments.js";
|
||||||
|
|
||||||
export async function main(args) {
|
export async function main(args) {
|
||||||
try {
|
try {
|
||||||
|
// This parses all the --safe-chain arguments and removes them from the args array
|
||||||
|
args = initializeCliArguments(args);
|
||||||
|
|
||||||
if (shouldScanCommand(args)) {
|
if (shouldScanCommand(args)) {
|
||||||
await scanCommand(args);
|
await scanCommand(args);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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) {
|
||||||
|
|
@ -59,10 +60,7 @@ export async function scanCommand(args) {
|
||||||
spinner.succeed("No malicious packages detected.");
|
spinner.succeed("No malicious packages detected.");
|
||||||
} else {
|
} else {
|
||||||
printMaliciousChanges(audit.disallowedChanges, spinner);
|
printMaliciousChanges(audit.disallowedChanges, spinner);
|
||||||
await acceptRiskOrExit(
|
await onMalwareFound();
|
||||||
"Do you want to continue with the installation despite the risks?",
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,19 +72,23 @@ function printMaliciousChanges(changes, spinner) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acceptRiskOrExit(message, defaultValue) {
|
async function onMalwareFound() {
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
|
|
||||||
|
if (getMalwareAction() === MALWARE_ACTION_PROMPT) {
|
||||||
const continueInstall = await ui.confirm({
|
const continueInstall = await ui.confirm({
|
||||||
message: message,
|
message:
|
||||||
default: defaultValue,
|
"Malicious packages were found. Do you want to continue with the installation?",
|
||||||
|
default: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (continueInstall) {
|
if (continueInstall) {
|
||||||
ui.writeInformation("Continuing with the installation...");
|
ui.writeWarning("Continuing with the installation despite the risks...");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ui.writeInformation("Exiting without installing packages.");
|
ui.writeError("Exiting without installing malicious packages.");
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { 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);
|
||||||
|
|
@ -11,6 +15,7 @@ describe("scanCommand", async () => {
|
||||||
fail: () => {},
|
fail: () => {},
|
||||||
}));
|
}));
|
||||||
const mockConfirm = mock.fn(() => true);
|
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,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";
|
// import { auditChanges, MAX_LENGTH_EXCEEDED } from "./audit/index.js";
|
||||||
mock.module("./audit/index.js", {
|
mock.module("./audit/index.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
|
|
@ -177,4 +190,99 @@ describe("scanCommand", async () => {
|
||||||
assert.equal(failureMessage.toLowerCase().includes("timeout"), false);
|
assert.equal(failureMessage.toLowerCase().includes("timeout"), false);
|
||||||
assert.equal(failureMessage.toLowerCase().includes("malicious"), true);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue