From e4c40330f70e460ac3fe8ba8636d4e08df2f2732 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 5 Nov 2025 12:01:08 +0100 Subject: [PATCH 1/8] Only write to stdout when safe-chain audited packages --- packages/safe-chain/src/main.js | 16 +- .../safe-chain/src/scanning/audit/index.js | 27 +++ .../src/scanning/audit/index.spec.js | 188 ++++++++++++++++++ 3 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 packages/safe-chain/src/scanning/audit/index.spec.js diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 3fba24f..f4d5866 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -6,6 +6,7 @@ import { getPackageManager } from "./packagemanager/currentPackageManager.js"; import { initializeCliArguments } from "./config/cliArguments.js"; import { createSafeChainProxy } from "./registryProxy/registryProxy.js"; import chalk from "chalk"; +import { getAuditStats } from "./scanning/audit/index.js"; /** * @param {string[]} args @@ -61,12 +62,15 @@ export async function main(args) { return 1; } - ui.emptyLine(); - ui.writeInformation( - `${chalk.green( - "✔" - )} Safe-chain: Command completed, no malicious packages found.` - ); + const auditStats = getAuditStats(); + if (auditStats.verifiedPackages > 0) { + ui.emptyLine(); + ui.writeInformation( + `${chalk.green("✔")} Safe-chain: Scanned ${ + auditStats.verifiedPackages + } packages, no malware found.` + ); + } // Returning the exit code back to the caller allows the promise // to be awaited in the bin files and return the correct exit code diff --git a/packages/safe-chain/src/scanning/audit/index.js b/packages/safe-chain/src/scanning/audit/index.js index 2d215cb..5b307fb 100644 --- a/packages/safe-chain/src/scanning/audit/index.js +++ b/packages/safe-chain/src/scanning/audit/index.js @@ -18,6 +18,29 @@ import { * @property {boolean} isAllowed */ +/** + * @typedef {Object} AuditStats + * @property {number} verifiedPackages + * @property {number} safePackages + * @property {number} malwarePackages + */ + +/** + * @type AuditStats + */ +const auditStats = { + verifiedPackages: 0, + safePackages: 0, + malwarePackages: 0, +}; + +/** + * @returns {AuditStats} + */ +export function getAuditStats() { + return auditStats; +} + /** * @param {PackageChange[]} changes * @@ -41,16 +64,20 @@ export async function auditChanges(changes) { ); if (malwarePackage) { + auditStats.malwarePackages += 1; ui.writeVerbose( `Safe-chain: Package ${change.name}@${change.version} is marked as malware: ${malwarePackage.status}` ); disallowedChanges.push({ ...change, reason: malwarePackage.status }); } else { + auditStats.safePackages += 1; ui.writeVerbose( `Safe-chain: Package ${change.name}@${change.version} is clean` ); allowedChanges.push(change); } + + auditStats.verifiedPackages += 1; } const auditResults = { diff --git a/packages/safe-chain/src/scanning/audit/index.spec.js b/packages/safe-chain/src/scanning/audit/index.spec.js new file mode 100644 index 0000000..51c9d23 --- /dev/null +++ b/packages/safe-chain/src/scanning/audit/index.spec.js @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import { describe, it, mock, beforeEach } from "node:test"; + +describe("audit/index", async () => { + const mockWriteVerbose = mock.fn(); + + // Mock UI module + mock.module("../../environment/userInteraction.js", { + namedExports: { + ui: { + writeVerbose: mockWriteVerbose, + }, + }, + }); + + // Mock malware database + const mockIsMalware = mock.fn(); + mock.module("../malwareDatabase.js", { + namedExports: { + MALWARE_STATUS_MALWARE: "malware", + openMalwareDatabase: async () => ({ + isMalware: mockIsMalware, + }), + }, + }); + + const { auditChanges, getAuditStats } = await import("./index.js"); + + beforeEach(() => { + mockWriteVerbose.mock.resetCalls(); + mockIsMalware.mock.resetCalls(); + }); + + describe("getAuditStats", () => { + it("should return audit stats object with correct structure", () => { + const stats = getAuditStats(); + + assert.ok(stats.hasOwnProperty("verifiedPackages")); + assert.ok(stats.hasOwnProperty("safePackages")); + assert.ok(stats.hasOwnProperty("malwarePackages")); + assert.equal(typeof stats.verifiedPackages, "number"); + assert.equal(typeof stats.safePackages, "number"); + assert.equal(typeof stats.malwarePackages, "number"); + }); + + it("should return the same object reference on multiple calls", () => { + const stats1 = getAuditStats(); + const stats2 = getAuditStats(); + + assert.equal(stats1, stats2); + }); + }); + + describe("auditChanges", () => { + it("should return empty allowed and disallowed arrays when no changes provided", async () => { + const result = await auditChanges([]); + + assert.deepEqual(result.allowedChanges, []); + assert.deepEqual(result.disallowedChanges, []); + assert.equal(result.isAllowed, true); + }); + + it("should mark package as allowed when not malware", async () => { + mockIsMalware.mock.mockImplementation(() => false); + + const changes = [{ name: "lodash", version: "4.17.21", type: "add" }]; + const result = await auditChanges(changes); + + assert.equal(result.allowedChanges.length, 1); + assert.equal(result.disallowedChanges.length, 0); + assert.equal(result.isAllowed, true); + assert.deepEqual(result.allowedChanges[0], changes[0]); + }); + + it("should mark package as disallowed when malware detected", async () => { + mockIsMalware.mock.mockImplementation(() => true); + + const changes = [ + { name: "malicious-pkg", version: "1.0.0", type: "add" }, + ]; + const result = await auditChanges(changes); + + assert.equal(result.allowedChanges.length, 0); + assert.equal(result.disallowedChanges.length, 1); + assert.equal(result.isAllowed, false); + assert.equal(result.disallowedChanges[0].name, "malicious-pkg"); + assert.equal(result.disallowedChanges[0].version, "1.0.0"); + assert.equal(result.disallowedChanges[0].reason, "malware"); + }); + + it("should handle mixed safe and malware packages", async () => { + mockIsMalware.mock.mockImplementation((name) => { + return name === "malicious-pkg"; + }); + + const changes = [ + { name: "lodash", version: "4.17.21", type: "add" }, + { name: "malicious-pkg", version: "1.0.0", type: "add" }, + { name: "express", version: "4.18.0", type: "add" }, + ]; + const result = await auditChanges(changes); + + assert.equal(result.allowedChanges.length, 2); + assert.equal(result.disallowedChanges.length, 1); + assert.equal(result.isAllowed, false); + assert.equal(result.disallowedChanges[0].name, "malicious-pkg"); + }); + + it("should only check malware for add and change types", async () => { + mockIsMalware.mock.mockImplementation(() => false); + + const changes = [ + { name: "pkg1", version: "1.0.0", type: "add" }, + { name: "pkg2", version: "2.0.0", type: "change" }, + { name: "pkg3", version: "3.0.0", type: "remove" }, + ]; + await auditChanges(changes); + + // Should only check pkg1 and pkg2, not pkg3 (remove type) + assert.equal(mockIsMalware.mock.calls.length, 2); + }); + + it("should increment verifiedPackages counter for each package", async () => { + mockIsMalware.mock.mockImplementation(() => false); + + const statsBefore = getAuditStats(); + const initialCount = statsBefore.verifiedPackages; + + const changes = [ + { name: "pkg1", version: "1.0.0", type: "add" }, + { name: "pkg2", version: "2.0.0", type: "add" }, + { name: "pkg3", version: "3.0.0", type: "add" }, + ]; + await auditChanges(changes); + + const statsAfter = getAuditStats(); + assert.equal(statsAfter.verifiedPackages, initialCount + 3); + }); + + it("should increment safePackages counter for safe packages", async () => { + mockIsMalware.mock.mockImplementation(() => false); + + const statsBefore = getAuditStats(); + const initialCount = statsBefore.safePackages; + + const changes = [ + { name: "lodash", version: "4.17.21", type: "add" }, + { name: "express", version: "4.18.0", type: "add" }, + ]; + await auditChanges(changes); + + const statsAfter = getAuditStats(); + assert.equal(statsAfter.safePackages, initialCount + 2); + }); + + it("should increment malwarePackages counter for malware packages", async () => { + mockIsMalware.mock.mockImplementation(() => true); + + const statsBefore = getAuditStats(); + const initialCount = statsBefore.malwarePackages; + + const changes = [ + { name: "malicious-1", version: "1.0.0", type: "add" }, + { name: "malicious-2", version: "2.0.0", type: "add" }, + ]; + await auditChanges(changes); + + const statsAfter = getAuditStats(); + assert.equal(statsAfter.malwarePackages, initialCount + 2); + }); + + it("should accumulate stats across multiple auditChanges calls", async () => { + mockIsMalware.mock.mockImplementation(() => false); + + const statsBefore = getAuditStats(); + const initialVerified = statsBefore.verifiedPackages; + + // First call + await auditChanges([{ name: "pkg1", version: "1.0.0", type: "add" }]); + + // Second call + await auditChanges([{ name: "pkg2", version: "2.0.0", type: "add" }]); + + const statsAfter = getAuditStats(); + assert.equal(statsAfter.verifiedPackages, initialVerified + 2); + }); + }); +}); From 378b0ac7c92da7b0fee77b16ae94f4a1f73d4f6b Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 5 Nov 2025 12:19:47 +0100 Subject: [PATCH 2/8] Rename verifiedPackages to totalPackages, fix e2e tests --- packages/safe-chain/src/main.js | 4 +- .../safe-chain/src/scanning/audit/index.js | 6 +- .../src/scanning/audit/index.spec.js | 14 +-- test/e2e/bun.e2e.spec.js | 2 +- test/e2e/npm-ci.e2e.spec.js | 2 +- test/e2e/npm.e2e.spec.js | 2 +- test/e2e/pip.e2e.spec.js | 110 +++++++++++------- test/e2e/pnpm-ci.e2e.spec.js | 2 +- test/e2e/pnpm.e2e.spec.js | 2 +- test/e2e/yarn-ci.e2e.spec.js | 2 +- test/e2e/yarn.e2e.spec.js | 2 +- 11 files changed, 89 insertions(+), 59 deletions(-) diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index f4d5866..ea4fe0e 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -63,11 +63,11 @@ export async function main(args) { } const auditStats = getAuditStats(); - if (auditStats.verifiedPackages > 0) { + if (auditStats.totalPackages > 0) { ui.emptyLine(); ui.writeInformation( `${chalk.green("✔")} Safe-chain: Scanned ${ - auditStats.verifiedPackages + auditStats.totalPackages } packages, no malware found.` ); } diff --git a/packages/safe-chain/src/scanning/audit/index.js b/packages/safe-chain/src/scanning/audit/index.js index 5b307fb..803051a 100644 --- a/packages/safe-chain/src/scanning/audit/index.js +++ b/packages/safe-chain/src/scanning/audit/index.js @@ -20,7 +20,7 @@ import { /** * @typedef {Object} AuditStats - * @property {number} verifiedPackages + * @property {number} totalPackages * @property {number} safePackages * @property {number} malwarePackages */ @@ -29,7 +29,7 @@ import { * @type AuditStats */ const auditStats = { - verifiedPackages: 0, + totalPackages: 0, safePackages: 0, malwarePackages: 0, }; @@ -77,7 +77,7 @@ export async function auditChanges(changes) { allowedChanges.push(change); } - auditStats.verifiedPackages += 1; + auditStats.totalPackages += 1; } const auditResults = { diff --git a/packages/safe-chain/src/scanning/audit/index.spec.js b/packages/safe-chain/src/scanning/audit/index.spec.js index 51c9d23..33ca9e3 100644 --- a/packages/safe-chain/src/scanning/audit/index.spec.js +++ b/packages/safe-chain/src/scanning/audit/index.spec.js @@ -35,10 +35,10 @@ describe("audit/index", async () => { it("should return audit stats object with correct structure", () => { const stats = getAuditStats(); - assert.ok(stats.hasOwnProperty("verifiedPackages")); + assert.ok(stats.hasOwnProperty("totalPackages")); assert.ok(stats.hasOwnProperty("safePackages")); assert.ok(stats.hasOwnProperty("malwarePackages")); - assert.equal(typeof stats.verifiedPackages, "number"); + assert.equal(typeof stats.totalPackages, "number"); assert.equal(typeof stats.safePackages, "number"); assert.equal(typeof stats.malwarePackages, "number"); }); @@ -120,11 +120,11 @@ describe("audit/index", async () => { assert.equal(mockIsMalware.mock.calls.length, 2); }); - it("should increment verifiedPackages counter for each package", async () => { + it("should increment totalPackages counter for each package", async () => { mockIsMalware.mock.mockImplementation(() => false); const statsBefore = getAuditStats(); - const initialCount = statsBefore.verifiedPackages; + const initialCount = statsBefore.totalPackages; const changes = [ { name: "pkg1", version: "1.0.0", type: "add" }, @@ -134,7 +134,7 @@ describe("audit/index", async () => { await auditChanges(changes); const statsAfter = getAuditStats(); - assert.equal(statsAfter.verifiedPackages, initialCount + 3); + assert.equal(statsAfter.totalPackages, initialCount + 3); }); it("should increment safePackages counter for safe packages", async () => { @@ -173,7 +173,7 @@ describe("audit/index", async () => { mockIsMalware.mock.mockImplementation(() => false); const statsBefore = getAuditStats(); - const initialVerified = statsBefore.verifiedPackages; + const initialCount = statsBefore.totalPackages; // First call await auditChanges([{ name: "pkg1", version: "1.0.0", type: "add" }]); @@ -182,7 +182,7 @@ describe("audit/index", async () => { await auditChanges([{ name: "pkg2", version: "2.0.0", type: "add" }]); const statsAfter = getAuditStats(); - assert.equal(statsAfter.verifiedPackages, initialVerified + 2); + assert.equal(statsAfter.totalPackages, initialCount + 2); }); }); }); diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index 8dea93b..4f24b7d 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -31,7 +31,7 @@ describe("E2E: bun coverage", () => { const result = await shell.runCommand("bun i axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js index dc1c23f..18ee789 100644 --- a/test/e2e/npm-ci.e2e.spec.js +++ b/test/e2e/npm-ci.e2e.spec.js @@ -36,7 +36,7 @@ describe("E2E: npm coverage using PATH", () => { const result = await shell.runCommand("npm i axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index ba836e7..b2b7211 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -31,7 +31,7 @@ describe("E2E: npm coverage", () => { const result = await shell.runCommand("npm i axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index c647d30..ec2cdc5 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -31,7 +31,7 @@ describe("E2E: pip coverage", () => { const result = await shell.runCommand("pip3 install requests"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -41,7 +41,7 @@ describe("E2E: pip coverage", () => { const result = await shell.runCommand("pip3 download requests"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -51,7 +51,7 @@ describe("E2E: pip coverage", () => { const result = await shell.runCommand("pip3 wheel requests"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -61,17 +61,19 @@ describe("E2E: pip coverage", () => { const result = await shell.runCommand("pip3 install --dry-run requests"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); it(`pip3 install with extras such as requests[socks]`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand('pip3 install "requests[socks]==2.32.3"'); + const result = await shell.runCommand( + 'pip3 install "requests[socks]==2.32.3"' + ); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -81,27 +83,27 @@ describe("E2E: pip coverage", () => { const result = await shell.runCommand('pip3 install "Jinja2>=3.1,<3.2"'); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); it(`python3 -m pip install routes through safe-chain`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand('python3 -m pip install requests'); + const result = await shell.runCommand("python3 -m pip install requests"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); it(`python3 -m pip download routes through safe-chain`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand('python3 -m pip download requests'); + const result = await shell.runCommand("python3 -m pip download requests"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -111,7 +113,9 @@ describe("E2E: pip coverage", () => { // Clear pip cache to ensure network download through proxy await shell.runCommand("pip3 cache purge"); - const result = await shell.runCommand("pip3 install --break-system-packages safe-chain-pi-test"); + const result = await shell.runCommand( + "pip3 install --break-system-packages safe-chain-pi-test" + ); assert.ok( result.output.includes("blocked 1 malicious package downloads:"), @@ -135,60 +139,72 @@ describe("E2E: pip coverage", () => { it(`python -m pip routes to aikido-pip (uses pip command)`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand('python -m pip install --break-system-packages requests'); + const result = await shell.runCommand( + "python -m pip install --break-system-packages requests" + ); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); // Verify it completed successfully (would fail if routing was incorrect) assert.ok( - result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"), + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), `Installation did not succeed. Output was:\n${result.output}` ); }); it(`python -m pip3 routes to aikido-pip3 (uses pip3 command)`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand('python -m pip3 install --break-system-packages requests'); + const result = await shell.runCommand( + "python -m pip3 install --break-system-packages requests" + ); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); // Verify it completed successfully (would fail if routing was incorrect) assert.ok( - result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"), + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), `Installation did not succeed. Output was:\n${result.output}` ); }); it(`python3 -m pip routes to aikido-pip3 (uses pip3 command)`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand('python3 -m pip install --break-system-packages requests'); + const result = await shell.runCommand( + "python3 -m pip install --break-system-packages requests" + ); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); // Verify it completed successfully (would fail if routing was incorrect) assert.ok( - result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"), + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), `Installation did not succeed. Output was:\n${result.output}` ); }); it(`python3 -m pip3 routes to aikido-pip3 (uses pip3 command)`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand('python3 -m pip3 install --break-system-packages requests'); + const result = await shell.runCommand( + "python3 -m pip3 install --break-system-packages requests" + ); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); // Verify it completed successfully (would fail if routing was incorrect) assert.ok( - result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"), + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), `Installation did not succeed. Output was:\n${result.output}` ); }); @@ -197,17 +213,20 @@ describe("E2E: pip coverage", () => { const shell = await container.openShell("zsh"); // Install a simple package from GitHub - this should use TCP tunnel, not MITM // Using a popular, small package for testing - const result = await shell.runCommand('pip3 install --break-system-packages git+https://github.com/psf/requests.git@v2.32.3'); + const result = await shell.runCommand( + "pip3 install --break-system-packages git+https://github.com/psf/requests.git@v2.32.3" + ); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); - // Verify installation succeeded (would fail if certificate validation via env CA bundle broke) + // Verify installation succeeded (would fail if certificate validation via env CA bundle broke) assert.ok( - result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"), - `Installation from GitHub failed - CA bundle may not be working. Output was:\n${result.output}` + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), + `Installation from GitHub failed - CA bundle may not be working. Output was:\n${result.output}` ); // Verify package was actually installed @@ -223,10 +242,12 @@ describe("E2E: pip coverage", () => { // Clear cache to force network download through proxy await shell.runCommand("pip3 cache purge"); - const result = await shell.runCommand('pip3 install --break-system-packages certifi'); + const result = await shell.runCommand( + "pip3 install --break-system-packages certifi" + ); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); @@ -238,7 +259,9 @@ describe("E2E: pip coverage", () => { // Should NOT contain SSL or certificate errors assert.ok( - !result.output.match(/SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i), + !result.output.match( + /SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i + ), `Should not have SSL/certificate errors. Output was:\n${result.output}` ); }); @@ -247,17 +270,20 @@ describe("E2E: pip coverage", () => { const shell = await container.openShell("zsh"); // Test installing from a direct HTTPS URL (not a registry) // This validates that non-registry HTTPS traffic works with our env-provided CA bundle - const result = await shell.runCommand('pip3 install --break-system-packages https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl'); + const result = await shell.runCommand( + "pip3 install --break-system-packages https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl" + ); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); // Since this is from pythonhosted.org, it should be MITM'd by safe-chain // But the certificate validation should still work assert.ok( - result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"), + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), `Installation from direct HTTPS URL failed. Output was:\n${result.output}` ); }); @@ -267,24 +293,28 @@ describe("E2E: pip coverage", () => { // Use Test PyPI which is NOT in knownPipRegistries // This tests tunneled HTTPS with our env-provided CA bundle (Safe Chain CA + Mozilla + Node roots) // If the CA bundle doesn't include public roots, this will fail with CERTIFICATE_VERIFY_FAILED - const result = await shell.runCommand('pip3 install --break-system-packages --index-url https://test.pypi.org/simple certifi'); + const result = await shell.runCommand( + "pip3 install --break-system-packages --index-url https://test.pypi.org/simple certifi" + ); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); // Should succeed if CA bundle properly handles tunneled hosts assert.ok( - result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"), + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), `Installation from Test PyPI failed. This may indicate the CA bundle lacks public roots. Output was:\n${result.output}` ); // Should NOT contain certificate verification errors assert.ok( - !result.output.match(/SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i), + !result.output.match( + /SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i + ), `Should not have SSL/certificate errors for tunneled hosts. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js index 339a5e0..6b92399 100644 --- a/test/e2e/pnpm-ci.e2e.spec.js +++ b/test/e2e/pnpm-ci.e2e.spec.js @@ -36,7 +36,7 @@ describe("E2E: pnpm coverage", () => { const result = await shell.runCommand("pnpm add axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index c0187d7..944530c 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -31,7 +31,7 @@ describe("E2E: pnpm coverage", () => { const result = await shell.runCommand("pnpm add axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/yarn-ci.e2e.spec.js b/test/e2e/yarn-ci.e2e.spec.js index 33ef4f2..8aac426 100644 --- a/test/e2e/yarn-ci.e2e.spec.js +++ b/test/e2e/yarn-ci.e2e.spec.js @@ -36,7 +36,7 @@ describe("E2E: yarn coverage", () => { const result = await shell.runCommand("yarn add axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index 3909318..32a8114 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -31,7 +31,7 @@ describe("E2E: yarn coverage", () => { const result = await shell.runCommand("yarn add axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); From 9c23345f1ca79fdf1df232825f25b888dd894c3f Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 07:29:57 -0800 Subject: [PATCH 3/8] Add flags to prevent errors in Docker image --- test/e2e/pip.e2e.spec.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index c647d30..b61c602 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -28,7 +28,7 @@ describe("E2E: pip coverage", () => { it(`successfully installs known safe packages with pip3`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand("pip3 install requests"); + const result = await shell.runCommand("pip3 install --break-system-packages requests"); assert.ok( result.output.includes("no malicious packages found."), @@ -38,7 +38,7 @@ describe("E2E: pip coverage", () => { it(`pip3 download`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand("pip3 download requests"); + const result = await shell.runCommand("pip3 download --break-system-packages requests"); assert.ok( result.output.includes("no malicious packages found."), @@ -48,7 +48,7 @@ describe("E2E: pip coverage", () => { it(`pip3 .whl`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand("pip3 wheel requests"); + const result = await shell.runCommand("pip3 wheel --break-system-packagesrequests"); assert.ok( result.output.includes("no malicious packages found."), @@ -58,7 +58,7 @@ describe("E2E: pip coverage", () => { it(`pip3 install --dry-run is respected by scanner`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand("pip3 install --dry-run requests"); + const result = await shell.runCommand("pip3 install --dry-run --break-system-packages requests"); assert.ok( result.output.includes("no malicious packages found."), @@ -68,7 +68,7 @@ describe("E2E: pip coverage", () => { it(`pip3 install with extras such as requests[socks]`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand('pip3 install "requests[socks]==2.32.3"'); + const result = await shell.runCommand('pip3 install --break-system-packages "requests[socks]==2.32.3"'); assert.ok( result.output.includes("no malicious packages found."), @@ -78,7 +78,7 @@ describe("E2E: pip coverage", () => { it(`pip3 install with range version specifier`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand('pip3 install "Jinja2>=3.1,<3.2"'); + const result = await shell.runCommand('pip3 install --break-system-packages "Jinja2>=3.1,<3.2"'); assert.ok( result.output.includes("no malicious packages found."), @@ -88,7 +88,7 @@ describe("E2E: pip coverage", () => { it(`python3 -m pip install routes through safe-chain`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand('python3 -m pip install requests'); + const result = await shell.runCommand('python3 -m pip install --break-system-packages requests'); assert.ok( result.output.includes("no malicious packages found."), @@ -98,7 +98,7 @@ describe("E2E: pip coverage", () => { it(`python3 -m pip download routes through safe-chain`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand('python3 -m pip download requests'); + const result = await shell.runCommand('python3 -m pip download --break-system-packagesrequests'); assert.ok( result.output.includes("no malicious packages found."), From 9f0f50eb15ae39628e2e7cedf3c7718855035b86 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 07:57:29 -0800 Subject: [PATCH 4/8] Small fix --- test/e2e/pip.e2e.spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index b61c602..03a6e3b 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -38,7 +38,7 @@ describe("E2E: pip coverage", () => { it(`pip3 download`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand("pip3 download --break-system-packages requests"); + const result = await shell.runCommand("pip3 download requests"); assert.ok( result.output.includes("no malicious packages found."), @@ -48,7 +48,7 @@ describe("E2E: pip coverage", () => { it(`pip3 .whl`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand("pip3 wheel --break-system-packagesrequests"); + const result = await shell.runCommand("pip3 wheel requests"); assert.ok( result.output.includes("no malicious packages found."), @@ -98,7 +98,7 @@ describe("E2E: pip coverage", () => { it(`python3 -m pip download routes through safe-chain`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand('python3 -m pip download --break-system-packagesrequests'); + const result = await shell.runCommand('python3 -m pip download requests'); assert.ok( result.output.includes("no malicious packages found."), From f0a3ae51dba9a6586fd49970c0cd0bde05c9b694 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 08:34:40 -0800 Subject: [PATCH 5/8] Only use mitm for pip packages --- .../pip/createPackageManager.js | 70 +------ .../pip/createPackageManager.spec.js | 14 +- .../commandArgumentScanner.js | 77 -------- .../commandArgumentScanner.spec.js | 144 -------------- .../parsing/parsePackagesFromInstallArgs.js | 179 ------------------ .../parsePackagesFromInstallArgs.spec.js | 110 ----------- .../packagemanager/pip/utils/pipCommands.js | 30 --- .../pip/utils/pipCommands.spec.js | 83 -------- 8 files changed, 9 insertions(+), 698 deletions(-) delete mode 100644 packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.js delete mode 100644 packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.spec.js delete mode 100644 packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.js delete mode 100644 packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.spec.js delete mode 100644 packages/safe-chain/src/packagemanager/pip/utils/pipCommands.js delete mode 100644 packages/safe-chain/src/packagemanager/pip/utils/pipCommands.spec.js diff --git a/packages/safe-chain/src/packagemanager/pip/createPackageManager.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.js index af3036f..cb5484d 100644 --- a/packages/safe-chain/src/packagemanager/pip/createPackageManager.js +++ b/packages/safe-chain/src/packagemanager/pip/createPackageManager.js @@ -1,79 +1,15 @@ -import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js"; import { runPip } from "./runPipCommand.js"; -import { - getPipCommandForArgs, - pipInstallCommand, - pipDownloadCommand, - pipWheelCommand, -} from "./utils/pipCommands.js"; /** * @param {string} [command] * @returns {import("../currentPackageManager.js").PackageManager} */ export function createPipPackageManager(command = "pip") { - /** - * @param {string[]} args - * @returns {boolean} - */ - function isSupportedCommand(args) { - const scanner = findDependencyScannerForCommand( - commandScannerMapping, - args - ); - return scanner.shouldScan(args); - } - - /** - * @param {string[]} args - * @returns {ReturnType} - */ - function getDependencyUpdatesForCommand(args) { - const scanner = findDependencyScannerForCommand( - commandScannerMapping, - args - ); - return scanner.scan(args); - } - return { runCommand: /** @param {string[]} args */ (args) => runPip(command, args), - isSupportedCommand, - getDependencyUpdatesForCommand, + // For pip, rely solely on MITM proxy to detect/deny downloads from known registries. + isSupportedCommand: () => false, + getDependencyUpdatesForCommand: () => [], }; } -/** - * @type {Record} - */ -const commandScannerMapping = { - [pipInstallCommand]: commandArgumentScanner(), - [pipDownloadCommand]: commandArgumentScanner(), // download also fetches packages from PyPI - [pipWheelCommand]: commandArgumentScanner(), // wheel downloads and builds packages - // Other commands return null scanner by default -}; - -/** - * @returns {import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner} - */ -function nullScanner() { - return { - shouldScan: () => false, - scan: () => [], - }; -} - -/** - * @param {Record} scanners - * @param {string[]} args - * @returns {import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner} - */ -function findDependencyScannerForCommand(scanners, args) { - const command = getPipCommandForArgs(args); - if (!command) { - return nullScanner(); - } - - const scanner = scanners[command]; - return scanner || nullScanner(); -} diff --git a/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js index 2d38b0d..69fc242 100644 --- a/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js +++ b/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js @@ -19,10 +19,10 @@ test("createPipPackageManager", async (t) => { await t.test("should support install, download, and wheel commands", () => { const pm = createPipPackageManager(); - - assert.strictEqual(pm.isSupportedCommand(["install", "requests"]), true); - assert.strictEqual(pm.isSupportedCommand(["download", "requests"]), true); - assert.strictEqual(pm.isSupportedCommand(["wheel", "requests"]), true); + // With MITM-only approach, pip does not pre-scan by args + assert.strictEqual(pm.isSupportedCommand(["install", "requests"]), false); + assert.strictEqual(pm.isSupportedCommand(["download", "requests"]), false); + assert.strictEqual(pm.isSupportedCommand(["wheel", "requests"]), false); }); await t.test("should not support uninstall and info commands", () => { @@ -35,12 +35,10 @@ test("createPipPackageManager", async (t) => { await t.test("should extract packages from install command", () => { const pm = createPipPackageManager(); - + // MITM-only: no dependency extraction from args const result = pm.getDependencyUpdatesForCommand(["install", "requests==2.28.0"]); assert.ok(Array.isArray(result)); - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].name, "requests"); - assert.strictEqual(result[0].version, "2.28.0"); + assert.strictEqual(result.length, 0); }); await t.test("should return empty array for unsupported commands", () => { diff --git a/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.js b/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.js deleted file mode 100644 index 27a07c2..0000000 --- a/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.js +++ /dev/null @@ -1,77 +0,0 @@ -import { parsePackagesFromInstallArgs } from "../parsing/parsePackagesFromInstallArgs.js"; -import { hasDryRunArg } from "../utils/pipCommands.js"; - -/** - * @typedef {Object} ScanResult - * @property {string} name - * @property {string} version - * @property {string} type - */ - -/** - * @typedef {Object} ScannerOptions - * @property {boolean} [ignoreDryRun] - */ - -/** - * @typedef {Object} CommandArgumentScanner - * @property {(args: string[]) => Promise | ScanResult[]} scan - * @property {(args: string[]) => boolean} shouldScan - */ - -/** - * @param {ScannerOptions} [options] - * - * @returns {CommandArgumentScanner} - */ -export function commandArgumentScanner(options = {}) { - const { ignoreDryRun = false } = options; - - /** - * @param {string[]} args - */ - function shouldScan(args) { - return shouldScanDependencies(args, ignoreDryRun); - } - - /** - * @param {string[]} args - * @returns {Promise | ScanResult[]} - */ - function scan(args) { - return scanDependencies(args); - } - - return { - shouldScan, - scan, - }; -} - -/** - * @param {string[]} args - * @param {boolean} ignoreDryRun - */ -function shouldScanDependencies(args, ignoreDryRun) { - return ignoreDryRun || !hasDryRunArg(args); -} - -/** - * @param {string[]} args - * @returns {Promise | ScanResult[]} - */ -function scanDependencies(args) { - return checkChangesFromArgs(args); -} - -/** - * @param {string[]} args - * @returns {Promise | ScanResult[]} - */ -export function checkChangesFromArgs(args) { - const packageUpdates = parsePackagesFromInstallArgs(args); - - // Parser already provides exact versions or "latest", no need to resolve - // Just return the packages with type "add" - return packageUpdates; -} diff --git a/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.spec.js b/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.spec.js deleted file mode 100644 index 9570756..0000000 --- a/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.spec.js +++ /dev/null @@ -1,144 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert"; -import { commandArgumentScanner, checkChangesFromArgs } from "./commandArgumentScanner.js"; - -test("commandArgumentScanner factory", async (t) => { - await t.test("should create scanner with required interface", () => { - const scanner = commandArgumentScanner(); - - assert.ok(scanner); - assert.strictEqual(typeof scanner.shouldScan, "function"); - assert.strictEqual(typeof scanner.scan, "function"); - }); -}); - -test("shouldScan", async (t) => { - await t.test("should return true for normal install command", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.shouldScan(["install", "requests"]); - assert.strictEqual(result, true); - }); - - await t.test("should return false for install with --dry-run", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.shouldScan(["install", "--dry-run", "requests"]); - assert.strictEqual(result, false); - }); - - await t.test("should return true for install with --dry-run when ignoreDryRun is true", () => { - const scanner = commandArgumentScanner({ ignoreDryRun: true }); - - const result = scanner.shouldScan(["install", "--dry-run", "requests"]); - assert.strictEqual(result, true); - }); -}); - -test("scan", async (t) => { - await t.test("should scan simple package installation", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.scan(["install", "requests"]); - assert.ok(Array.isArray(result)); - assert.strictEqual(result.length, 1); - assert.deepEqual(result[0], { - name: "requests", - version: "latest", - type: "add", - }); - }); - - await t.test("should scan package with exact version", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.scan(["install", "requests==2.28.0"]); - assert.strictEqual(result.length, 1); - assert.deepEqual(result[0], { - name: "requests", - version: "2.28.0", - type: "add", - }); - }); - - await t.test("should scan multiple packages", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.scan(["install", "requests==2.28.0", "flask"]); - assert.strictEqual(result.length, 2); - assert.deepEqual(result[0], { - name: "requests", - version: "2.28.0", - type: "add", - }); - assert.deepEqual(result[1], { - name: "flask", - version: "latest", - type: "add", - }); - }); - - await t.test("should skip packages with range specifiers", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.scan(["install", "requests>=2.0.0", "flask==2.0.0"]); - assert.strictEqual(result.length, 1); - assert.deepEqual(result[0], { - name: "flask", - version: "2.0.0", - type: "add", - }); - }); - - await t.test("should skip flags with parameters", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.scan([ - "install", - "-r", - "requirements.txt", - "requests==2.28.0", - ]); - assert.strictEqual(result.length, 1); - assert.deepEqual(result[0], { - name: "requests", - version: "2.28.0", - type: "add", - }); - }); - - await t.test("should handle === exact version specifier", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.scan(["install", "requests===2.28.0"]); - assert.strictEqual(result.length, 1); - assert.deepEqual(result[0], { - name: "requests", - version: "2.28.0", - type: "add", - }); - }); -}); - -test("checkChangesFromArgs helper", async (t) => { - await t.test("should extract packages from args", () => { - const result = checkChangesFromArgs(["install", "requests==2.28.0", "flask"]); - - assert.strictEqual(result.length, 2); - assert.deepEqual(result[0], { - name: "requests", - version: "2.28.0", - type: "add", - }); - assert.deepEqual(result[1], { - name: "flask", - version: "latest", - type: "add", - }); - }); - - await t.test("should handle empty args", () => { - const result = checkChangesFromArgs([]); - assert.deepStrictEqual(result, []); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.js b/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.js deleted file mode 100644 index ac3d99f..0000000 --- a/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.js +++ /dev/null @@ -1,179 +0,0 @@ -/** - * @typedef {Object} PackageDetail - * @property {string} name - * @property {string} version - * @property {string} type - */ - -/** - * @typedef {Object} PipOption - * @property {string} name - * @property {number} numberOfParameters - */ - -/** - * Supported formats that will be returned: - * - package_name (no version) - * - package_name==version (exact version) - * - package_name===version (exact version, PEP 440) - * - * Ranges: Because they don't specify an exact version, the following formats are skipped and we rely on the MITM scanner: - * - package_name>=version - * - package_name<=version - * - package_name>version - * - package_name= 0 ? spec.indexOf("]", extrasStart) : -1; - let base = spec; - if (extrasStart >= 0 && extrasEnd > extrasStart) { - base = spec.slice(0, extrasStart) + spec.slice(extrasEnd + 1); - } - - // Split on first occurrence of a comparator or comma spec - // Support multi-constraint lists like ">=1,<2" by detecting the first comparator - const comparatorRegex = /(===|==|!=|~=|>=|<=|<|>)/; - const m = base.match(comparatorRegex); - if (!m) { - // No comparator => just a name, use "latest" as version - return { name: base, version: "latest" }; - } - - const idx = m.index; - const name = base.slice(0, idx); - const versionPart = base.slice(idx); // e.g. '==2.28.0' or '>=1,<2' - - // Normalize whitespace inside versionPart - const versionWithOperator = versionPart.replace(/\s+/g, ""); - - // Only return packages with exact version specifiers (== or ===) - // Skip range specifiers (<, >, <=, >=, ~=, !=) since they don't provide a specific version - if (!versionWithOperator.startsWith("==")) { - return null; - } - - // Strip the == or === operator to get just the version number - const version = versionWithOperator.replace(/^===?/, ""); - - return { name, version }; -} diff --git a/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.spec.js b/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.spec.js deleted file mode 100644 index 8a653c9..0000000 --- a/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.spec.js +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { parsePackagesFromInstallArgs } from "./parsePackagesFromInstallArgs.js"; - -describe("parsePackagesFromInstallArgs", () => { - it("should parse simple package name", () => { - const result = parsePackagesFromInstallArgs(["install", "requests"]); - assert.deepEqual(result, [ - { name: "requests", version: "latest", type: "add" }, - ]); - }); - - it("should parse package with version specifier", () => { - const result = parsePackagesFromInstallArgs(["install", "requests==2.28.0"]); - assert.deepEqual(result, [ - { name: "requests", version: "2.28.0", type: "add" }, - ]); - }); - - it("should skip flags", () => { - const result = parsePackagesFromInstallArgs(["install", "--upgrade", "requests"]); - assert.deepEqual(result, [ - { name: "requests", version: "latest", type: "add" }, - ]); - }); - - it("should parse multiple packages", () => { - const result = parsePackagesFromInstallArgs(["install", "requests", "flask", "django==4.0"]); - assert.deepEqual(result, [ - { name: "requests", version: "latest", type: "add" }, - { name: "flask", version: "latest", type: "add" }, - { name: "django", version: "4.0", type: "add" }, - ]); - }); - - it("should parse extras and strip them from name", () => { - const result = parsePackagesFromInstallArgs(["install", "django[postgres]==4.2.1"]); - assert.deepEqual(result, [ - { name: "django", version: "4.2.1", type: "add" }, - ]); - }); - - it("should skip ranges", () => { - const result = parsePackagesFromInstallArgs(["install", "requests>=2,<3"]); - assert.deepEqual(result, []); - }); - - it("should skip packages with range specifiers", () => { - const result = parsePackagesFromInstallArgs([ - "install", - "requests>=2.0.0", - "flask>1.0", - "django<=4.0", - "numpy~=1.20", - "scipy!=1.5.0", - "pandas==1.3.0", - ]); - // Only pandas with exact version (==) should be returned - assert.deepEqual(result, [ - { name: "pandas", version: "1.3.0", type: "add" }, - ]); - }); - - it("should support === exact version specifier", () => { - const result = parsePackagesFromInstallArgs(["install", "requests===2.28.0"]); - assert.deepEqual(result, [ - { name: "requests", version: "2.28.0", type: "add" }, - ]); - }); - - it("should skip VCS/URL/path)", () => { - const result = parsePackagesFromInstallArgs([ - "install", - "git+https://github.com/pallets/flask.git", - "https://files.pythonhosted.org/packages/foo/bar.whl", - "file:/tmp/pkg.whl", - "./localpkg", - ]); - assert.deepEqual(result, []); - }); - - it("should return empty array for no packages", () => { - const result = parsePackagesFromInstallArgs(["install", "--help"]); - assert.deepEqual(result, []); - }); - - it("should skip all flags with parameters", () => { - const result = parsePackagesFromInstallArgs([ - "install", - "--target", - "/tmp/target", - "--platform", - "linux", - "--python-version", - "3.9", - "--index-url", - "https://pypi.org/simple", - "--trusted-host", - "pypi.org", - "requests==2.28.0", - "--cache-dir", - "/tmp/cache", - "flask", - ]); - assert.deepEqual(result, [ - { name: "requests", version: "2.28.0", type: "add" }, - { name: "flask", version: "latest", type: "add" }, - ]); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.js b/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.js deleted file mode 100644 index 92699ac..0000000 --- a/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.js +++ /dev/null @@ -1,30 +0,0 @@ -export const pipInstallCommand = "install"; -export const pipDownloadCommand = "download"; -export const pipWheelCommand = "wheel"; - -/** - * @param {string[]} args - * @returns {string | null} - */ -export function getPipCommandForArgs(args) { - if (!args || args.length === 0) { - return null; - } - - // The first non-flag argument is the command - for (const arg of args) { - if (!arg.startsWith("-")) { - return arg; - } - } - - return null; -} - -/** - * @param {string[]} args - * @returns {boolean} - */ -export function hasDryRunArg(args) { - return args.some((arg) => arg === "--dry-run"); -} diff --git a/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.spec.js b/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.spec.js deleted file mode 100644 index 346ad8f..0000000 --- a/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.spec.js +++ /dev/null @@ -1,83 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert"; -import { - getPipCommandForArgs, - hasDryRunArg, - pipInstallCommand, - pipDownloadCommand, - pipWheelCommand, -} from "./pipCommands.js"; - -test("getPipCommandForArgs", async (t) => { - await t.test("should return null for empty args", () => { - assert.strictEqual(getPipCommandForArgs([]), null); - }); - - await t.test("should return null for null args", () => { - assert.strictEqual(getPipCommandForArgs(null), null); - }); - - await t.test("should return the first non-flag argument", () => { - assert.strictEqual(getPipCommandForArgs(["install"]), "install"); - }); - - await t.test("should skip flags and return command", () => { - assert.strictEqual( - getPipCommandForArgs(["-v", "--verbose", "install"]), - "install" - ); - }); - - await t.test("should return install command", () => { - assert.strictEqual( - getPipCommandForArgs(["install", "requests"]), - "install" - ); - }); - - await t.test("should return uninstall command", () => { - assert.strictEqual( - getPipCommandForArgs(["uninstall", "requests"]), - "uninstall" - ); - }); - - await t.test("should return null if only flags", () => { - assert.strictEqual(getPipCommandForArgs(["--version", "-v"]), null); - }); -}); - -test("hasDryRunArg", async (t) => { - await t.test("should return false for empty args", () => { - assert.strictEqual(hasDryRunArg([]), false); - }); - - await t.test("should return true if --dry-run is present", () => { - assert.strictEqual(hasDryRunArg(["install", "--dry-run", "requests"]), true); - }); - - await t.test("should return false if --dry-run is not present", () => { - assert.strictEqual(hasDryRunArg(["install", "requests"]), false); - }); - - await t.test("should return true for --dry-run with other flags", () => { - assert.strictEqual( - hasDryRunArg(["install", "-v", "--dry-run", "--upgrade", "requests"]), - true - ); - }); -}); - -test("command constants", async (t) => { - await t.test("should have correct install command", () => { - assert.strictEqual(pipInstallCommand, "install"); - }); - - await t.test("should have correct download command", () => { - assert.strictEqual(pipDownloadCommand, "download"); - }); - - await t.test("should have correct wheel command", () => { - assert.strictEqual(pipWheelCommand, "wheel"); - }); -}); From 87606def48cd00749e27a710fbd54cad31379c8e Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 09:18:18 -0800 Subject: [PATCH 6/8] Fix comments --- .../src/packagemanager/pip/createPackageManager.spec.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js index 69fc242..d2668c0 100644 --- a/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js +++ b/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js @@ -19,7 +19,7 @@ test("createPipPackageManager", async (t) => { await t.test("should support install, download, and wheel commands", () => { const pm = createPipPackageManager(); - // With MITM-only approach, pip does not pre-scan by args + // MITM-only approach, pip does not scan args assert.strictEqual(pm.isSupportedCommand(["install", "requests"]), false); assert.strictEqual(pm.isSupportedCommand(["download", "requests"]), false); assert.strictEqual(pm.isSupportedCommand(["wheel", "requests"]), false); @@ -35,7 +35,6 @@ test("createPipPackageManager", async (t) => { await t.test("should extract packages from install command", () => { const pm = createPipPackageManager(); - // MITM-only: no dependency extraction from args const result = pm.getDependencyUpdatesForCommand(["install", "requests==2.28.0"]); assert.ok(Array.isArray(result)); assert.strictEqual(result.length, 0); From bded1fe6607dcac5a1b0c8fe272cdef7fb1375a9 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 09:28:57 -0800 Subject: [PATCH 7/8] Fix test --- test/e2e/pip.e2e.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index adabe9f..5d046a7 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -96,7 +96,7 @@ describe("E2E: pip coverage", () => { it(`python3 -m pip install routes through safe-chain`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand("python3 -m pip install requests"); + const result = await shell.runCommand("python3 -m pip install --break-system-packages requests"); assert.ok( result.output.includes("no malware found."), From 3b56a0181f43cb0dd3d5096bc8ed38ba11de14d4 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 09:55:09 -0800 Subject: [PATCH 8/8] Update comment --- packages/safe-chain/bin/safe-chain.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index b416f43..30f4086 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -54,7 +54,7 @@ function writeHelp() { ui.writeInformation( `- ${chalk.cyan( "safe-chain setup" - )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx and pip.` + )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.` ); ui.writeInformation( `- ${chalk.cyan(