Exit installation when detecting changes failed due to non-zero exit code in dry-run

This commit is contained in:
Sander Declerck 2025-09-19 08:52:42 +02:00
parent f7589160af
commit 528a60c166
No known key found for this signature in database
5 changed files with 169 additions and 10 deletions

View file

@ -15,6 +15,7 @@ export async function main(args) {
} }
} catch (error) { } catch (error) {
ui.writeError("Failed to check for malicious packages:", error.message); ui.writeError("Failed to check for malicious packages:", error.message);
process.exit(1);
} }
var result = getPackageManager().runCommand(args); var result = getPackageManager().runCommand(args);

View file

@ -37,10 +37,17 @@ function checkChangesWithDryRun(args) {
// Dry-run can return a non-zero status code in some cases // Dry-run can return a non-zero status code in some cases
// e.g., when running "npm audit fix --dry-run", it returns exit code 1 // e.g., when running "npm audit fix --dry-run", it returns exit code 1
// when there are vulnurabilities that can be fixed. // when there are vulnerabilities that can be fixed.
if (dryRunOutput.status !== 0 && !doesCommandReturnNonZero(args)) {
throw new Error(
`Dry-run command failed with exit code ${dryRunOutput.status} and output:\n${dryRunOutput.output}`
);
}
if (dryRunOutput.status !== 0 && !dryRunOutput.output) { if (dryRunOutput.status !== 0 && !dryRunOutput.output) {
ui.writeError("Detecting changes failed."); throw new Error(
return []; `Dry-run command failed with exit code ${dryRunOutput.status} and produced no output.`
);
} }
const parsedOutput = parseDryRunOutput(dryRunOutput.output); const parsedOutput = parseDryRunOutput(dryRunOutput.output);
@ -48,3 +55,11 @@ function checkChangesWithDryRun(args) {
// reverse the array to have the top-level packages first // reverse the array to have the top-level packages first
return parsedOutput.reverse(); return parsedOutput.reverse();
} }
function doesCommandReturnNonZero(args) {
if (args.length < 2) {
return false;
}
return args[0] === "audit" && args[1] === "fix";
}

View file

@ -0,0 +1,139 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert/strict";
describe("dryRunScanner", async () => {
const mockWriteError = mock.fn();
const mockDryRunNpmCommandAndOutput = mock.fn();
// Mock ui module
mock.module("../../../environment/userInteraction.js", {
namedExports: {
ui: {
writeError: mockWriteError,
},
},
});
// Mock dryRunNpmCommandAndOutput function
mock.module("../runNpmCommand.js", {
namedExports: {
dryRunNpmCommandAndOutput: mockDryRunNpmCommandAndOutput,
},
});
const { dryRunScanner } = await import("./dryRunScanner.js");
describe("doesCommandReturnNonZero", () => {
// We need to access the internal function for testing
// Since it's not exported, we'll test it indirectly through the main functionality
it("should handle npm audit fix commands that return non-zero", async () => {
mockDryRunNpmCommandAndOutput.mock.resetCalls();
mockWriteError.mock.resetCalls();
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
status: 1,
output: "found 5 vulnerabilities that can be fixed",
}));
const scanner = dryRunScanner();
const result = scanner.scan(["audit", "fix"]);
// Should not throw an error for audit fix commands
assert.ok(Array.isArray(result));
assert.equal(mockWriteError.mock.callCount(), 0);
});
it("should throw error for unexpected non-zero exit codes", async () => {
mockDryRunNpmCommandAndOutput.mock.resetCalls();
mockWriteError.mock.resetCalls();
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
status: 1,
output: "some error output",
}));
const scanner = dryRunScanner();
assert.throws(() => {
scanner.scan(["install", "lodash"]);
}, /Dry-run command failed with exit code 1/);
});
it("should handle zero exit codes normally", async () => {
mockDryRunNpmCommandAndOutput.mock.resetCalls();
mockWriteError.mock.resetCalls();
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
status: 0,
output: "added 1 package",
}));
const scanner = dryRunScanner();
const result = scanner.scan(["install", "lodash"]);
assert.ok(Array.isArray(result));
assert.equal(mockWriteError.mock.callCount(), 0);
});
it("should throw error for non-zero exit with no output for audit fix", async () => {
mockDryRunNpmCommandAndOutput.mock.resetCalls();
mockWriteError.mock.resetCalls();
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
status: 1,
output: "",
}));
const scanner = dryRunScanner();
assert.throws(() => {
scanner.scan(["audit", "fix"]);
}, /Dry-run command failed with exit code 1/);
});
});
describe("scanner functionality", () => {
it("should use dryRunCommand option when provided", async () => {
mockDryRunNpmCommandAndOutput.mock.resetCalls();
mockWriteError.mock.resetCalls();
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
status: 0,
output: "no changes",
}));
const scanner = dryRunScanner({ dryRunCommand: "install" });
scanner.scan(["install-test", "lodash"]);
// Should call with "install" instead of "install-test"
assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 1);
const calledArgs =
mockDryRunNpmCommandAndOutput.mock.calls[0].arguments[0];
assert.deepEqual(calledArgs, ["install", "lodash"]);
});
it("should skip scanning when hasDryRunArg returns true", async () => {
mockDryRunNpmCommandAndOutput.mock.resetCalls();
mockWriteError.mock.resetCalls();
const scanner = dryRunScanner();
const shouldScan = scanner.shouldScan(["install", "--dry-run"]);
assert.equal(shouldScan, false);
// Should not call dryRunNpmCommandAndOutput since scanning is skipped
assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 0);
});
it("should skip scanning when skipScanWhen returns true", async () => {
const scanner = dryRunScanner({
skipScanWhen: (args) => args.includes("--skip"),
});
const shouldScan = scanner.shouldScan(["install", "--skip"]);
assert.equal(shouldScan, false);
});
it("should scan when conditions are met", async () => {
const scanner = dryRunScanner();
const shouldScan = scanner.shouldScan(["install", "lodash"]);
assert.equal(shouldScan, true);
});
});
});

