mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 20:20:49 +00:00
Move safe-chain package to packages/safe-chain
This commit is contained in:
parent
fc9a9ca129
commit
7673d32912
68 changed files with 85 additions and 52 deletions
56
packages/safe-chain/src/scanning/audit/index.js
Normal file
56
packages/safe-chain/src/scanning/audit/index.js
Normal 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;
|
||||
}
|
||||
92
packages/safe-chain/src/scanning/index.js
Normal file
92
packages/safe-chain/src/scanning/index.js
Normal 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);
|
||||
}
|
||||
180
packages/safe-chain/src/scanning/index.scanCommand.spec.js
Normal file
180
packages/safe-chain/src/scanning/index.scanCommand.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
72
packages/safe-chain/src/scanning/malwareDatabase.js
Normal file
72
packages/safe-chain/src/scanning/malwareDatabase.js
Normal 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";
|
||||
Loading…
Add table
Add a link
Reference in a new issue