Move safe-chain package to packages/safe-chain

This commit is contained in:
Sander Declerck 2025-09-05 11:19:37 +02:00
parent fc9a9ca129
commit 7673d32912
No known key found for this signature in database
68 changed files with 85 additions and 52 deletions

View file

@ -0,0 +1,56 @@
import {
MALWARE_STATUS_MALWARE,
openMalwareDatabase,
} from "../malwareDatabase.js";
export async function auditChanges(changes) {
const allowedChanges = [];
const disallowedChanges = [];
var malwarePackages = await getPackagesWithMalware(
changes.filter(
(change) => change.type === "add" || change.type === "change"
)
);
for (const change of changes) {
const malwarePackage = malwarePackages.find(
(pkg) => pkg.name === change.name && pkg.version === change.version
);
if (malwarePackage) {
disallowedChanges.push({ ...change, reason: malwarePackage.status });
} else {
allowedChanges.push(change);
}
}
const auditResults = {
allowedChanges,
disallowedChanges,
isAllowed: disallowedChanges.length === 0,
};
return auditResults;
}
async function getPackagesWithMalware(changes) {
if (changes.length === 0) {
return [];
}
const malwareDb = await openMalwareDatabase();
let allVulnerablePackages = [];
for (const change of changes) {
if (malwareDb.isMalware(change.name, change.version)) {
allVulnerablePackages.push({
name: change.name,
version: change.version,
status: MALWARE_STATUS_MALWARE,
});
}
}
return allVulnerablePackages;
}

View file

@ -0,0 +1,92 @@
import { auditChanges } from "./audit/index.js";
import { getScanTimeout } from "../config/configFile.js";
import { setTimeout } from "timers/promises";
import chalk from "chalk";
import { getPackageManager } from "../packagemanager/currentPackageManager.js";
import { ui } from "../environment/userInteraction.js";
export function shouldScanCommand(args) {
if (!args || args.length === 0) {
return false;
}
return getPackageManager().isSupportedCommand(args);
}
export async function scanCommand(args) {
if (!shouldScanCommand(args)) {
return [];
}
let timedOut = false;
const spinner = ui.startProcess("Scanning for malicious packages...");
let audit;
await Promise.race([
(async () => {
try {
const packageManager = getPackageManager();
const changes = await packageManager.getDependencyUpdatesForCommand(
args
);
if (timedOut) {
return;
}
if (changes.length > 0) {
spinner.setText(`Scanning ${changes.length} package(s)...`);
}
audit = await auditChanges(changes);
} catch (error) {
spinner.fail(`Error while scanning: ${error.message}`);
throw error;
}
})(),
setTimeout(getScanTimeout()).then(() => {
timedOut = true;
}),
]);
if (timedOut) {
spinner.fail("Timeout exceeded while scanning.");
throw new Error("Timeout exceeded while scanning npm install command.");
}
if (!audit || audit.isAllowed) {
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
);
}
}
function printMaliciousChanges(changes, spinner) {
spinner.fail(chalk.bold("Malicious changes detected:"));
for (const change of changes) {
ui.writeInformation(` - ${change.name}@${change.version}`);
}
}
async function acceptRiskOrExit(message, defaultValue) {
ui.emptyLine();
const continueInstall = await ui.confirm({
message: message,
default: defaultValue,
});
if (continueInstall) {
ui.writeInformation("Continuing with the installation...");
return;
}
ui.writeInformation("Exiting without installing packages.");
ui.emptyLine();
process.exit(1);
}

View file