View file

@ -18,7 +18,7 @@ export function runNpm(args) {
export function dryRunNpmCommandAndOutput(args) { export function dryRunNpmCommandAndOutput(args) {
try { try {
const npmCommand = `npm ${args.join(" ")} --dry-run`; const npmCommand = `npm ${args.join(" ")} --ignore-scripts --dry-run`;
const output = execSync(npmCommand, { stdio: "pipe" }); const output = execSync(npmCommand, { stdio: "pipe" });
return { status: 0, output: output.toString() }; return { status: 0, output: output.toString() };
} catch (error) { } catch (error) {

View file

@ -21,7 +21,9 @@ export async function scanCommand(args) {
let timedOut = false; let timedOut = false;
const spinner = ui.startProcess("Scanning for malicious packages..."); const spinner = ui.startProcess(
"Safe-chain: Scanning for malicious packages..."
);
let audit; let audit;
await Promise.race([ await Promise.race([
@ -37,12 +39,14 @@ export async function scanCommand(args) {
} }
if (changes.length > 0) { if (changes.length > 0) {
spinner.setText(`Scanning ${changes.length} package(s)...`); spinner.setText(
`Safe-chain: Scanning ${changes.length} package(s)...`
);
} }
audit = await auditChanges(changes); audit = await auditChanges(changes);
} catch (error) { } catch (error) {
spinner.fail(`Error while scanning: ${error.message}`); spinner.fail(`Safe-chain: Error while scanning.`);
throw error; throw error;
} }
})(), })(),
@ -52,12 +56,12 @@ export async function scanCommand(args) {
]); ]);
if (timedOut) { if (timedOut) {
spinner.fail("Timeout exceeded while scanning."); spinner.fail("Safe-chain: Timeout exceeded while scanning.");
throw new Error("Timeout exceeded while scanning npm install command."); throw new Error("Timeout exceeded while scanning npm install command.");
} }
if (!audit || audit.isAllowed) { if (!audit || audit.isAllowed) {
spinner.succeed("No malicious packages detected."); spinner.succeed("Safe-chain: No malicious packages detected.");
} else { } else {
printMaliciousChanges(audit.disallowedChanges, spinner); printMaliciousChanges(audit.disallowedChanges, spinner);
await onMalwareFound(); await onMalwareFound();
@ -65,7 +69,7 @@ export async function scanCommand(args) {
} }
function printMaliciousChanges(changes, spinner) { function printMaliciousChanges(changes, spinner) {
spinner.fail(chalk.bold("Malicious changes detected:")); spinner.fail("Safe-chain: " + chalk.bold("Malicious changes detected:"));
for (const change of changes) { for (const change of changes) {
ui.writeInformation(` - ${change.name}@${change.version}`); ui.writeInformation(` - ${change.name}@${change.version}`);