From e2afcb16e34dbfe4912b112ed3a194ab39f59ede Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 30 Sep 2025 13:52:21 +0200 Subject: [PATCH 01/18] Implement a proxy blocking tarball requests for packages containing malware. --- package-lock.json | 10 ++ packages/safe-chain/package.json | 1 + packages/safe-chain/src/main.js | 30 ++++- .../npm/dependencyScanner/dryRunScanner.js | 7 +- .../dependencyScanner/dryRunScanner.spec.js | 14 +-- .../src/packagemanager/npm/runNpmCommand.js | 36 ++++-- .../src/packagemanager/pnpm/runPnpmCommand.js | 15 ++- .../src/packagemanager/yarn/runYarnCommand.js | 46 ++++++- .../safe-chain/src/registryProxy/certUtils.js | 114 +++++++++++++++++ .../src/registryProxy/mitmRequestHandler.js | 76 +++++++++++ .../src/registryProxy/parsePackageFromUrl.js | 48 +++++++ .../registryProxy/parsePackageFromUrl.spec.js | 114 +++++++++++++++++ .../src/registryProxy/registryProxy.js | 119 ++++++++++++++++++ .../src/registryProxy/tunnelRequestHandler.js | 20 +++ packages/safe-chain/src/utils/safeSpawn.js | 14 ++- test/e2e/package.json | 2 +- 16 files changed, 633 insertions(+), 33 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/certUtils.js create mode 100644 packages/safe-chain/src/registryProxy/mitmRequestHandler.js create mode 100644 packages/safe-chain/src/registryProxy/parsePackageFromUrl.js create mode 100644 packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js create mode 100644 packages/safe-chain/src/registryProxy/registryProxy.js create mode 100644 packages/safe-chain/src/registryProxy/tunnelRequestHandler.js diff --git a/package-lock.json b/package-lock.json index 4840448..f111431 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3323,6 +3323,15 @@ "node": ">= 0.6" } }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-pty": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", @@ -4878,6 +4887,7 @@ "abbrev": "3.0.1", "chalk": "5.4.1", "make-fetch-happen": "14.0.3", + "node-forge": "1.3.1", "npm-registry-fetch": "18.0.2", "ora": "8.2.0", "semver": "7.7.2" diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index 32228e7..c74d44e 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -31,6 +31,7 @@ "abbrev": "3.0.1", "chalk": "5.4.1", "make-fetch-happen": "14.0.3", + "node-forge": "1.3.1", "npm-registry-fetch": "18.0.2", "ora": "8.2.0", "semver": "7.7.2" diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 81b1b3a..9dd61d6 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -4,8 +4,13 @@ import { scanCommand, shouldScanCommand } from "./scanning/index.js"; import { ui } from "./environment/userInteraction.js"; import { getPackageManager } from "./packagemanager/currentPackageManager.js"; import { initializeCliArguments } from "./config/cliArguments.js"; +import { createSafeChainProxy } from "./registryProxy/registryProxy.js"; +import chalk from "chalk"; export async function main(args) { + const proxy = createSafeChainProxy(); + await proxy.startServer(); + try { // This parses all the --safe-chain arguments and removes them from the args array args = initializeCliArguments(args); @@ -18,6 +23,29 @@ export async function main(args) { process.exit(1); } - var result = getPackageManager().runCommand(args); + var result = await getPackageManager().runCommand(args); + + await proxy.stopServer(); + const blockedRequests = proxy.getBlockedRequests(); + if (blockedRequests.length > 0) { + ui.emptyLine(); + + ui.writeInformation( + `Safe-chain: ${chalk.bold( + `blocked ${blockedRequests.length} malicious package downloads` + )}:` + ); + + for (const req of blockedRequests) { + ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`); + } + + ui.emptyLine(); + ui.writeError("Exiting without installing malicious packages."); + ui.emptyLine(); + + process.exit(1); + } + process.exit(result.status); } diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js index 0db23cb..59cd236 100644 --- a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js +++ b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js @@ -8,7 +8,8 @@ export function dryRunScanner(scannerOptions) { shouldScan: (args) => shouldScanDependencies(scannerOptions, args), }; } -function scanDependencies(scannerOptions, args) { + +async function scanDependencies(scannerOptions, args) { let dryRunArgs = args; if (scannerOptions?.dryRunCommand) { @@ -31,8 +32,8 @@ function shouldScanDependencies(scannerOptions, args) { return true; } -function checkChangesWithDryRun(args) { - const dryRunOutput = dryRunNpmCommandAndOutput(args); +async function checkChangesWithDryRun(args) { + const dryRunOutput = await dryRunNpmCommandAndOutput(args); // 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 diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js index 4fb6272..88d7681 100644 --- a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js +++ b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js @@ -36,7 +36,7 @@ describe("dryRunScanner", async () => { })); const scanner = dryRunScanner(); - const result = scanner.scan(["audit", "fix"]); + const result = await scanner.scan(["audit", "fix"]); // Should not throw an error for audit fix commands assert.ok(Array.isArray(result)); @@ -53,8 +53,8 @@ describe("dryRunScanner", async () => { const scanner = dryRunScanner(); - assert.throws(() => { - scanner.scan(["install", "lodash"]); + await assert.rejects(async () => { + await scanner.scan(["install", "lodash"]); }, /Dry-run command failed with exit code 1/); }); @@ -67,7 +67,7 @@ describe("dryRunScanner", async () => { })); const scanner = dryRunScanner(); - const result = scanner.scan(["install", "lodash"]); + const result = await scanner.scan(["install", "lodash"]); assert.ok(Array.isArray(result)); assert.equal(mockWriteError.mock.callCount(), 0); @@ -83,8 +83,8 @@ describe("dryRunScanner", async () => { const scanner = dryRunScanner(); - assert.throws(() => { - scanner.scan(["audit", "fix"]); + await assert.rejects(async () => { + await scanner.scan(["audit", "fix"]); }, /Dry-run command failed with exit code 1/); }); }); @@ -99,7 +99,7 @@ describe("dryRunScanner", async () => { })); const scanner = dryRunScanner({ dryRunCommand: "install" }); - scanner.scan(["install-test", "lodash"]); + await scanner.scan(["install-test", "lodash"]); // Should call with "install" instead of "install-test" assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 1); diff --git a/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js b/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js index 70a0d17..26a4a9d 100644 --- a/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js +++ b/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js @@ -1,10 +1,14 @@ -import { execSync } from "child_process"; import { ui } from "../../environment/userInteraction.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -export function runNpm(args) { +export async function runNpm(args) { try { - const npmCommand = `npm ${args.join(" ")}`; - execSync(npmCommand, { stdio: "inherit" }); + const result = await safeSpawn("npm", args, { + stdio: "inherit", + env: mergeSafeChainProxyEnvironmentVariables(process.env), + }); + return { status: result.status }; } catch (error) { if (error.status) { return { status: error.status }; @@ -13,17 +17,29 @@ export function runNpm(args) { return { status: 1 }; } } - return { status: 0 }; } -export function dryRunNpmCommandAndOutput(args) { +export async function dryRunNpmCommandAndOutput(args) { try { - const npmCommand = `npm ${args.join(" ")} --ignore-scripts --dry-run`; - const output = execSync(npmCommand, { stdio: "pipe" }); - return { status: 0, output: output.toString() }; + const result = await safeSpawn( + "npm", + [...args, "--ignore-scripts", "--dry-run"], + { + stdio: "pipe", + env: mergeSafeChainProxyEnvironmentVariables(process.env), + } + ); + return { + status: result.status, + output: result.status === 0 ? result.stdout : result.stderr, + }; } catch (error) { if (error.status) { - const output = error.stdout ? error.stdout.toString() : ""; + const output = + error.stdout?.toString() ?? + error.stderr?.toString() ?? + error.message ?? + ""; return { status: error.status, output }; } else { ui.writeError("Error executing command:", error.message); diff --git a/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js b/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js index 0d5133f..794d6e3 100644 --- a/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js +++ b/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js @@ -1,13 +1,20 @@ import { ui } from "../../environment/userInteraction.js"; -import { safeSpawnSync } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; -export function runPnpmCommand(args, toolName = "pnpm") { +export async function runPnpmCommand(args, toolName = "pnpm") { try { let result; if (toolName === "pnpm") { - result = safeSpawnSync("pnpm", args, { stdio: "inherit" }); + result = await safeSpawn("pnpm", args, { + stdio: "inherit", + env: mergeSafeChainProxyEnvironmentVariables(process.env), + }); } else if (toolName === "pnpx") { - result = safeSpawnSync("pnpx", args, { stdio: "inherit" }); + result = await safeSpawn("pnpx", args, { + stdio: "inherit", + env: mergeSafeChainProxyEnvironmentVariables(process.env), + }); } else { throw new Error(`Unsupported tool name for aikido-pnpm: ${toolName}`); } diff --git a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js index c89c804..2c3795c 100644 --- a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js +++ b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js @@ -1,10 +1,17 @@ -import { execSync } from "child_process"; import { ui } from "../../environment/userInteraction.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -export function runYarnCommand(args) { +export async function runYarnCommand(args) { try { - const npxCommand = `yarn ${args.join(" ")}`; - execSync(npxCommand, { stdio: "inherit" }); + const env = mergeSafeChainProxyEnvironmentVariables(process.env); + await fixYarnProxyEnvironmentVariables(env); + + const result = await safeSpawn("yarn", args, { + stdio: "inherit", + env, + }); + return { status: result.status }; } catch (error) { if (error.status) { return { status: error.status }; @@ -13,5 +20,34 @@ export function runYarnCommand(args) { return { status: 1 }; } } - return { status: 0 }; +} + +async function fixYarnProxyEnvironmentVariables(env) { + // Yarn ignores standard proxy environment variables HTTPS_PROXY and NODE_EXTRA_CA_CERTS + + // Yarn v2/v3 and v4+ use different environment variables for proxy and CA certs + // When setting all variables, yarn returns an error about conflicting variables + // - v2/v3: "Usage Error: Unrecognized or legacy configuration settings found: httpsCaFilePath" + // - v4+: "Usage Error: Unrecognized or legacy configuration settings found: caFilePath" + + const version = await yarnVersion(); + const majorVersion = parseInt(version.split(".")[0]); + + if (majorVersion >= 4) { + env.YARN_HTTPS_PROXY = env.HTTPS_PROXY; + env.YARN_HTTPS_CA_FILE_PATH = env.NODE_EXTRA_CA_CERTS; + } else if (majorVersion === 2 || majorVersion === 3) { + env.YARN_HTTPS_PROXY = env.HTTPS_PROXY; + env.YARN_CA_FILE_PATH = env.NODE_EXTRA_CA_CERTS; + } +} + +async function yarnVersion() { + const result = await safeSpawn("yarn", ["--version"], { + stdio: "pipe", + }); + if (result.status !== 0) { + throw new Error("Failed to get yarn version"); + } + return result.stdout.trim(); } diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js new file mode 100644 index 0000000..99789f6 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -0,0 +1,114 @@ +import forge from "node-forge"; +import path from "path"; +import fs from "fs"; +import os from "os"; + +const certFolder = path.join(os.homedir(), ".safe-chain", "certs"); +const ca = loadCa(); + +const certCache = new Map(); + +export function getCaCertPath() { + return path.join(certFolder, "ca-cert.pem"); +} + +export function generateCertForHost(hostname) { + let existingCert = certCache.get(hostname); + if (existingCert) { + return existingCert; + } + + const keys = forge.pki.rsa.generateKeyPair(2048); + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = "01"; + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + 1); + + const attrs = [{ name: "commonName", value: hostname }]; + cert.setSubject(attrs); + cert.setIssuer(ca.certificate.subject.attributes); + cert.setExtensions([ + { + name: "subjectAltName", + altNames: [ + { + type: 2, // DNS + value: hostname, + }, + ], + }, + { + name: "keyUsage", + digitalSignature: true, + keyEncipherment: true, + }, + ]); + cert.sign(ca.privateKey, forge.md.sha256.create()); + + const result = { + privateKey: forge.pki.privateKeyToPem(keys.privateKey), + certificate: forge.pki.certificateToPem(cert), + }; + + certCache.set(hostname, result); + + return result; +} + +function loadCa() { + const keyPath = path.join(certFolder, "ca-key.pem"); + const certPath = path.join(certFolder, "ca-cert.pem"); + + if (fs.existsSync(keyPath) && fs.existsSync(certPath)) { + const privateKeyPem = fs.readFileSync(keyPath, "utf8"); + const certPem = fs.readFileSync(certPath, "utf8"); + const privateKey = forge.pki.privateKeyFromPem(privateKeyPem); + const certificate = forge.pki.certificateFromPem(certPem); + + // Don't return a cert that is valid for less than 1 hour + const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000); + if (certificate.validity.notAfter > oneHourFromNow) { + return { privateKey, certificate }; + } + } + + const { privateKey, certificate } = generateCa(); + fs.mkdirSync(certFolder, { recursive: true }); + fs.writeFileSync(keyPath, forge.pki.privateKeyToPem(privateKey)); + fs.writeFileSync(certPath, forge.pki.certificateToPem(certificate)); + return { privateKey, certificate }; +} + +function generateCa() { + const keys = forge.pki.rsa.generateKeyPair(2048); + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = "01"; + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + 1); + + const attrs = [{ name: "commonName", value: "safe-chain proxy" }]; + cert.setSubject(attrs); + cert.setIssuer(attrs); + cert.setExtensions([ + { + name: "basicConstraints", + cA: true, + }, + { + name: "keyUsage", + keyCertSign: true, + digitalSignature: true, + keyEncipherment: true, + }, + ]); + cert.sign(keys.privateKey, forge.md.sha256.create()); + + return { + privateKey: keys.privateKey, + certificate: cert, + }; +} diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js new file mode 100644 index 0000000..3958a70 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -0,0 +1,76 @@ +import https from "https"; +import { generateCertForHost } from "./certUtils.js"; +import chalk from "chalk"; + +export function mitmConnect(req, clientSocket, isAllowed) { + const { hostname } = new URL(`http://${req.url}`); + + const server = createHttpsServer(hostname, isAllowed); + + // Establish the connection + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + + // Hand off the socket to the HTTPS server + server.emit("connection", clientSocket); +} + +function createHttpsServer(hostname, isAllowed) { + const cert = generateCertForHost(hostname); + + async function handleRequest(req, res) { + const targetUrl = `https://${hostname}${req.url}`; + + if (!(await isAllowed(targetUrl))) { + res.writeHead(403, "Forbidden - blocked by safe-chain"); + res.end("Blocked by safe-chain"); + return; + } + + // Collect request body + forwardRequest(req, hostname, res); + } + + return https.createServer( + { + key: cert.privateKey, + cert: cert.certificate, + }, + handleRequest + ); +} + +function forwardRequest(req, hostname, res) { + const proxyReq = createProxyRequest(hostname, req, res); + + proxyReq.on("error", () => { + res.writeHead(502); + res.end("Bad Gateway"); + }); + + req.on("data", (chunk) => { + proxyReq.write(chunk); + }); + + req.on("end", () => { + proxyReq.end(); + }); +} + +function createProxyRequest(hostname, req, res) { + const options = { + hostname: hostname, + port: 443, + path: req.url, + method: req.method, + headers: { ...req.headers }, + }; + + delete options.headers.host; + + const proxyReq = https.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + }); + + return proxyReq; +} diff --git a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js new file mode 100644 index 0000000..7368b35 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js @@ -0,0 +1,48 @@ +export const knownRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; + +export function parsePackageFromUrl(url) { + let packageName, version, registry; + + for (const knownRegistry of knownRegistries) { + if (url.includes(knownRegistry)) { + registry = knownRegistry; + break; + } + } + + if (!registry || !url.endsWith(".tgz")) { + return { packageName, version }; + } + + const registryIndex = url.indexOf(registry); + const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash + + const separatorIndex = afterRegistry.indexOf("/-/"); + if (separatorIndex === -1) { + return { packageName, version }; + } + + packageName = afterRegistry.substring(0, separatorIndex); + const filename = afterRegistry.substring( + separatorIndex + 3, + afterRegistry.length - 4 + ); // Remove /-/ and .tgz + + // Extract version from filename + // For scoped packages like @babel/core, the filename is core-7.21.4.tgz + // For regular packages like lodash, the filename is lodash-4.17.21.tgz + if (packageName.startsWith("@")) { + const scopedPackageName = packageName.substring( + packageName.lastIndexOf("/") + 1 + ); + if (filename.startsWith(scopedPackageName + "-")) { + version = filename.substring(scopedPackageName.length + 1); + } + } else { + if (filename.startsWith(packageName + "-")) { + version = filename.substring(packageName.length + 1); + } + } + + return { packageName, version }; +} diff --git a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js new file mode 100644 index 0000000..0b8f700 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js @@ -0,0 +1,114 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { parsePackageFromUrl } from "./parsePackageFromUrl.js"; + +describe("parsePackageFromUrl", () => { + const testCases = [ + // Regular packages + { + url: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + expected: { packageName: "lodash", version: "4.17.21" }, + }, + { + url: "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + expected: { packageName: "express", version: "4.18.2" }, + }, + // Packages with hyphens in name + { + url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-1.0.0.tgz", + expected: { packageName: "safe-chain-test", version: "1.0.0" }, + }, + { + url: "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.0.tgz", + expected: { packageName: "web-vitals", version: "3.5.0" }, + }, + // Preview/prerelease versions + { + url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-0.0.1-security.tgz", + expected: { packageName: "safe-chain-test", version: "0.0.1-security" }, + }, + { + url: "https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz", + expected: { packageName: "lodash", version: "5.0.0-beta.1" }, + }, + { + url: "https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz", + expected: { packageName: "react", version: "18.3.0-canary-abc123" }, + }, + // Scoped packages + { + url: "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz", + expected: { packageName: "@babel/core", version: "7.21.4" }, + }, + { + url: "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", + expected: { packageName: "@types/node", version: "20.10.5" }, + }, + { + url: "https://registry.npmjs.org/@angular/common/-/common-17.0.8.tgz", + expected: { packageName: "@angular/common", version: "17.0.8" }, + }, + // Scoped packages with hyphens + { + url: "https://registry.npmjs.org/@safe-chain/test-package/-/test-package-2.1.0.tgz", + expected: { packageName: "@safe-chain/test-package", version: "2.1.0" }, + }, + { + url: "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.465.0.tgz", + expected: { packageName: "@aws-sdk/client-s3", version: "3.465.0" }, + }, + // Scoped packages with preview versions + { + url: "https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz", + expected: { packageName: "@babel/core", version: "8.0.0-alpha.1" }, + }, + { + url: "https://registry.npmjs.org/@safe-chain/security-test/-/security-test-1.0.0-security.tgz", + expected: { + packageName: "@safe-chain/security-test", + version: "1.0.0-security", + }, + }, + // Yarn registry + { + url: "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz", + expected: { packageName: "lodash", version: "4.17.21" }, + }, + { + url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz", + expected: { packageName: "@babel/core", version: "7.21.4" }, + }, + // Invalid URLs should return undefined values + { + url: "https://example.com/package.tgz", + expected: { packageName: undefined, version: undefined }, + }, + // URL to get package info, not tarball + { + url: "https://registry.npmjs.org/lodash", + expected: { packageName: undefined, version: undefined }, + }, + // Complex version patterns + { + url: "https://registry.npmjs.org/package-with-many-hyphens/-/package-with-many-hyphens-1.0.0-rc.1+build.123.tgz", + expected: { + packageName: "package-with-many-hyphens", + version: "1.0.0-rc.1+build.123", + }, + }, + { + url: "https://registry.npmjs.org/@scope/package-name-with-hyphens/-/package-name-with-hyphens-2.0.0-beta.2.tgz", + expected: { + packageName: "@scope/package-name-with-hyphens", + version: "2.0.0-beta.2", + }, + }, + ]; + + testCases.forEach(({ url, expected }, index) => { + it(`should parse URL ${index + 1}: ${url}`, () => { + const result = parsePackageFromUrl(url); + assert.deepEqual(result, expected); + }); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js new file mode 100644 index 0000000..fb83c0e --- /dev/null +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -0,0 +1,119 @@ +import * as http from "http"; +import { tunnelRequest } from "./tunnelRequestHandler.js"; +import { mitmConnect } from "./mitmRequestHandler.js"; +import { getCaCertPath } from "./certUtils.js"; +import { auditChanges } from "../scanning/audit/index.js"; +import { knownRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js"; + +const state = { + port: null, + blockedRequests: [], +}; + +export function createSafeChainProxy() { + const server = createProxyServer(); + server.on("connect", handleConnect); + + return { + startServer: () => startServer(server), + stopServer: () => stopServer(server), + getBlockedRequests: () => state.blockedRequests, + }; +} + +function getSafeChainProxyEnvironmentVariables() { + return { + HTTPS_PROXY: `http://localhost:${state.port}`, + GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`, + NODE_EXTRA_CA_CERTS: getCaCertPath(), + }; +} + +export function mergeSafeChainProxyEnvironmentVariables(env) { + const proxyEnv = getSafeChainProxyEnvironmentVariables(); + + for (const key of Object.keys(env)) { + // If we were to simply copy all env variables, we might overwrite + // the proxy settings set by safe-chain when casing varies (e.g. http_proxy vs HTTP_PROXY) + // So we only copy the variable if it's not already set in a different case + const upperKey = key.toUpperCase(); + + if (!proxyEnv[upperKey]) { + proxyEnv[upperKey] = env[key]; + } + } + + return proxyEnv; +} + +function createProxyServer() { + const server = http.createServer((_, res) => { + res.writeHead(400, "Bad Request"); + res.write( + "Safe-chain proxy: Direct http not supported. Only CONNECT requests are allowed." + ); + res.end(); + }); + + return server; +} + +function startServer(server) { + return new Promise((resolve, reject) => { + server.listen(0, () => { + const address = server.address(); + if (address && typeof address === "object") { + state.port = address.port; + resolve(); + } else { + reject(new Error("Failed to start proxy server")); + } + }); + + server.on("error", (err) => { + reject(err); + }); + }); +} + +function stopServer(server) { + return new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); +} + +function handleConnect(req, clientSocket, head) { + // CONNECT method is used for HTTPS requests + // It establishes a tunnel to the server identified by the request URL + + if (knownRegistries.some((reg) => req.url.includes(reg))) { + // For npm and yarn registries, we want to intercept and inspect the traffic + // so we can block packages with malware + mitmConnect(req, clientSocket, isAllowedUrl); + } else { + // For other hosts, just tunnel the request to the destination tcp socket + tunnelRequest(req, clientSocket, head); + } +} + +async function isAllowedUrl(url) { + const { packageName, version } = parsePackageFromUrl(url); + + // This happens when the URL is not a tarball download url. + if (!packageName || !version) { + return true; + } + + const auditResult = await auditChanges([ + { name: packageName, version, type: "add" }, + ]); + + if (!auditResult.isAllowed) { + state.blockedRequests.push({ packageName, version, url }); + return false; + } + + return true; +} diff --git a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js new file mode 100644 index 0000000..8579729 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js @@ -0,0 +1,20 @@ +import * as net from "net"; +import { ui } from "../environment/userInteraction.js"; + +export function tunnelRequest(req, clientSocket, head) { + const { port, hostname } = new URL(`http://${req.url}`); + + const serverSocket = net.connect(port || 443, hostname, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + serverSocket.write(head); + serverSocket.pipe(clientSocket); + clientSocket.pipe(serverSocket); + }); + + serverSocket.on("error", (err) => { + ui.writeError( + `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}` + ); + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + }); +} diff --git a/packages/safe-chain/src/utils/safeSpawn.js b/packages/safe-chain/src/utils/safeSpawn.js index 826ab7d..5af4740 100644 --- a/packages/safe-chain/src/utils/safeSpawn.js +++ b/packages/safe-chain/src/utils/safeSpawn.js @@ -22,12 +22,22 @@ export async function safeSpawn(command, args, options = {}) { const fullCommand = buildCommand(command, args); return new Promise((resolve, reject) => { const child = spawn(fullCommand, { ...options, shell: true }); + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); child.on("close", (code) => { resolve({ status: code, - stdout: Buffer.from(""), - stderr: Buffer.from(""), + stdout: stdout, + stderr: stderr, }); }); diff --git a/test/e2e/package.json b/test/e2e/package.json index 1dc6e8a..9217808 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "description": "End-to-end tests for the Aikido Safe Chain", "scripts": { - "test": "node --test **/*.spec.js" + "test": "node --test --test-concurrency=1 **/*.spec.js" }, "keywords": [], "author": "Aikido Security", From a3f91b8b5520f13ce6c39ec9458e088253c978a5 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 30 Sep 2025 13:53:59 +0200 Subject: [PATCH 02/18] Fix linting issue --- packages/safe-chain/src/registryProxy/mitmRequestHandler.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 3958a70..6a72879 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -1,6 +1,5 @@ import https from "https"; import { generateCertForHost } from "./certUtils.js"; -import chalk from "chalk"; export function mitmConnect(req, clientSocket, isAllowed) { const { hostname } = new URL(`http://${req.url}`); From 6c08c6adce62b0757f742f32b1e0bb2eecb8020c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 30 Sep 2025 15:03:49 +0200 Subject: [PATCH 03/18] Add end-to-end tests for proxy blocking malware packages --- .github/workflows/test-on-pr.yml | 2 +- test/e2e/npm.e2e.spec.js | 46 ++++++++++++++++++++++++++++++++ test/e2e/pnpm.e2e.spec.js | 22 +++++++++++++++ test/e2e/yarn.e2e.spec.js | 46 ++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index 740d741..59d0549 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -82,7 +82,7 @@ jobs: # EOL compatibility testing - Node 16 (EOL Sept 2023) - node_version: "16" npm_version: "8.0.0" - yarn_version: "3.6.0" + yarn_version: "1.22.0" pnpm_version: "8.0.0" steps: diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index 0e64971..fc23ebb 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -60,6 +60,52 @@ describe("E2E: npm coverage", () => { ); }); + it(`safe-chain blocks download of malicious packages already in package.json`, async () => { + const shell = await container.openShell("zsh"); + const npmVersion = (await shell.runCommand("npm --version")).output.trim(); + const majorVersion = parseInt(npmVersion.split(".")[0]); + const minorVersion = parseInt(npmVersion.split(".")[1]); + const isBelow10_4 = + majorVersion < 10 || (majorVersion === 10 && minorVersion < 4); + await shell.runCommand( + 'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json' + ); + + var result = await shell.runCommand("npm install"); + + if (isBelow10_4) { + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes( + "Exiting without installing malicious packages." + ), + `Output did not include expected text. Output was:\n${result.output}` + ); + } else { + assert.ok( + result.output.includes("Malicious changes detected:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes( + "Exiting without installing malicious packages." + ), + `Output did not include expected text. Output was:\n${result.output}` + ); + } + }); + it("safe-chain blocks npx from executing malicious packages", async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand("npx safe-chain-test"); diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index db7eb58..c9460e6 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -60,6 +60,28 @@ describe("E2E: pnpm coverage", () => { ); }); + it(`safe-chain blocks download of malicious packages already in package.json`, async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand( + 'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json' + ); + + var result = await shell.runCommand("pnpm install"); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + it("safe-chain blocks pnpx from executing malicious packages", async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand("pnpx safe-chain-test"); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index fb22b76..df8a88b 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -60,6 +60,52 @@ describe("E2E: yarn coverage", () => { ); }); + it(`safe-chain blocks download of malicious packages already in package.json`, async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand( + 'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json' + ); + + var result = await shell.runCommand("yarn"); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks installation of malicious packages`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("yarn add safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + const listResult = await shell.runCommand("yarn list"); + assert.ok( + !listResult.output.includes("safe-chain-test"), + `Malicious package was installed despite safe-chain protection. Output of 'yarn list' was:\n${listResult.output}` + ); + }); + it("safe-chain blocks yarn dlx from executing malicious packages", async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand("yarn dlx safe-chain-test"); From 3b145a4695363ea584e225786d6f5164be524d5f Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 30 Sep 2025 15:11:00 +0200 Subject: [PATCH 04/18] Create verifyNoMaliciousPackages function in proxy --- packages/safe-chain/src/main.js | 22 +-------------- .../src/registryProxy/registryProxy.js | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 9dd61d6..1d259c9 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -5,7 +5,6 @@ import { ui } from "./environment/userInteraction.js"; import { getPackageManager } from "./packagemanager/currentPackageManager.js"; import { initializeCliArguments } from "./config/cliArguments.js"; import { createSafeChainProxy } from "./registryProxy/registryProxy.js"; -import chalk from "chalk"; export async function main(args) { const proxy = createSafeChainProxy(); @@ -26,26 +25,7 @@ export async function main(args) { var result = await getPackageManager().runCommand(args); await proxy.stopServer(); - const blockedRequests = proxy.getBlockedRequests(); - if (blockedRequests.length > 0) { - ui.emptyLine(); - - ui.writeInformation( - `Safe-chain: ${chalk.bold( - `blocked ${blockedRequests.length} malicious package downloads` - )}:` - ); - - for (const req of blockedRequests) { - ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`); - } - - ui.emptyLine(); - ui.writeError("Exiting without installing malicious packages."); - ui.emptyLine(); - - process.exit(1); - } + proxy.verifyNoMaliciousPackages(); process.exit(result.status); } diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index fb83c0e..c2812de 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -4,6 +4,8 @@ import { mitmConnect } from "./mitmRequestHandler.js"; import { getCaCertPath } from "./certUtils.js"; import { auditChanges } from "../scanning/audit/index.js"; import { knownRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js"; +import { ui } from "../environment/userInteraction.js"; +import chalk from "chalk"; const state = { port: null, @@ -18,6 +20,7 @@ export function createSafeChainProxy() { startServer: () => startServer(server), stopServer: () => stopServer(server), getBlockedRequests: () => state.blockedRequests, + verifyNoMaliciousPackages, }; } @@ -117,3 +120,27 @@ async function isAllowedUrl(url) { return true; } + +function verifyNoMaliciousPackages() { + if (state.blockedRequests.length === 0) { + return; + } + + ui.emptyLine(); + + ui.writeInformation( + `Safe-chain: ${chalk.bold( + `blocked ${state.blockedRequests.length} malicious package downloads` + )}:` + ); + + for (const req of state.blockedRequests) { + ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`); + } + + ui.emptyLine(); + ui.writeError("Exiting without installing malicious packages."); + ui.emptyLine(); + + process.exit(1); +} From 95663dc5f463db4693e5ca0bc334e494818575a3 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 1 Oct 2025 08:10:49 +0200 Subject: [PATCH 05/18] Fix proxy for npm 10.0.0 -> 10.4.0 --- .../src/registryProxy/mitmRequestHandler.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 6a72879..750815e 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -17,7 +17,8 @@ function createHttpsServer(hostname, isAllowed) { const cert = generateCertForHost(hostname); async function handleRequest(req, res) { - const targetUrl = `https://${hostname}${req.url}`; + const pathAndQuery = getRequestPathAndQuery(req.url); + const targetUrl = `https://${hostname}${pathAndQuery}`; if (!(await isAllowed(targetUrl))) { res.writeHead(403, "Forbidden - blocked by safe-chain"); @@ -38,6 +39,14 @@ function createHttpsServer(hostname, isAllowed) { ); } +function getRequestPathAndQuery(url) { + if (url.startsWith("http://") || url.startsWith("https://")) { + const parsedUrl = new URL(url); + return parsedUrl.pathname + parsedUrl.search + parsedUrl.hash; + } + return url; +} + function forwardRequest(req, hostname, res) { const proxyReq = createProxyRequest(hostname, req, res); From bf97f089ca06def64a778a4609ef9cc87ce84327 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 1 Oct 2025 08:36:00 +0200 Subject: [PATCH 06/18] Change npm test version to 10.2.0 --- .github/workflows/test-on-pr.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index 59d0549..7b93768 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -46,7 +46,7 @@ jobs: include: # Common production setup - node_version: "20" - npm_version: "10.0.0" + npm_version: "10.2.0" yarn_version: "4.0.0" pnpm_version: "9.0.0" # Current Active LTS with latest tools @@ -66,7 +66,7 @@ jobs: pnpm_version: "latest" # Version pinning scenario - node_version: "22" - npm_version: "10.0.0" + npm_version: "10.2.0" yarn_version: "4.0.0" pnpm_version: "9.0.0" # Backward compatibility testing From 67304751bd1128bac3e7db77e6673bdf80f1226e Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 1 Oct 2025 08:53:56 +0200 Subject: [PATCH 07/18] Handle process exit better + some PR cleanup --- packages/safe-chain/bin/aikido-npm.js | 4 +++- packages/safe-chain/bin/aikido-npx.js | 4 +++- packages/safe-chain/bin/aikido-pnpm.js | 4 +++- packages/safe-chain/bin/aikido-pnpx.js | 4 +++- packages/safe-chain/bin/aikido-yarn.js | 4 +++- packages/safe-chain/src/main.js | 2 +- .../packagemanager/npm/dependencyScanner/dryRunScanner.js | 2 +- packages/safe-chain/src/registryProxy/registryProxy.js | 6 ++++-- 8 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/safe-chain/bin/aikido-npm.js b/packages/safe-chain/bin/aikido-npm.js index 4176db1..d8b8c3e 100755 --- a/packages/safe-chain/bin/aikido-npm.js +++ b/packages/safe-chain/bin/aikido-npm.js @@ -6,7 +6,9 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa const packageManagerName = "npm"; initializePackageManager(packageManagerName, getNpmVersion()); -await main(process.argv.slice(2)); +var exitCode = await main(process.argv.slice(2)); + +process.exit(exitCode); function getNpmVersion() { try { diff --git a/packages/safe-chain/bin/aikido-npx.js b/packages/safe-chain/bin/aikido-npx.js index 067608c..7f06c7c 100755 --- a/packages/safe-chain/bin/aikido-npx.js +++ b/packages/safe-chain/bin/aikido-npx.js @@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa const packageManagerName = "npx"; initializePackageManager(packageManagerName, process.versions.node); -await main(process.argv.slice(2)); +var exitCode = await main(process.argv.slice(2)); + +process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-pnpm.js b/packages/safe-chain/bin/aikido-pnpm.js index e7bac47..7177159 100755 --- a/packages/safe-chain/bin/aikido-pnpm.js +++ b/packages/safe-chain/bin/aikido-pnpm.js @@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa const packageManagerName = "pnpm"; initializePackageManager(packageManagerName, process.versions.node); -await main(process.argv.slice(2)); +var exitCode = await main(process.argv.slice(2)); + +process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-pnpx.js b/packages/safe-chain/bin/aikido-pnpx.js index 25884ce..4bb6840 100755 --- a/packages/safe-chain/bin/aikido-pnpx.js +++ b/packages/safe-chain/bin/aikido-pnpx.js @@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa const packageManagerName = "pnpx"; initializePackageManager(packageManagerName, process.versions.node); -await main(process.argv.slice(2)); +var exitCode = await main(process.argv.slice(2)); + +process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-yarn.js b/packages/safe-chain/bin/aikido-yarn.js index a0eaaf6..002a956 100755 --- a/packages/safe-chain/bin/aikido-yarn.js +++ b/packages/safe-chain/bin/aikido-yarn.js @@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa const packageManagerName = "yarn"; initializePackageManager(packageManagerName, process.versions.node); -await main(process.argv.slice(2)); +var exitCode = await main(process.argv.slice(2)); + +process.exit(exitCode); diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 1d259c9..916a81f 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -27,5 +27,5 @@ export async function main(args) { await proxy.stopServer(); proxy.verifyNoMaliciousPackages(); - process.exit(result.status); + return result.status; } diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js index 59cd236..6189b2f 100644 --- a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js +++ b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js @@ -9,7 +9,7 @@ export function dryRunScanner(scannerOptions) { }; } -async function scanDependencies(scannerOptions, args) { +function scanDependencies(scannerOptions, args) { let dryRunArgs = args; if (scannerOptions?.dryRunCommand) { diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index c2812de..9155c27 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -42,7 +42,7 @@ export function mergeSafeChainProxyEnvironmentVariables(env) { const upperKey = key.toUpperCase(); if (!proxyEnv[upperKey]) { - proxyEnv[upperKey] = env[key]; + proxyEnv[key] = env[key]; } } @@ -104,7 +104,8 @@ function handleConnect(req, clientSocket, head) { async function isAllowedUrl(url) { const { packageName, version } = parsePackageFromUrl(url); - // This happens when the URL is not a tarball download url. + // packageName and version are undefined when the URL is not a package download + // In that case, we can allow the request to proceed if (!packageName || !version) { return true; } @@ -123,6 +124,7 @@ async function isAllowedUrl(url) { function verifyNoMaliciousPackages() { if (state.blockedRequests.length === 0) { + // No malicious packages were blocked, so nothing to block return; } From 49fd0f5928ebe4caa06fcf67b0513edb7e244205 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 1 Oct 2025 09:24:18 +0200 Subject: [PATCH 08/18] Better error-handling when stopping the proxy --- packages/safe-chain/src/registryProxy/registryProxy.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 9155c27..9880129 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -81,9 +81,14 @@ function startServer(server) { function stopServer(server) { return new Promise((resolve) => { - server.close(() => { + try { + server.close(() => { + resolve(); + }); + } catch { resolve(); - }); + } + setTimeout(() => resolve(), 1000); }); } From 60543308f444e5af972f6edf066f40e171034b4f Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 1 Oct 2025 10:01:04 +0200 Subject: [PATCH 09/18] Change validity of generateCertForHost to 1 hour. --- packages/safe-chain/src/registryProxy/certUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js index 99789f6..d5d414c 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.js +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -24,7 +24,7 @@ export function generateCertForHost(hostname) { cert.serialNumber = "01"; cert.validity.notBefore = new Date(); cert.validity.notAfter = new Date(); - cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + 1); + cert.validity.notAfter.setHours(cert.validity.notBefore.getHours() + 1); const attrs = [{ name: "commonName", value: hostname }]; cert.setSubject(attrs); From a6980d5108e589cc712d50ece7d664f18667f663 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 2 Oct 2025 09:06:35 +0200 Subject: [PATCH 10/18] Add upstream proxy support --- package-lock.json | 1 + packages/safe-chain/package.json | 1 + .../src/registryProxy/mitmRequestHandler.js | 6 ++ .../src/registryProxy/tunnelRequestHandler.js | 68 +++++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/package-lock.json b/package-lock.json index f111431..6b74d53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4886,6 +4886,7 @@ "dependencies": { "abbrev": "3.0.1", "chalk": "5.4.1", + "https-proxy-agent": "7.0.6", "make-fetch-happen": "14.0.3", "node-forge": "1.3.1", "npm-registry-fetch": "18.0.2", diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index c74d44e..d28fe73 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -30,6 +30,7 @@ "dependencies": { "abbrev": "3.0.1", "chalk": "5.4.1", + "https-proxy-agent": "7.0.6", "make-fetch-happen": "14.0.3", "node-forge": "1.3.1", "npm-registry-fetch": "18.0.2", diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 750815e..4be9987 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -1,5 +1,6 @@ import https from "https"; import { generateCertForHost } from "./certUtils.js"; +import { HttpsProxyAgent } from "https-proxy-agent"; export function mitmConnect(req, clientSocket, isAllowed) { const { hostname } = new URL(`http://${req.url}`); @@ -75,6 +76,11 @@ function createProxyRequest(hostname, req, res) { delete options.headers.host; + const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; + if (httpsProxy) { + options.agent = new HttpsProxyAgent(httpsProxy); + } + const proxyReq = https.request(options, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); diff --git a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js index 8579729..1609664 100644 --- a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js @@ -2,6 +2,16 @@ import * as net from "net"; import { ui } from "../environment/userInteraction.js"; export function tunnelRequest(req, clientSocket, head) { + const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; + + if (httpsProxy) { + tunnelRequestViaProxy(req, clientSocket, head, httpsProxy); + } else { + tunnelRequestToDestination(req, clientSocket, head); + } +} + +function tunnelRequestToDestination(req, clientSocket, head) { const { port, hostname } = new URL(`http://${req.url}`); const serverSocket = net.connect(port || 443, hostname, () => { @@ -18,3 +28,61 @@ export function tunnelRequest(req, clientSocket, head) { clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); }); } + +function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { + const { port, hostname } = new URL(`http://${req.url}`); + const proxy = new URL(proxyUrl); + + // Connect to proxy server + const proxySocket = net.connect({ + host: proxy.hostname, + port: proxy.port, + }); + + proxySocket.on("connect", () => { + // Send CONNECT request to proxy + const connectRequest = [ + `CONNECT ${hostname}:${port || 443} HTTP/1.1`, + `Host: ${hostname}:${port || 443}`, + "", + "", + ].join("\r\n"); + + proxySocket.write(connectRequest); + }); + + let isConnected = false; + proxySocket.once("data", (data) => { + const response = data.toString(); + + // Check if CONNECT succeeded (HTTP/1.1 200) + if (response.startsWith("HTTP/1.1 200")) { + isConnected = true; + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + proxySocket.write(head); + proxySocket.pipe(clientSocket); + clientSocket.pipe(proxySocket); + } else { + ui.writeError( + `Safe-chain: proxy CONNECT failed: ${response.split("\r\n")[0]}` + ); + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + proxySocket.end(); + } + }); + + proxySocket.on("error", (err) => { + if (!isConnected) { + ui.writeError( + `Safe-chain: error connecting to proxy ${proxy.hostname}:${ + proxy.port || 8080 + } - ${err.message}` + ); + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + } + }); + + clientSocket.on("error", () => { + proxySocket.end(); + }); +} From 53bfb14fea840adf98a37a898d6cd8f9b86ae39a Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 2 Oct 2025 09:20:59 +0200 Subject: [PATCH 11/18] Only load the malware database once --- packages/safe-chain/src/scanning/malwareDatabase.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index 26f1999..0181e5e 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -8,7 +8,13 @@ import { } from "../config/configFile.js"; import { ui } from "../environment/userInteraction.js"; +let cachedMalwareDatabase = null; + export async function openMalwareDatabase() { + if (cachedMalwareDatabase) { + return cachedMalwareDatabase; + } + const malwareDatabase = await getMalwareDatabase(); function getPackageStatus(name, version) { @@ -25,13 +31,14 @@ export async function openMalwareDatabase() { return packageData.reason; } - return { + cachedMalwareDatabase = { getPackageStatus, isMalware: (name, version) => { const status = getPackageStatus(name, version); return isMalwareStatus(status); }, }; + return cachedMalwareDatabase; } async function getMalwareDatabase() { From 32f5ef9b1678ad9565d49359d73a1193408f47fc Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 2 Oct 2025 10:47:58 +0200 Subject: [PATCH 12/18] Add e2e tests to verify existing proxy is being respected. --- test/e2e/Dockerfile | 3 ++ test/e2e/safe-chain-proxy.e2e.spec.js | 60 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 test/e2e/safe-chain-proxy.e2e.spec.js diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index a84db30..3c8ce73 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -29,6 +29,9 @@ ARG PNPM_VERSION=latest SHELL ["/bin/bash", "-c"] ENV BASH_ENV=~/.bashrc +# Install a proxy +RUN apt-get update && apt-get install tinyproxy -y + # Install zsh RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.1/zsh-in-docker.sh)" # Install fish diff --git a/test/e2e/safe-chain-proxy.e2e.spec.js b/test/e2e/safe-chain-proxy.e2e.spec.js new file mode 100644 index 0000000..6abbb0f --- /dev/null +++ b/test/e2e/safe-chain-proxy.e2e.spec.js @@ -0,0 +1,60 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: Safe chain proxy", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + // Run a new Docker container for each test + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup"); + }); + + afterEach(async () => { + // Stop and clean up the container after each test + if (container) { + await container.stop(); + container = null; + } + }); + + it(`safe-chain proxy respects upstream proxy settings`, async () => { + // Configure and start a proxy inside the container + const proxy = await container.openShell("zsh"); + await proxy.runCommand( + `echo 'BasicAuth user password' >> /etc/tinyproxy/tinyproxy.conf` + ); + await proxy.runCommand("tinyproxy"); + + const shell = await container.openShell("zsh"); + await shell.runCommand( + 'export HTTPS_PROXY="http://user:password@localhost:8888"' + ); + const { output } = await shell.runCommand("npm install axios"); + + // Check if the installation was successful + assert( + output.includes("added") || output.includes("up to date"), + "npm install did not complete successfully" + ); + + const proxyLog = await container.openShell("zsh"); + const { output: logOutput } = await proxyLog.runCommand( + "cat /var/log/tinyproxy/tinyproxy.log" + ); + + // Check if the proxy log contains entries for the npm install + assert( + logOutput.includes("CONNECT registry.npmjs.org:443"), + "Proxy log does not contain expected entries" + ); + }); +}); From ccaa7934ee94f4c382fec7cb70d9195b3d962532 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 3 Oct 2025 16:21:55 +0200 Subject: [PATCH 13/18] Improve cli output. --- packages/safe-chain/src/main.js | 8 ++++ packages/safe-chain/src/scanning/index.js | 2 +- .../src/scanning/index.scanCommand.spec.js | 44 +++++++++++-------- test/e2e/npm-ci.e2e.spec.js | 2 +- test/e2e/npm.e2e.spec.js | 2 +- 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 +- 9 files changed, 41 insertions(+), 25 deletions(-) diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 916a81f..3d4d5b1 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -5,6 +5,7 @@ import { ui } from "./environment/userInteraction.js"; import { getPackageManager } from "./packagemanager/currentPackageManager.js"; import { initializeCliArguments } from "./config/cliArguments.js"; import { createSafeChainProxy } from "./registryProxy/registryProxy.js"; +import chalk from "chalk"; export async function main(args) { const proxy = createSafeChainProxy(); @@ -27,5 +28,12 @@ export async function main(args) { await proxy.stopServer(); proxy.verifyNoMaliciousPackages(); + ui.emptyLine(); + ui.writeInformation( + `${chalk.green( + "✔" + )} Safe-chain: Command completed, no malicious packages found.` + ); + return result.status; } diff --git a/packages/safe-chain/src/scanning/index.js b/packages/safe-chain/src/scanning/index.js index 48a3e3a..d4f8613 100644 --- a/packages/safe-chain/src/scanning/index.js +++ b/packages/safe-chain/src/scanning/index.js @@ -61,7 +61,7 @@ export async function scanCommand(args) { } if (!audit || audit.isAllowed) { - spinner.succeed("Safe-chain: No malicious packages detected."); + spinner.stop(); } else { printMaliciousChanges(audit.disallowedChanges, spinner); await onMalwareFound(); diff --git a/packages/safe-chain/src/scanning/index.scanCommand.spec.js b/packages/safe-chain/src/scanning/index.scanCommand.spec.js index 715ecfb..f81a155 100644 --- a/packages/safe-chain/src/scanning/index.scanCommand.spec.js +++ b/packages/safe-chain/src/scanning/index.scanCommand.spec.js @@ -13,6 +13,7 @@ describe("scanCommand", async () => { setText: () => {}, succeed: () => {}, fail: () => {}, + stop: () => {}, })); const mockConfirm = mock.fn(() => true); let malwareAction = MALWARE_ACTION_PROMPT; @@ -88,29 +89,31 @@ describe("scanCommand", async () => { const { scanCommand } = await import("./index.js"); it("should succeed when there are no changes", async () => { - let successMessageWasSet = false; + let progressWasStopped = false; mockStartProcess.mock.mockImplementationOnce(() => ({ setText: () => {}, - succeed: () => { - successMessageWasSet = true; - }, + succeed: () => {}, fail: () => {}, + stop: () => { + progressWasStopped = true; + }, })); mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => []); await scanCommand(["install", "lodash"]); - assert.equal(successMessageWasSet, true); + assert.equal(progressWasStopped, true); }); it("should succeed when changes are not malicious", async () => { - let successMessageWasSet = false; + let progressWasStopped = false; mockStartProcess.mock.mockImplementationOnce(() => ({ setText: () => {}, - succeed: () => { - successMessageWasSet = true; - }, + succeed: () => {}, fail: () => {}, + stop: () => { + progressWasStopped = true; + }, })); mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ { name: "lodash", version: "4.17.21" }, @@ -118,7 +121,7 @@ describe("scanCommand", async () => { await scanCommand(["install", "lodash"]); - assert.equal(successMessageWasSet, true); + assert.equal(progressWasStopped, true); }); it("should throw an error when timing out", async () => { @@ -129,6 +132,7 @@ describe("scanCommand", async () => { fail: () => { failureMessageWasSet = true; }, + stop: () => {}, })); getScanTimeoutMock.mock.mockImplementationOnce(() => 100); mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => { @@ -149,6 +153,7 @@ describe("scanCommand", async () => { fail: () => { failureMessageWasSet = true; }, + stop: () => {}, })); mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ { name: "malicious", version: "1.0.0" }, @@ -173,6 +178,7 @@ describe("scanCommand", async () => { fail: (message) => { failureMessages.push(message); }, + stop: () => {}, })); getScanTimeoutMock.mock.mockImplementationOnce(() => 100); mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => { @@ -194,21 +200,22 @@ describe("scanCommand", async () => { it("should exit immediately when malicious changes are detected in block mode", async () => { // Set malware action to block mode for this test malwareAction = MALWARE_ACTION_BLOCK; - + // Reset mock call count mockConfirm.mock.resetCalls(); - + let failureMessageWasSet = false; let exitCode = null; - + mockStartProcess.mock.mockImplementationOnce(() => ({ setText: () => {}, succeed: () => {}, fail: () => { failureMessageWasSet = true; }, + stop: () => {}, })); - + mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ { name: "malicious", version: "1.0.0" }, ]); @@ -241,19 +248,20 @@ describe("scanCommand", async () => { it("should exit immediately when malicious changes are detected in block mode without prompting", async () => { // Set malware action to block mode for this test malwareAction = MALWARE_ACTION_BLOCK; - + // Reset mock call count mockConfirm.mock.resetCalls(); - + let processExited = false; let userWasPrompted = false; - + mockStartProcess.mock.mockImplementationOnce(() => ({ setText: () => {}, succeed: () => {}, fail: () => {}, + stop: () => {}, })); - + mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ { name: "malicious", version: "1.0.0" }, ]); diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js index 3e08c3d..dc1c23f 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 detected."), + result.output.includes("no malicious packages 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 fc23ebb..c744835 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 detected."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js index 9a8c6a2..339a5e0 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 detected."), + result.output.includes("no malicious packages 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 c9460e6..c0187d7 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 detected."), + result.output.includes("no malicious packages 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 5466851..33ef4f2 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 detected."), + result.output.includes("no malicious packages 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 df8a88b..3909318 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 detected."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); From 3ef4ed8bad50383b0d90b0c02027b3d3d9e86b34 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 6 Oct 2025 13:47:38 +0200 Subject: [PATCH 14/18] Update main.js code flow so proxy always gets stopped + add comment on why exit status is handled in bin/aikido-(tool).js --- packages/safe-chain/src/main.js | 44 ++++++++++++++--------- packages/safe-chain/src/scanning/index.js | 2 ++ 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 3d4d5b1..da36a59 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -16,24 +16,36 @@ export async function main(args) { args = initializeCliArguments(args); if (shouldScanCommand(args)) { - await scanCommand(args); + const resultCode = await scanCommand(args); + + // Returning the exit code back to the caller allows the promise + // to be awaited in the bin files and return the correct exit code + if (resultCode !== 0) { + return resultCode; + } } + + var result = await getPackageManager().runCommand(args); + + proxy.verifyNoMaliciousPackages(); + + ui.emptyLine(); + ui.writeInformation( + `${chalk.green( + "✔" + )} Safe-chain: Command completed, no malicious packages 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 + return result.status; } catch (error) { ui.writeError("Failed to check for malicious packages:", error.message); - process.exit(1); + + // Returning the exit code back to the caller allows the promise + // to be awaited in the bin files and return the correct exit code + return 1; + } finally { + await proxy.stopServer(); } - - var result = await getPackageManager().runCommand(args); - - await proxy.stopServer(); - proxy.verifyNoMaliciousPackages(); - - ui.emptyLine(); - ui.writeInformation( - `${chalk.green( - "✔" - )} Safe-chain: Command completed, no malicious packages found.` - ); - - return result.status; } diff --git a/packages/safe-chain/src/scanning/index.js b/packages/safe-chain/src/scanning/index.js index d4f8613..91314ae 100644 --- a/packages/safe-chain/src/scanning/index.js +++ b/packages/safe-chain/src/scanning/index.js @@ -62,9 +62,11 @@ export async function scanCommand(args) { if (!audit || audit.isAllowed) { spinner.stop(); + return 0; } else { printMaliciousChanges(audit.disallowedChanges, spinner); await onMalwareFound(); + return 1; } } From ea383a18de63edc309fad4464465c306058af783 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 6 Oct 2025 16:23:56 +0200 Subject: [PATCH 15/18] Insert proxy settings for npx as well --- .../src/packagemanager/npx/runNpxCommand.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js b/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js index cc78abb..b8896b7 100644 --- a/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js +++ b/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js @@ -1,10 +1,14 @@ -import { execSync } from "child_process"; import { ui } from "../../environment/userInteraction.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -export function runNpx(args) { +export async function runNpx(args) { try { - const npxCommand = `npx ${args.join(" ")}`; - execSync(npxCommand, { stdio: "inherit" }); + const result = await safeSpawn("npx", args, { + stdio: "inherit", + env: mergeSafeChainProxyEnvironmentVariables(process.env), + }); + return { status: result.status }; } catch (error) { if (error.status) { return { status: error.status }; @@ -13,5 +17,4 @@ export function runNpx(args) { return { status: 1 }; } } - return { status: 0 }; } From 240123372ab9ac0b469301ea5a0eec274b87bf70 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 8 Oct 2025 10:49:04 +0200 Subject: [PATCH 16/18] Handle PR Comments --- packages/safe-chain/src/main.js | 14 ++++++++------ .../safe-chain/src/registryProxy/registryProxy.js | 13 +++++++++---- packages/safe-chain/src/scanning/index.js | 7 +++---- .../safe-chain/src/scanning/malwareDatabase.js | 2 ++ packages/safe-chain/src/utils/safeSpawn.js | 2 ++ 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index da36a59..e106e83 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -16,18 +16,20 @@ export async function main(args) { args = initializeCliArguments(args); if (shouldScanCommand(args)) { - const resultCode = await scanCommand(args); + const commandScanResult = await scanCommand(args); // Returning the exit code back to the caller allows the promise // to be awaited in the bin files and return the correct exit code - if (resultCode !== 0) { - return resultCode; + if (commandScanResult !== 0) { + return commandScanResult; } } - var result = await getPackageManager().runCommand(args); + const packageManagerResult = await getPackageManager().runCommand(args); - proxy.verifyNoMaliciousPackages(); + if (!proxy.verifyNoMaliciousPackages()) { + return 1; + } ui.emptyLine(); ui.writeInformation( @@ -38,7 +40,7 @@ export async function main(args) { // Returning the exit code back to the caller allows the promise // to be awaited in the bin files and return the correct exit code - return result.status; + return packageManagerResult.status; } catch (error) { ui.writeError("Failed to check for malicious packages:", error.message); diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 9880129..3558673 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -7,6 +7,7 @@ import { knownRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js"; import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; +const SERVER_STOP_TIMEOUT_MS = 1000; const state = { port: null, blockedRequests: [], @@ -19,12 +20,15 @@ export function createSafeChainProxy() { return { startServer: () => startServer(server), stopServer: () => stopServer(server), - getBlockedRequests: () => state.blockedRequests, verifyNoMaliciousPackages, }; } function getSafeChainProxyEnvironmentVariables() { + if (!state.port) { + return {}; + } + return { HTTPS_PROXY: `http://localhost:${state.port}`, GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`, @@ -63,6 +67,7 @@ function createProxyServer() { function startServer(server) { return new Promise((resolve, reject) => { + // Passing port 0 makes the OS assign an available port server.listen(0, () => { const address = server.address(); if (address && typeof address === "object") { @@ -88,7 +93,7 @@ function stopServer(server) { } catch { resolve(); } - setTimeout(() => resolve(), 1000); + setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS); }); } @@ -130,7 +135,7 @@ async function isAllowedUrl(url) { function verifyNoMaliciousPackages() { if (state.blockedRequests.length === 0) { // No malicious packages were blocked, so nothing to block - return; + return true; } ui.emptyLine(); @@ -149,5 +154,5 @@ function verifyNoMaliciousPackages() { ui.writeError("Exiting without installing malicious packages."); ui.emptyLine(); - process.exit(1); + return false; } diff --git a/packages/safe-chain/src/scanning/index.js b/packages/safe-chain/src/scanning/index.js index 91314ae..36f62ca 100644 --- a/packages/safe-chain/src/scanning/index.js +++ b/packages/safe-chain/src/scanning/index.js @@ -65,8 +65,7 @@ export async function scanCommand(args) { return 0; } else { printMaliciousChanges(audit.disallowedChanges, spinner); - await onMalwareFound(); - return 1; + return await onMalwareFound(); } } @@ -90,11 +89,11 @@ async function onMalwareFound() { if (continueInstall) { ui.writeWarning("Continuing with the installation despite the risks..."); - return; + return 0; } } ui.writeError("Exiting without installing malicious packages."); ui.emptyLine(); - process.exit(1); + return 1; } diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index 0181e5e..1cb781b 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -31,6 +31,8 @@ export async function openMalwareDatabase() { return packageData.reason; } + // This implicitely caches the malware database + // that's closed over by the getPackageStatus function cachedMalwareDatabase = { getPackageStatus, isMalware: (name, version) => { diff --git a/packages/safe-chain/src/utils/safeSpawn.js b/packages/safe-chain/src/utils/safeSpawn.js index 5af4740..c5cd913 100644 --- a/packages/safe-chain/src/utils/safeSpawn.js +++ b/packages/safe-chain/src/utils/safeSpawn.js @@ -22,6 +22,8 @@ export async function safeSpawn(command, args, options = {}) { const fullCommand = buildCommand(command, args); return new Promise((resolve, reject) => { const child = spawn(fullCommand, { ...options, shell: true }); + + // When stdio is piped, we need to collect the output let stdout = ""; let stderr = ""; From 8950d528d57893f5486406afd24fb3cdfdb1a204 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 8 Oct 2025 10:56:31 +0200 Subject: [PATCH 17/18] Fix tests to match new behavior --- .../src/scanning/index.scanCommand.spec.js | 51 ++++--------------- 1 file changed, 10 insertions(+), 41 deletions(-) diff --git a/packages/safe-chain/src/scanning/index.scanCommand.spec.js b/packages/safe-chain/src/scanning/index.scanCommand.spec.js index f81a155..1858d10 100644 --- a/packages/safe-chain/src/scanning/index.scanCommand.spec.js +++ b/packages/safe-chain/src/scanning/index.scanCommand.spec.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { describe, it, mock } from "node:test"; +import { beforeEach, describe, it, mock } from "node:test"; import { setTimeout } from "node:timers/promises"; import { MALWARE_ACTION_PROMPT, @@ -88,6 +88,11 @@ describe("scanCommand", async () => { const { scanCommand } = await import("./index.js"); + beforeEach(() => { + // Reset malware action back to prompt mode for other tests + malwareAction = MALWARE_ACTION_PROMPT; + }); + it("should succeed when there are no changes", async () => { let progressWasStopped = false; mockStartProcess.mock.mockImplementationOnce(() => ({ @@ -205,7 +210,6 @@ describe("scanCommand", async () => { mockConfirm.mock.resetCalls(); let failureMessageWasSet = false; - let exitCode = null; mockStartProcess.mock.mockImplementationOnce(() => ({ setText: () => {}, @@ -220,27 +224,10 @@ describe("scanCommand", async () => { { name: "malicious", version: "1.0.0" }, ]); - // Mock process.exit - const originalExit = process.exit; - process.exit = mock.fn((code) => { - exitCode = code; - throw new Error("Process exit called"); // Prevent actual exit - }); - - try { - await assert.rejects( - scanCommand(["install", "malicious"]), - /Process exit called/ - ); - } finally { - // Restore original process.exit - process.exit = originalExit; - // Reset malware action back to prompt mode for other tests - malwareAction = MALWARE_ACTION_PROMPT; - } + const result = await scanCommand(["install", "malicious"]); assert.equal(failureMessageWasSet, true); - assert.equal(exitCode, 1); + assert.equal(result, 1); // Confirm should not have been called in block mode assert.equal(mockConfirm.mock.callCount(), 0); }); @@ -252,7 +239,6 @@ describe("scanCommand", async () => { // Reset mock call count mockConfirm.mock.resetCalls(); - let processExited = false; let userWasPrompted = false; mockStartProcess.mock.mockImplementationOnce(() => ({ @@ -271,26 +257,9 @@ describe("scanCommand", async () => { return false; }); - // Mock process.exit - const originalExit = process.exit; - process.exit = mock.fn(() => { - processExited = true; - throw new Error("Process exit called"); // Prevent actual exit - }); + const result = await scanCommand(["install", "malicious"]); - try { - await assert.rejects( - scanCommand(["install", "malicious"]), - /Process exit called/ - ); - } finally { - // Restore original process.exit - process.exit = originalExit; - // Reset malware action back to prompt mode for other tests - malwareAction = MALWARE_ACTION_PROMPT; - } - - assert.equal(processExited, true); + assert.equal(result, 1); assert.equal(userWasPrompted, false); }); }); From 16c76de0f3d066a53d65a039a6fa05189afbf416 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 8 Oct 2025 11:38:21 +0200 Subject: [PATCH 18/18] Add comment on how safe-chain works with the system proxy. --- .../src/registryProxy/tunnelRequestHandler.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js index 1609664..95e2beb 100644 --- a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js @@ -5,6 +5,16 @@ export function tunnelRequest(req, clientSocket, head) { const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; if (httpsProxy) { + // If an HTTPS proxy is set, tunnel the request via the proxy + // This is the system proxy, not the safe-chain proxy + // The package manager will run via the safe-chain proxy + // The safe-chain proxy will then send the request to the system proxy + // Typical flow: package manager -> safe-chain proxy -> system proxy -> destination + + // There are 2 processes involved in this: + // 1. Safe-chain process: has HTTPS_PROXY set to system proxy + // 2. Package manager process: has HTTPS_PROXY set to safe-chain proxy + tunnelRequestViaProxy(req, clientSocket, head, httpsProxy); } else { tunnelRequestToDestination(req, clientSocket, head);