@ -0,0 +1,180 @@
import assert from "node:assert/strict";
import { describe, it, mock } from "node:test";
import { setTimeout } from "node:timers/promises";
describe("scanCommand", async () => {
const getScanTimeoutMock = mock.fn(() => 1000);
const mockGetDependencyUpdatesForCommand = mock.fn();
const mockStartProcess = mock.fn(() => ({
setText: () => {},
succeed: () => {},
fail: () => {},
}));
const mockConfirm = mock.fn(() => true);
// import { getPackageManager } from "../packagemanager/currentPackageManager.js";
mock.module("../packagemanager/currentPackageManager.js", {
namedExports: {
getPackageManager: () => {
return {
isSupportedCommand: () => true,
getDependencyUpdatesForCommand: mockGetDependencyUpdatesForCommand,
};
},
},
});
// import { getScanTimeout } from "../config/configFile.js";
mock.module("../config/configFile.js", {
namedExports: {
getScanTimeout: getScanTimeoutMock,
getBaseUrl: () => undefined,
},
});
// import { ui } from "../environment/userInteraction.js";
mock.module("../environment/userInteraction.js", {
namedExports: {
ui: {
startProcess: mockStartProcess,
writeError: () => {},
writeInformation: () => {},
writeWarning: () => {},
emptyLine: () => {},
confirm: mockConfirm,
},
},
});
// import { auditChanges, MAX_LENGTH_EXCEEDED } from "./audit/index.js";
mock.module("./audit/index.js", {
namedExports: {
auditChanges: (changes) => {
const malisciousChangeName = "malicious";
const allowedChanges = changes.filter(
(change) => change.name !== malisciousChangeName
);
const disallowedChanges = changes
.filter((change) => change.name === malisciousChangeName)
.map((change) => ({
...change,
reason: "malicious",
}));
const auditResults = {
allowedChanges,
disallowedChanges,
isAllowed: disallowedChanges.length === 0,
};
return auditResults;
},
MAX_LENGTH_EXCEEDED: "MAX_LENGTH_EXCEEDED",
},
});
const { scanCommand } = await import("./index.js");
it("should succeed when there are no changes", async () => {
let successMessageWasSet = false;
mockStartProcess.mock.mockImplementationOnce(() => ({
setText: () => {},
succeed: () => {
successMessageWasSet = true;
},
fail: () => {},
}));
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => []);
await scanCommand(["install", "lodash"]);
assert.equal(successMessageWasSet, true);
});
it("should succeed when changes are not malicious", async () => {
let successMessageWasSet = false;
mockStartProcess.mock.mockImplementationOnce(() => ({
setText: () => {},
succeed: () => {
successMessageWasSet = true;
},
fail: () => {},
}));
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
{ name: "lodash", version: "4.17.21" },
]);
await scanCommand(["install", "lodash"]);
assert.equal(successMessageWasSet, true);
});
it("should throw an error when timing out", async () => {
let failureMessageWasSet = false;
mockStartProcess.mock.mockImplementationOnce(() => ({
setText: () => {},
succeed: () => {},
fail: () => {
failureMessageWasSet = true;
},
}));
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
await setTimeout(150);
return [{ name: "lodash", version: "4.17.21" }];
});
await assert.rejects(scanCommand(["install", "lodash"]));
assert.equal(failureMessageWasSet, true);
});
it("should fail and prompt the user when malicious changes are detected", async () => {
let failureMessageWasSet = false;
mockStartProcess.mock.mockImplementationOnce(() => ({
setText: () => {},
succeed: () => {},
fail: () => {
failureMessageWasSet = true;
},
}));
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"]);
assert.equal(failureMessageWasSet, true);
assert.equal(userWasPrompted, true);
});
it("should not report a timeout when the user takes a long time to respond (it should not affect the timeout)", async () => {
let failureMessages = [];
mockStartProcess.mock.mockImplementationOnce(() => ({
setText: () => {},
succeed: () => {},
fail: (message) => {
failureMessages.push(message);
},
}));
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
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"]);
assert.equal(failureMessages.length, 1);
const failureMessage = failureMessages[0];
assert.equal(failureMessage.toLowerCase().includes("timeout"), false);
assert.equal(failureMessage.toLowerCase().includes("malicious"), true);
});
});

View file

@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { describe, it, mock } from "node:test";
describe("shouldScanCommand", async () => {
const isSupportedCommandMock = mock.fn(() => undefined);
mock.module("../packagemanager/currentPackageManager.js", {
namedExports: {
getPackageManager: () => {
return {
isSupportedCommand: isSupportedCommandMock,
getDependencyUpdatesForCommand: () => [],
};
},
},
});
const { shouldScanCommand } = await import("./index.js");
it("should return false if the argument is an empty array", () => {
const result = shouldScanCommand([]);
assert.strictEqual(result, false);
});
it("should return false if the argument is undefined", () => {
const result = shouldScanCommand(undefined);
assert.strictEqual(result, false);
});
it("should return true if the package manager supports the command", () => {
isSupportedCommandMock.mock.mockImplementation(() => true);
const result = shouldScanCommand(["install", "lodash"]);
assert.strictEqual(result, true);
});
it("should return false if the package manager does not support the command", () => {
isSupportedCommandMock.mock.mockImplementation(() => false);
const result = shouldScanCommand(["unknown", "command"]);
assert.strictEqual(result, false);
});
});

View file

@ -0,0 +1,72 @@
import {
fetchMalwareDatabase,
fetchMalwareDatabaseVersion,
} from "../api/aikido.js";
import {
readDatabaseFromLocalCache,
writeDatabaseToLocalCache,
} from "../config/configFile.js";
import { ui } from "../environment/userInteraction.js";
export async function openMalwareDatabase() {
const malwareDatabase = await getMalwareDatabase();
function getPackageStatus(name, version) {
const packageData = malwareDatabase.find(
(pkg) =>
pkg.package_name === name &&
(pkg.version === version || pkg.version === "*")
);
if (!packageData) {
return MALWARE_STATUS_OK;
}
return packageData.reason;
}
return {
getPackageStatus,
isMalware: (name, version) => {
const status = getPackageStatus(name, version);
return isMalwareStatus(status);
},
};
}
async function getMalwareDatabase() {
const { malwareDatabase: cachedDatabase, version: cachedVersion } =
readDatabaseFromLocalCache();
try {
if (cachedDatabase) {
const currentVersion = await fetchMalwareDatabaseVersion();
if (cachedVersion === currentVersion) {
return cachedDatabase;
}
}
const { malwareDatabase, version } = await fetchMalwareDatabase();
writeDatabaseToLocalCache(malwareDatabase, version);
return malwareDatabase;
} catch (error) {
if (cachedDatabase) {
ui.writeWarning(
"Failed to fetch the latest malware database. Using cached version."
);
return cachedDatabase;
}
throw error;
}
}
function isMalwareStatus(status) {
let malwareStatus = status.toUpperCase();
return malwareStatus === MALWARE_STATUS_MALWARE;
}
export const MALWARE_STATUS_OK = "OK";
export const MALWARE_STATUS_MALWARE = "MALWARE";
export const MALWARE_STATUS_TELEMETRY = "TELEMETRY";
export const MALWARE_STATUS_PROTESTWARE = "PROTESTWARE";