diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js index 83bc03e..425eca4 100644 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js @@ -1,8 +1,13 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; -import { PIP_COMMAND, PIP3_COMMAND, PYTHON_COMMAND, PYTHON3_COMMAND } from "./pipSettings.js"; +import { getCombinedCaBundlePath } from "../../registryProxy/builtInProxy/certBundle.js"; +import { + PIP_COMMAND, + PIP3_COMMAND, + PYTHON_COMMAND, + PYTHON3_COMMAND, +} from "./pipSettings.js"; import fs from "node:fs/promises"; import fsSync from "node:fs"; import os from "node:os"; @@ -20,7 +25,11 @@ import { spawn } from "child_process"; export function shouldBypassSafeChain(command, args) { if (command === PYTHON_COMMAND || command === PYTHON3_COMMAND) { // Check if args start with -m pip - if (args.length >= 2 && args[0] === "-m" && (args[1] === PIP_COMMAND || args[1] === PIP3_COMMAND)) { + if ( + args.length >= 2 && + args[0] === "-m" && + (args[1] === PIP_COMMAND || args[1] === PIP3_COMMAND) + ) { return false; } return true; @@ -32,43 +41,49 @@ export function shouldBypassSafeChain(command, args) { * Sets fallback CA bundle environment variables used by Python libraries. * These are applied in addition to the PIP_CONFIG_FILE to ensure all Python * network libraries respect the combined CA bundle, even if they don't read pip's config. - * + * * @param {NodeJS.ProcessEnv} env - Environment object to modify * @param {string} combinedCaPath - Path to the combined CA bundle */ function setFallbackCaBundleEnvironmentVariables(env, combinedCaPath) { // REQUESTS_CA_BUNDLE: Used by the popular 'requests' library if (env.REQUESTS_CA_BUNDLE) { - ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); + ui.writeWarning( + "Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.", + ); } env.REQUESTS_CA_BUNDLE = combinedCaPath; // SSL_CERT_FILE: Used by some Python SSL libraries and urllib if (env.SSL_CERT_FILE) { - ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); + ui.writeWarning( + "Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.", + ); } env.SSL_CERT_FILE = combinedCaPath; // PIP_CERT: Pip's own environment variable for certificate verification if (env.PIP_CERT) { - ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); + ui.writeWarning( + "Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.", + ); } env.PIP_CERT = combinedCaPath; } /** * Runs a pip command with safe-chain's certificate bundle and proxy configuration. - * + * * Creates a temporary pip config file to configure: * - Cert bundle for HTTPS verification * - Proxy settings - * + * * If the user has an existing PIP_CONFIG_FILE, a new temporary config is created that merges * their settings with safe-chain's, leaving the original file unchanged. - * + * * Special handling for commands that modify config/cache/state: PIP_CONFIG_FILE is NOT overridden to allow * users to read/write persistent config. Only CA environment variables are set for these commands. - * + * * @param {string} command - The pip command executable (e.g., 'pip3' or 'python3') * @param {string[]} args - Command line arguments to pass to pip * @returns {Promise<{status: number}>} Exit status of the pip command @@ -76,12 +91,16 @@ function setFallbackCaBundleEnvironmentVariables(env, combinedCaPath) { export async function runPip(command, args) { // Check if we should bypass safe-chain (python/python3 without -m pip) if (shouldBypassSafeChain(command, args)) { - ui.writeVerbose(`Safe-chain: Bypassing safe-chain for non-pip invocation: ${command} ${args.join(" ")}`); + ui.writeVerbose( + `Safe-chain: Bypassing safe-chain for non-pip invocation: ${command} ${args.join(" ")}`, + ); // Spawn the ORIGINAL command with ORIGINAL args return new Promise((_resolve) => { const proc = spawn(command, args, { stdio: "inherit" }); proc.on("exit", (/** @type {number | null} */ code) => { - ui.writeVerbose(`${command} ${args.join(" ")} exited with status ${code}`); + ui.writeVerbose( + `${command} ${args.join(" ")} exited with status ${code}`, + ); ui.writeBufferedLogsAndStopBuffering(); process.exit(code ?? 0); }); @@ -104,28 +123,32 @@ export async function runPip(command, args) { // Commands that need access to persistent config/cache/state files // These should not have PIP_CONFIG_FILE overridden as it would prevent them from // reading/writing to the user's actual pip configuration and cache directories - const configRelatedCommands = ['config', 'cache', 'debug', 'completion']; - const isConfigRelatedCommand = args.length > 0 && configRelatedCommands.includes(args[0]); + const configRelatedCommands = ["config", "cache", "debug", "completion"]; + const isConfigRelatedCommand = + args.length > 0 && configRelatedCommands.includes(args[0]); // https://pip.pypa.io/en/stable/topics/https-certificates/ explains that the 'cert' param (which we're providing via INI file) // will tell pip to use the provided CA bundle for HTTPS verification. // Proxy settings: GLOBAL_AGENT_HTTP_PROXY is our safe-chain proxy (if active), // otherwise fall back to user-defined HTTPS_PROXY or HTTP_PROXY environment variables - const proxy = env.GLOBAL_AGENT_HTTP_PROXY || env.HTTPS_PROXY || env.HTTP_PROXY || ''; + const proxy = + env.GLOBAL_AGENT_HTTP_PROXY || env.HTTPS_PROXY || env.HTTP_PROXY || ""; const tmpDir = os.tmpdir(); const pipConfigPath = path.join(tmpDir, `safe-chain-pip-${Date.now()}.ini`); let cleanupConfigPath = null; // Track temp file for cleanup if (isConfigRelatedCommand) { - ui.writeVerbose(`Safe-chain: Skipping PIP_CONFIG_FILE override for 'pip ${args[0]}' command to allow persistent config/cache access.`); - - // Still set the fallback CA bundle environment variables to avoid edge cases where a + ui.writeVerbose( + `Safe-chain: Skipping PIP_CONFIG_FILE override for 'pip ${args[0]}' command to allow persistent config/cache access.`, + ); + + // Still set the fallback CA bundle environment variables to avoid edge cases where a // plugin or extension triggers a network call during config introspection // This can do no harm setFallbackCaBundleEnvironmentVariables(env, combinedCaPath); - + const result = await safeSpawn(command, args, { stdio: "inherit", env, @@ -145,9 +168,10 @@ export async function runPip(command, args) { await fs.writeFile(pipConfigPath, pipConfig); env.PIP_CONFIG_FILE = pipConfigPath; cleanupConfigPath = pipConfigPath; - } else if (fsSync.existsSync(env.PIP_CONFIG_FILE)) { - ui.writeVerbose("Safe-chain: Merging user provided PIP_CONFIG_FILE with safe-chain certificate and proxy settings."); + ui.writeVerbose( + "Safe-chain: Merging user provided PIP_CONFIG_FILE with safe-chain certificate and proxy settings.", + ); const userConfig = env.PIP_CONFIG_FILE; // Read the existing config without modifying it @@ -159,25 +183,28 @@ export async function runPip(command, args) { // Cert if (typeof parsed.global.cert !== "undefined") { - ui.writeWarning("Safe-chain: User defined cert found in PIP_CONFIG_FILE. It will be overwritten in the temporary config."); + ui.writeWarning( + "Safe-chain: User defined cert found in PIP_CONFIG_FILE. It will be overwritten in the temporary config.", + ); } parsed.global.cert = combinedCaPath; // Proxy if (typeof parsed.global.proxy !== "undefined") { - ui.writeWarning("Safe-chain: User defined proxy found in PIP_CONFIG_FILE. It will be overwritten in the temporary config."); + ui.writeWarning( + "Safe-chain: User defined proxy found in PIP_CONFIG_FILE. It will be overwritten in the temporary config.", + ); } if (proxy) { parsed.global.proxy = proxy; } - + const updated = ini.stringify(parsed); // Save to a new temp file to avoid overwriting user's original config await fs.writeFile(pipConfigPath, updated, "utf-8"); env.PIP_CONFIG_FILE = pipConfigPath; cleanupConfigPath = pipConfigPath; - } else { // The user provided PIP_CONFIG_FILE does not exist on disk // PIP will handle this as an error and inform the user diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js index 0707333..b256725 100644 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js @@ -24,7 +24,10 @@ describe("runPipCommand environment variable handling", () => { // Capture the config file content before the function cleans it up if (options.env.PIP_CONFIG_FILE) { try { - capturedConfigContent = await fs.readFile(options.env.PIP_CONFIG_FILE, "utf-8"); + capturedConfigContent = await fs.readFile( + options.env.PIP_CONFIG_FILE, + "utf-8", + ); } catch { // Ignore if file doesn't exist or can't be read } @@ -49,7 +52,7 @@ describe("runPipCommand environment variable handling", () => { }); // Mock certBundle to return a test combined bundle path - mock.module("../../registryProxy/certBundle.js", { + mock.module("../../registryProxy/builtInProxy/certBundle.js", { namedExports: { getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem", }, @@ -65,7 +68,12 @@ describe("runPipCommand environment variable handling", () => { }); it("should NOT set PIP_CONFIG_FILE for 'pip config' commands to allow persistent config access", async () => { - const res = await runPip("pip3", ["config", "set", "global.index-url", "https://test.pypi.org/simple"]); + const res = await runPip("pip3", [ + "config", + "set", + "global.index-url", + "https://test.pypi.org/simple", + ]); assert.strictEqual(res.status, 0); assert.ok(capturedArgs, "safeSpawn should have been called"); @@ -73,24 +81,24 @@ describe("runPipCommand environment variable handling", () => { assert.strictEqual( capturedArgs.options.env.PIP_CONFIG_FILE, undefined, - "PIP_CONFIG_FILE should NOT be set for pip config commands" + "PIP_CONFIG_FILE should NOT be set for pip config commands", ); // But CA environment variables should still be set assert.strictEqual( capturedArgs.options.env.REQUESTS_CA_BUNDLE, "/tmp/test-combined-ca.pem", - "REQUESTS_CA_BUNDLE should still be set" + "REQUESTS_CA_BUNDLE should still be set", ); assert.strictEqual( capturedArgs.options.env.SSL_CERT_FILE, "/tmp/test-combined-ca.pem", - "SSL_CERT_FILE should still be set" + "SSL_CERT_FILE should still be set", ); assert.strictEqual( capturedArgs.options.env.PIP_CERT, "/tmp/test-combined-ca.pem", - "PIP_CERT should still be set" + "PIP_CERT should still be set", ); }); @@ -102,7 +110,7 @@ describe("runPipCommand environment variable handling", () => { assert.strictEqual( capturedArgs.options.env.PIP_CONFIG_FILE, undefined, - "PIP_CONFIG_FILE should NOT be set for pip config get" + "PIP_CONFIG_FILE should NOT be set for pip config get", ); }); @@ -114,7 +122,7 @@ describe("runPipCommand environment variable handling", () => { assert.strictEqual( capturedArgs.options.env.PIP_CONFIG_FILE, undefined, - "PIP_CONFIG_FILE should NOT be set for pip config list" + "PIP_CONFIG_FILE should NOT be set for pip config list", ); }); @@ -126,14 +134,14 @@ describe("runPipCommand environment variable handling", () => { assert.strictEqual( capturedArgs.options.env.PIP_CONFIG_FILE, undefined, - "PIP_CONFIG_FILE should NOT be set for pip cache commands" + "PIP_CONFIG_FILE should NOT be set for pip cache commands", ); // CA env vars should still be set assert.strictEqual( capturedArgs.options.env.SSL_CERT_FILE, "/tmp/test-combined-ca.pem", - "SSL_CERT_FILE should still be set" + "SSL_CERT_FILE should still be set", ); }); @@ -145,7 +153,7 @@ describe("runPipCommand environment variable handling", () => { assert.strictEqual( capturedArgs.options.env.PIP_CONFIG_FILE, undefined, - "PIP_CONFIG_FILE should NOT be set for pip debug" + "PIP_CONFIG_FILE should NOT be set for pip debug", ); }); @@ -157,7 +165,7 @@ describe("runPipCommand environment variable handling", () => { assert.strictEqual( capturedArgs.options.env.PIP_CONFIG_FILE, undefined, - "PIP_CONFIG_FILE should NOT be set for pip completion" + "PIP_CONFIG_FILE should NOT be set for pip completion", ); }); @@ -169,13 +177,20 @@ describe("runPipCommand environment variable handling", () => { assert.strictEqual( capturedArgs.options.env.PIP_CERT, "/tmp/test-combined-ca.pem", - "PIP_CERT should be set to combined bundle path" + "PIP_CERT should be set to combined bundle path", ); // Check PIP_CONFIG_FILE env var exists and is a non-empty string const configPath = capturedArgs.options.env.PIP_CONFIG_FILE; assert.ok(configPath, "PIP_CONFIG_FILE should be set"); - assert.strictEqual(typeof configPath, "string", "PIP_CONFIG_FILE should be a string"); - assert.ok(configPath.length > 0, "PIP_CONFIG_FILE should be a non-empty path"); + assert.strictEqual( + typeof configPath, + "string", + "PIP_CONFIG_FILE should be a string", + ); + assert.ok( + configPath.length > 0, + "PIP_CONFIG_FILE should be a non-empty path", + ); }); it("should set REQUESTS_CA_BUNDLE and SSL_CERT_FILE for default PyPI (no explicit index)", async () => { @@ -188,12 +203,12 @@ describe("runPipCommand environment variable handling", () => { assert.strictEqual( capturedArgs.options.env.REQUESTS_CA_BUNDLE, "/tmp/test-combined-ca.pem", - "REQUESTS_CA_BUNDLE should be set to combined bundle path" + "REQUESTS_CA_BUNDLE should be set to combined bundle path", ); assert.strictEqual( capturedArgs.options.env.SSL_CERT_FILE, "/tmp/test-combined-ca.pem", - "SSL_CERT_FILE should be set to combined bundle path" + "SSL_CERT_FILE should be set to combined bundle path", ); }); @@ -208,11 +223,11 @@ describe("runPipCommand environment variable handling", () => { // Env vars should be set unconditionally assert.strictEqual( capturedArgs.options.env.REQUESTS_CA_BUNDLE, - "/tmp/test-combined-ca.pem" + "/tmp/test-combined-ca.pem", ); assert.strictEqual( capturedArgs.options.env.SSL_CERT_FILE, - "/tmp/test-combined-ca.pem" + "/tmp/test-combined-ca.pem", ); }); @@ -224,11 +239,11 @@ describe("runPipCommand environment variable handling", () => { // Environment variables still set (pip CLI --cert takes precedence) assert.strictEqual( capturedArgs.options.env.REQUESTS_CA_BUNDLE, - "/tmp/test-combined-ca.pem" + "/tmp/test-combined-ca.pem", ); assert.strictEqual( capturedArgs.options.env.SSL_CERT_FILE, - "/tmp/test-combined-ca.pem" + "/tmp/test-combined-ca.pem", ); }); @@ -239,13 +254,16 @@ describe("runPipCommand environment variable handling", () => { assert.strictEqual( capturedArgs.options.env.HTTPS_PROXY, "http://localhost:8080", - "HTTPS_PROXY should be set by proxy merge" + "HTTPS_PROXY should be set by proxy merge", ); }); it("should create a new temp config when existing config exists (original file untouched)", async () => { const tmpDir = os.tmpdir(); - const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`); + const userCfgPath = path.join( + tmpDir, + `safe-chain-test-pip-${Date.now()}.ini`, + ); const initial = "[global]\nindex-url = https://example.com/simple\n"; await fs.writeFile(userCfgPath, initial, "utf-8"); @@ -253,19 +271,42 @@ describe("runPipCommand environment variable handling", () => { const res = await runPip("pip3", ["install", "requests"]); assert.strictEqual(res.status, 0); const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE; - assert.notStrictEqual(newCfgPath, userCfgPath, "should point to a new temp config file"); + assert.notStrictEqual( + newCfgPath, + userCfgPath, + "should point to a new temp config file", + ); // Original file unchanged const originalContent = await fs.readFile(userCfgPath, "utf-8"); const originalParsed = ini.parse(originalContent); - assert.strictEqual(originalParsed.global.cert, undefined, "original file should not gain cert"); + assert.strictEqual( + originalParsed.global.cert, + undefined, + "original file should not gain cert", + ); // New file has merged settings (read from captured content before cleanup) - assert.ok(capturedConfigContent, "config content should have been captured"); + assert.ok( + capturedConfigContent, + "config content should have been captured", + ); const newParsed = ini.parse(capturedConfigContent); - assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "new config should include cert"); - assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "new config should include proxy from env"); - assert.strictEqual(newParsed.global["index-url"], "https://example.com/simple", "index-url should be preserved"); + assert.strictEqual( + newParsed.global.cert, + "/tmp/test-combined-ca.pem", + "new config should include cert", + ); + assert.strictEqual( + newParsed.global.proxy, + "http://localhost:8080", + "new config should include proxy from env", + ); + assert.strictEqual( + newParsed.global["index-url"], + "https://example.com/simple", + "index-url should be preserved", + ); customEnv = null; }); @@ -274,24 +315,30 @@ describe("runPipCommand environment variable handling", () => { const res = await runPip("pip3", ["install", "requests"]); assert.strictEqual(res.status, 0); - assert.ok(capturedConfigContent, "config content should have been captured"); + assert.ok( + capturedConfigContent, + "config content should have been captured", + ); const parsed = ini.parse(capturedConfigContent); assert.ok(parsed.global, "[global] should exist after creation"); assert.strictEqual( parsed.global.proxy, "http://localhost:8080", - "proxy should be set from merged env" + "proxy should be set from merged env", ); assert.strictEqual( parsed.global.cert, "/tmp/test-combined-ca.pem", - "cert should be set during creation" + "cert should be set during creation", ); }); it("should create new temp config adding cert but preserving existing proxy (original file unchanged)", async () => { const tmpDir = os.tmpdir(); - const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`); + const userCfgPath = path.join( + tmpDir, + `safe-chain-test-pip-${Date.now()}.ini`, + ); const initial = "[global]\nproxy = http://original:9999\n"; await fs.writeFile(userCfgPath, initial, "utf-8"); @@ -299,18 +346,41 @@ describe("runPipCommand environment variable handling", () => { const res = await runPip("pip3", ["install", "requests"]); assert.strictEqual(res.status, 0); const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE; - assert.notStrictEqual(newCfgPath, userCfgPath, "should use a new temp config file"); + assert.notStrictEqual( + newCfgPath, + userCfgPath, + "should use a new temp config file", + ); // Original file unchanged const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8")); - assert.strictEqual(originalParsed.global.cert, undefined, "original file should not gain cert"); - assert.strictEqual(originalParsed.global.proxy, "http://original:9999", "original proxy remains"); + assert.strictEqual( + originalParsed.global.cert, + undefined, + "original file should not gain cert", + ); + assert.strictEqual( + originalParsed.global.proxy, + "http://original:9999", + "original proxy remains", + ); // New file: cert and proxy always overwritten (read from captured content) - assert.ok(capturedConfigContent, "config content should have been captured"); + assert.ok( + capturedConfigContent, + "config content should have been captured", + ); const newParsed = ini.parse(capturedConfigContent); - assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config"); - assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config"); + assert.strictEqual( + newParsed.global.cert, + "/tmp/test-combined-ca.pem", + "cert always overwritten in temp config", + ); + assert.strictEqual( + newParsed.global.proxy, + "http://localhost:8080", + "proxy always overwritten in temp config", + ); customEnv = null; }); @@ -321,7 +391,7 @@ describe("runPipCommand environment variable handling", () => { "[global]", "cert = /path/to/existing.pem", "proxy = http://original:9999", - "" + "", ].join("\n"); await fs.writeFile(cfgPath, initialIni, "utf-8"); @@ -329,25 +399,51 @@ describe("runPipCommand environment variable handling", () => { const res = await runPip("pip3", ["install", "requests"]); assert.strictEqual(res.status, 0, "execution should succeed"); const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE; - assert.notStrictEqual(newCfgPath, cfgPath, "should use a newly generated temp config file"); + assert.notStrictEqual( + newCfgPath, + cfgPath, + "should use a newly generated temp config file", + ); // Original file stays untouched const originalContent = await fs.readFile(cfgPath, "utf-8"); const originalParsed = ini.parse(originalContent); - assert.strictEqual(originalParsed.global.cert, "/path/to/existing.pem", "original cert preserved"); - assert.strictEqual(originalParsed.global.proxy, "http://original:9999", "original proxy preserved"); + assert.strictEqual( + originalParsed.global.cert, + "/path/to/existing.pem", + "original cert preserved", + ); + assert.strictEqual( + originalParsed.global.proxy, + "http://original:9999", + "original proxy preserved", + ); - // New temp config: cert and proxy always overwritten (read from captured content) - assert.ok(capturedConfigContent, "config content should have been captured"); - const newParsed = ini.parse(capturedConfigContent); - assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config"); - assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config"); + // New temp config: cert and proxy always overwritten (read from captured content) + assert.ok( + capturedConfigContent, + "config content should have been captured", + ); + const newParsed = ini.parse(capturedConfigContent); + assert.strictEqual( + newParsed.global.cert, + "/tmp/test-combined-ca.pem", + "cert always overwritten in temp config", + ); + assert.strictEqual( + newParsed.global.proxy, + "http://localhost:8080", + "proxy always overwritten in temp config", + ); customEnv = null; }); it("should create new temp config preserving existing cert and adding missing proxy", async () => { const tmpDir = os.tmpdir(); - const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`); + const userCfgPath = path.join( + tmpDir, + `safe-chain-test-pip-${Date.now()}.ini`, + ); const initial = "[global]\ncert = /path/to/existing.pem\n"; await fs.writeFile(userCfgPath, initial, "utf-8"); @@ -355,29 +451,55 @@ describe("runPipCommand environment variable handling", () => { const res = await runPip("pip3", ["install", "requests"]); assert.strictEqual(res.status, 0); const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE; - assert.notStrictEqual(newCfgPath, userCfgPath, "should produce a new temp config file"); + assert.notStrictEqual( + newCfgPath, + userCfgPath, + "should produce a new temp config file", + ); // Original remains unchanged const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8")); - assert.strictEqual(originalParsed.global.cert, "/path/to/existing.pem", "original cert unchanged"); - assert.strictEqual(originalParsed.global.proxy, undefined, "original proxy still missing"); + assert.strictEqual( + originalParsed.global.cert, + "/path/to/existing.pem", + "original cert unchanged", + ); + assert.strictEqual( + originalParsed.global.proxy, + undefined, + "original proxy still missing", + ); - // New file: cert and proxy always overwritten (read from captured content) - assert.ok(capturedConfigContent, "config content should have been captured"); - const newParsed = ini.parse(capturedConfigContent); - assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config"); - assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config"); + // New file: cert and proxy always overwritten (read from captured content) + assert.ok( + capturedConfigContent, + "config content should have been captured", + ); + const newParsed = ini.parse(capturedConfigContent); + assert.strictEqual( + newParsed.global.cert, + "/tmp/test-combined-ca.pem", + "cert always overwritten in temp config", + ); + assert.strictEqual( + newParsed.global.proxy, + "http://localhost:8080", + "proxy always overwritten in temp config", + ); customEnv = null; }); it("should log warnings when cert and proxy are already set in user config file", async () => { const tmpDir = os.tmpdir(); - const cfgPath = path.join(tmpDir, `safe-chain-test-pip-warn-${Date.now()}.ini`); + const cfgPath = path.join( + tmpDir, + `safe-chain-test-pip-warn-${Date.now()}.ini`, + ); const initialIni = [ "[global]", "cert = /user/cert.pem", "proxy = http://user-proxy:9999", - "" + "", ].join("\n"); await fs.writeFile(cfgPath, initialIni, "utf-8"); @@ -387,16 +509,28 @@ describe("runPipCommand environment variable handling", () => { let output = ""; const originalWrite = process.stdout.write; const originalError = process.stderr.write; - process.stdout.write = (chunk, ...args) => { output += chunk; return originalWrite.apply(process.stdout, [chunk, ...args]); }; - process.stderr.write = (chunk, ...args) => { output += chunk; return originalError.apply(process.stderr, [chunk, ...args]); }; + process.stdout.write = (chunk, ...args) => { + output += chunk; + return originalWrite.apply(process.stdout, [chunk, ...args]); + }; + process.stderr.write = (chunk, ...args) => { + output += chunk; + return originalError.apply(process.stderr, [chunk, ...args]); + }; await runPip("pip3", ["install", "requests"]); process.stdout.write = originalWrite; process.stderr.write = originalError; - assert.ok(output.includes("cert found in PIP_CONFIG_FILE"), "Should warn about cert overwrite in output"); - assert.ok(output.includes("proxy found in PIP_CONFIG_FILE"), "Should warn about proxy overwrite in output"); + assert.ok( + output.includes("cert found in PIP_CONFIG_FILE"), + "Should warn about cert overwrite in output", + ); + assert.ok( + output.includes("proxy found in PIP_CONFIG_FILE"), + "Should warn about proxy overwrite in output", + ); customEnv = null; }); @@ -407,13 +541,18 @@ describe("runPipCommand environment variable handling", () => { assert.strictEqual(shouldBypassSafeChain("python", ["--version"]), true); assert.strictEqual(shouldBypassSafeChain("python3", ["--version"]), true); - assert.strictEqual(shouldBypassSafeChain("python", ["-m", "http.server"]), true); - assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "http.server"]), true); + assert.strictEqual( + shouldBypassSafeChain("python", ["-m", "http.server"]), + true, + ); + assert.strictEqual( + shouldBypassSafeChain("python3", ["-m", "http.server"]), + true, + ); assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip"]), false); assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip"]), false); assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip3"]), false); assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip3"]), false); }); - }); diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js index 2f70cfa..73a6384 100644 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js @@ -1,11 +1,11 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; +import { getCombinedCaBundlePath } from "../../registryProxy/builtInProxy/certBundle.js"; /** * Sets CA bundle environment variables used by Python libraries and pipx. - * + * * @param {NodeJS.ProcessEnv} env - Env object * @param {string} combinedCaPath - Path to the combined CA bundle * @return {NodeJS.ProcessEnv} Modified environment object @@ -14,17 +14,23 @@ function getPipXCaBundleEnvironmentVariables(env, combinedCaPath) { let retVal = { ...env }; if (env.SSL_CERT_FILE) { - ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); + ui.writeWarning( + "Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.", + ); } retVal.SSL_CERT_FILE = combinedCaPath; if (env.REQUESTS_CA_BUNDLE) { - ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); + ui.writeWarning( + "Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.", + ); } retVal.REQUESTS_CA_BUNDLE = combinedCaPath; if (env.PIP_CERT) { - ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); + ui.writeWarning( + "Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.", + ); } retVal.PIP_CERT = combinedCaPath; return retVal; @@ -32,7 +38,7 @@ function getPipXCaBundleEnvironmentVariables(env, combinedCaPath) { /** * Runs a pipx command with safe-chain's certificate bundle and proxy configuration. - * + * * @param {string} command - The command to execute * @param {string[]} args - Command line arguments * @returns {Promise<{status: number}>} Exit status of the command @@ -42,7 +48,10 @@ export async function runPipX(command, args) { const env = mergeSafeChainProxyEnvironmentVariables(process.env); const combinedCaPath = getCombinedCaBundlePath(); - const modifiedEnv = getPipXCaBundleEnvironmentVariables(env, combinedCaPath); + const modifiedEnv = getPipXCaBundleEnvironmentVariables( + env, + combinedCaPath, + ); // Note: pipx uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration // These are already set by mergeSafeChainProxyEnvironmentVariables diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js index dd04dc2..4b92bd1 100644 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js @@ -41,7 +41,7 @@ describe("runPipXCommand", () => { }, }); - mock.module("../../registryProxy/certBundle.js", { + mock.module("../../registryProxy/builtInProxy/certBundle.js", { namedExports: { getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem", }, @@ -65,7 +65,11 @@ describe("runPipXCommand", () => { const res = await runPipX("pipx", ["install", "ruff"]); assert.strictEqual(res.status, 0); - assert.strictEqual(safeSpawnMock.mock.calls.length, 1, "safeSpawn should be called once"); + assert.strictEqual( + safeSpawnMock.mock.calls.length, + 1, + "safeSpawn should be called once", + ); const [, , options] = safeSpawnMock.mock.calls[0].arguments; const env = options.env; diff --git a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js index c8094e5..72741d3 100644 --- a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js +++ b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js @@ -1,7 +1,7 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; +import { getCombinedCaBundlePath } from "../../registryProxy/builtInProxy/certBundle.js"; /** * @returns {import("../currentPackageManager.js").PackageManager} @@ -19,36 +19,42 @@ export function createPoetryPackageManager() { /** * Sets CA bundle environment variables used by Poetry and Python libraries. * Poetry uses the Python requests library which respects these environment variables. - * + * * @param {NodeJS.ProcessEnv} env - Environment object to modify * @param {string} combinedCaPath - Path to the combined CA bundle */ function setPoetryCaBundleEnvironmentVariables(env, combinedCaPath) { // SSL_CERT_FILE: Used by Python SSL libraries and requests if (env.SSL_CERT_FILE) { - ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); + ui.writeWarning( + "Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.", + ); } env.SSL_CERT_FILE = combinedCaPath; // REQUESTS_CA_BUNDLE: Used by the requests library (which Poetry uses) if (env.REQUESTS_CA_BUNDLE) { - ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); + ui.writeWarning( + "Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.", + ); } env.REQUESTS_CA_BUNDLE = combinedCaPath; // PIP_CERT: Poetry may use pip internally if (env.PIP_CERT) { - ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); + ui.writeWarning( + "Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.", + ); } env.PIP_CERT = combinedCaPath; } /** * Runs a poetry command with safe-chain's certificate bundle and proxy configuration. - * + * * Poetry respects standard HTTP_PROXY/HTTPS_PROXY environment variables through * the Python requests library. - * + * * @param {string[]} args - Command line arguments to pass to poetry * @returns {Promise<{status: number}>} Exit status of the poetry command */ @@ -63,7 +69,7 @@ async function runPoetryCommand(args) { stdio: "inherit", env, }); - + return { status: result.status }; } catch (/** @type any */ error) { if (error.status) { diff --git a/packages/safe-chain/src/packagemanager/uv/runUvCommand.js b/packages/safe-chain/src/packagemanager/uv/runUvCommand.js index ed02fe3..5d6bd4f 100644 --- a/packages/safe-chain/src/packagemanager/uv/runUvCommand.js +++ b/packages/safe-chain/src/packagemanager/uv/runUvCommand.js @@ -1,44 +1,50 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; +import { getCombinedCaBundlePath } from "../../registryProxy/builtInProxy/certBundle.js"; /** * Sets CA bundle environment variables used by Python libraries and uv. - * + * * @param {NodeJS.ProcessEnv} env - Env object * @param {string} combinedCaPath - Path to the combined CA bundle */ function setUvCaBundleEnvironmentVariables(env, combinedCaPath) { // SSL_CERT_FILE: Used by Python SSL libraries and underlying HTTP clients if (env.SSL_CERT_FILE) { - ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); + ui.writeWarning( + "Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.", + ); } env.SSL_CERT_FILE = combinedCaPath; // REQUESTS_CA_BUNDLE: Used by the requests library (which uv may use internally) if (env.REQUESTS_CA_BUNDLE) { - ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); + ui.writeWarning( + "Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.", + ); } env.REQUESTS_CA_BUNDLE = combinedCaPath; // PIP_CERT: Some underlying pip operations may respect this if (env.PIP_CERT) { - ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); + ui.writeWarning( + "Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.", + ); } env.PIP_CERT = combinedCaPath; } /** * Runs a uv command with safe-chain's certificate bundle and proxy configuration. - * + * * uv respects standard environment variables for proxy and TLS configuration: * - HTTP_PROXY / HTTPS_PROXY: Proxy settings * - SSL_CERT_FILE / REQUESTS_CA_BUNDLE: CA bundle for TLS verification - * + * * Unlike pip (which requires a temporary config file for cert configuration), uv directly * honors environment variables, so no config/ini file is needed. - * + * * @param {string} command - The uv command to execute (typically 'uv') * @param {string[]} args - Command line arguments to pass to uv * @returns {Promise<{status: number}>} Exit status of the uv command diff --git a/packages/safe-chain/src/registryProxy/certBundle.js b/packages/safe-chain/src/registryProxy/builtInProxy/certBundle.js similarity index 91% rename from packages/safe-chain/src/registryProxy/certBundle.js rename to packages/safe-chain/src/registryProxy/builtInProxy/certBundle.js index 42549b9..06aaf23 100644 --- a/packages/safe-chain/src/registryProxy/certBundle.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/certBundle.js @@ -6,7 +6,7 @@ import certifi from "certifi"; import tls from "node:tls"; import { X509Certificate } from "node:crypto"; import { getCaCertPath } from "./certUtils.js"; -import { ui } from "../environment/userInteraction.js"; +import { ui } from "../../environment/userInteraction.js"; /** * Check if a PEM string contains only parsable cert blocks. @@ -50,7 +50,7 @@ function isParsable(pem) { * - Mozilla roots via certifi (for public HTTPS) * - Node's built-in root certificates (fallback) * - User's custom certificates (if NODE_EXTRA_CA_CERTS environment variable is set) - * + * * @returns {string} Path to the combined CA bundle PEM file */ export function getCombinedCaBundlePath() { @@ -92,14 +92,21 @@ export function getCombinedCaBundlePath() { const userPem = readUserCertificateFile(userCertPath); if (userPem) { parts.push(userPem.trim()); - ui.writeVerbose(`Safe-chain: Merging user's NODE_EXTRA_CA_CERTS from ${userCertPath}`); + ui.writeVerbose( + `Safe-chain: Merging user's NODE_EXTRA_CA_CERTS from ${userCertPath}`, + ); } else { - ui.writeWarning(`Safe-chain: Could not read or parse user's NODE_EXTRA_CA_CERTS from ${userCertPath}`); + ui.writeWarning( + `Safe-chain: Could not read or parse user's NODE_EXTRA_CA_CERTS from ${userCertPath}`, + ); } } const combined = parts.filter(Boolean).join("\n"); - const target = path.join(os.tmpdir(), `safe-chain-ca-bundle-${Date.now()}.pem`); + const target = path.join( + os.tmpdir(), + `safe-chain-ca-bundle-${Date.now()}.pem`, + ); fs.writeFileSync(target, combined, { encoding: "utf8" }); return target; } @@ -177,5 +184,3 @@ function readUserCertificateFile(certPath) { return null; } } - - diff --git a/packages/safe-chain/src/registryProxy/certBundle.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/certBundle.spec.js similarity index 100% rename from packages/safe-chain/src/registryProxy/certBundle.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/certBundle.spec.js diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/builtInProxy/certUtils.js similarity index 100% rename from packages/safe-chain/src/registryProxy/certUtils.js rename to packages/safe-chain/src/registryProxy/builtInProxy/certUtils.js diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js new file mode 100644 index 0000000..f6b5d62 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js @@ -0,0 +1,150 @@ +import * as http from "http"; +import { tunnelRequest } from "./tunnelRequestHandler.js"; +import { mitmConnect } from "./mitmRequestHandler.js"; +import { handleHttpProxyRequest } from "./plainHttpProxy.js"; +import { getCombinedCaBundlePath } from "./certBundle.js"; +import { ui } from "../../environment/userInteraction.js"; +import chalk from "chalk"; +import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; +import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; + +/** * + * @returns {import("../registryProxy.js").SafeChainProxy} */ +export function createBuiltInProxyServer() { + const SERVER_STOP_TIMEOUT_MS = 1000; + /** + * @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}} + */ + const state = { + port: null, + blockedRequests: [], + }; + + const server = http.createServer( + // This handles direct HTTP requests (non-CONNECT requests) + // This is normally http-only traffic, but we also handle + // https for clients that don't properly use CONNECT + handleHttpProxyRequest, + ); + + // This handles HTTPS requests via the CONNECT method + server.on("connect", handleConnect); + + return { + startServer: () => startServer(server), + stopServer: () => stopServer(server), + verifyNoMaliciousPackages, + hasSuppressedVersions: getHasSuppressedVersions, + getServerPort: () => state.port, + getCombinedCaBundlePath, + }; + + /** + * @param {import("http").Server} server + * + * @returns {Promise} + */ + 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") { + state.port = address.port; + resolve(); + } else { + reject(new Error("Failed to start proxy server")); + } + }); + + server.on("error", (err) => { + reject(err); + }); + }); + } + + /** + * @param {import("http").Server} server + * + * @returns {Promise} + */ + function stopServer(server) { + return new Promise((resolve) => { + try { + server.close(() => { + resolve(); + }); + } catch { + resolve(); + } + setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS); + }); + } + + /** + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} clientSocket + * @param {Buffer} head + * + * @returns {void} + */ + function handleConnect(req, clientSocket, head) { + // CONNECT method is used for HTTPS requests + // It establishes a tunnel to the server identified by the request URL + + const interceptor = createInterceptorForUrl(req.url || ""); + + if (interceptor) { + // Subscribe to malware blocked events + interceptor.on( + "malwareBlocked", + ( + /** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event, + ) => { + onMalwareBlocked(event.packageName, event.version, event.targetUrl); + }, + ); + + mitmConnect(req, clientSocket, interceptor); + } else { + // For other hosts, just tunnel the request to the destination tcp socket + ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`); + tunnelRequest(req, clientSocket, head); + } + } + + /** + * + * @param {string} packageName + * @param {string} version + * @param {string} url + */ + function onMalwareBlocked(packageName, version, url) { + state.blockedRequests.push({ packageName, version, url }); + } + + function verifyNoMaliciousPackages() { + if (state.blockedRequests.length === 0) { + // No malicious packages were blocked, so nothing to block + return true; + } + + 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.writeExitWithoutInstallingMaliciousPackages(); + ui.emptyLine(); + + return false; + } +} diff --git a/packages/safe-chain/src/registryProxy/getConnectTimeout.js b/packages/safe-chain/src/registryProxy/builtInProxy/getConnectTimeout.js similarity index 100% rename from packages/safe-chain/src/registryProxy/getConnectTimeout.js rename to packages/safe-chain/src/registryProxy/builtInProxy/getConnectTimeout.js diff --git a/packages/safe-chain/src/registryProxy/http-utils.js b/packages/safe-chain/src/registryProxy/builtInProxy/http-utils.js similarity index 100% rename from packages/safe-chain/src/registryProxy/http-utils.js rename to packages/safe-chain/src/registryProxy/builtInProxy/http-utils.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/createInterceptorForEcoSystem.js similarity index 93% rename from packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/createInterceptorForEcoSystem.js index 79b5200..680be59 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/createInterceptorForEcoSystem.js @@ -2,7 +2,7 @@ import { ECOSYSTEM_JS, ECOSYSTEM_PY, getEcoSystem, -} from "../../config/settings.js"; +} from "../../../config/settings.js"; import { npmInterceptorForUrl } from "./npm/npmInterceptor.js"; import { pipInterceptorForUrl } from "./pipInterceptor.js"; diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/interceptorBuilder.js similarity index 100% rename from packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/interceptorBuilder.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js similarity index 91% rename from packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js index 14e3ba7..cfa708c 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js @@ -1,5 +1,8 @@ -import { getMinimumPackageAgeHours, getNpmMinimumPackageAgeExclusions } from "../../../config/settings.js"; -import { ui } from "../../../environment/userInteraction.js"; +import { + getMinimumPackageAgeHours, + getNpmMinimumPackageAgeExclusions, +} from "../../../../config/settings.js"; +import { ui } from "../../../../environment/userInteraction.js"; import { getHeaderValueAsString } from "../../http-utils.js"; const state = { @@ -68,15 +71,20 @@ export function modifyNpmInfoResponse(body, headers) { // Check if this package is excluded from minimum age filtering const packageName = bodyJson.name; const exclusions = getNpmMinimumPackageAgeExclusions(); - if (packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern))) { + if ( + packageName && + exclusions.some((pattern) => + matchesExclusionPattern(packageName, pattern), + ) + ) { ui.writeVerbose( - `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).` + `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).`, ); return body; } const cutOff = new Date( - new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 + new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000, ); const hasLatestTag = !!bodyJson["dist-tags"]["latest"]; @@ -113,7 +121,7 @@ export function modifyNpmInfoResponse(body, headers) { return Buffer.from(JSON.stringify(bodyJson)); } catch (/** @type {any} */ err) { ui.writeVerbose( - `Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}` + `Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}`, ); return body; } @@ -129,7 +137,7 @@ function deleteVersionFromJson(json, version) { const packageName = typeof json?.name === "string" ? json.name : "(unknown)"; ui.writeVerbose( - `Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).` + `Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`, ); delete json.time[version]; @@ -148,18 +156,20 @@ function deleteVersionFromJson(json, version) { */ function calculateLatestTag(tagList) { const entries = Object.entries(tagList).filter( - ([version, _]) => version !== "created" && version !== "modified" + ([version, _]) => version !== "created" && version !== "modified", ); const latestFullRelease = getMostRecentTag( - Object.fromEntries(entries.filter(([version, _]) => !version.includes("-"))) + Object.fromEntries( + entries.filter(([version, _]) => !version.includes("-")), + ), ); if (latestFullRelease) { return latestFullRelease; } const latestPrerelease = getMostRecentTag( - Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))) + Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))), ); return latestPrerelease; } diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.js similarity index 89% rename from packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.js index 3d3b8b4..a8c1a61 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.js @@ -1,8 +1,8 @@ import { getNpmCustomRegistries, skipMinimumPackageAge, -} from "../../../config/settings.js"; -import { isMalwarePackage } from "../../../scanning/audit/index.js"; +} from "../../../../config/settings.js"; +import { isMalwarePackage } from "../../../../scanning/audit/index.js"; import { interceptRequests } from "../interceptorBuilder.js"; import { isPackageInfoUrl, @@ -23,7 +23,7 @@ const knownJsRegistries = [ */ export function npmInterceptorForUrl(url) { const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find( - (reg) => url.includes(reg) + (reg) => url.includes(reg), ); if (registry) { @@ -41,7 +41,7 @@ function buildNpmInterceptor(registry) { return interceptRequests(async (reqContext) => { const { packageName, version } = parseNpmPackageUrl( reqContext.targetUrl, - registry + registry, ); if (await isMalwarePackage(packageName, version)) { diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js similarity index 94% rename from packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js index 834a2ad..302c5b8 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -6,23 +6,24 @@ describe("npmInterceptor minimum package age", async () => { let skipMinimumPackageAgeSetting = false; let minimumPackageAgeExclusionsSetting = []; - mock.module("../../../config/settings.js", { + mock.module("../../../../config/settings.js", { namedExports: { getMinimumPackageAgeHours: () => minimumPackageAgeSettings, skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, getNpmCustomRegistries: () => [], - getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting, + getNpmMinimumPackageAgeExclusions: () => + minimumPackageAgeExclusionsSetting, }, }); - mock.module("../../../scanning/audit/index.js", { + mock.module("../../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async () => { return false; }, }, }); - mock.module("../../../environment/userInteraction.js", { + mock.module("../../../../environment/userInteraction.js", { namedExports: { ui: { startProcess: () => {}, @@ -64,9 +65,8 @@ describe("npmInterceptor minimum package age", async () => { ]) { it(`modifyResponse should be true for package info requests: ${packageInfoUrl}`, async () => { const interceptor = npmInterceptorForUrl(packageInfoUrl); - const requestInterceptor = await interceptor.handleRequest( - packageInfoUrl - ); + const requestInterceptor = + await interceptor.handleRequest(packageInfoUrl); assert.equal(requestInterceptor.modifiesResponse(), true); }); @@ -120,9 +120,8 @@ describe("npmInterceptor minimum package age", async () => { ]) { it(`modifyResponse should be false for special endpoints: ${specialEndpoint}`, async () => { const interceptor = npmInterceptorForUrl(specialEndpoint); - const requestInterceptor = await interceptor.handleRequest( - specialEndpoint - ); + const requestInterceptor = + await interceptor.handleRequest(specialEndpoint); assert.equal(requestInterceptor.modifiesResponse(), false); }); @@ -152,7 +151,7 @@ describe("npmInterceptor minimum package age", async () => { ["2.0.0"]: getDate(-4), ["3.0.0"]: getDate(-3), }, - }) + }), ); const modifiedJson = JSON.parse(modifiedBody); @@ -193,7 +192,7 @@ describe("npmInterceptor minimum package age", async () => { ["2.0.0"]: getDate(-4), ["3.0.0"]: getDate(-3), }, - }) + }), ); const modifiedJson = JSON.parse(modifiedBody); @@ -225,7 +224,7 @@ describe("npmInterceptor minimum package age", async () => { // cutoff-date here ["2.0.0-alpha"]: getDate(-4), }, - }) + }), ); const modifiedJson = JSON.parse(modifiedBody); @@ -261,7 +260,7 @@ describe("npmInterceptor minimum package age", async () => { const modifiedBody = await runModifyNpmInfoRequest( packageUrl, - originalBody + originalBody, ); const modifiedJson = JSON.parse(modifiedBody); @@ -303,7 +302,7 @@ describe("npmInterceptor minimum package age", async () => { ["3.0.0"]: getDate(-40), // ~1.7 days old - should be removed ["4.0.0"]: getDate(-24), // 1 day old - should be removed }, - }) + }), ); const modifiedJson = JSON.parse(modifiedBody); @@ -347,7 +346,7 @@ describe("npmInterceptor minimum package age", async () => { // 1-hour cutoff here ["3.0.0"]: getDate(0), // just published - should be removed }, - }) + }), ); const modifiedJson = JSON.parse(modifiedBody); @@ -386,7 +385,10 @@ describe("npmInterceptor minimum package age", async () => { }, }); - const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + originalBody, + ); const modifiedJson = JSON.parse(modifiedBody); // All versions should remain unchanged since lodash is excluded @@ -416,7 +418,7 @@ describe("npmInterceptor minimum package age", async () => { ["1.0.0"]: getDate(-7), ["3.0.0"]: getDate(-3), }, - }) + }), ); const modifiedJson = JSON.parse(modifiedBody); @@ -446,7 +448,10 @@ describe("npmInterceptor minimum package age", async () => { }, }); - const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + originalBody, + ); const modifiedJson = JSON.parse(modifiedBody); // All versions should remain for excluded scoped package @@ -474,7 +479,10 @@ describe("npmInterceptor minimum package age", async () => { }, }); - const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + originalBody, + ); const modifiedJson = JSON.parse(modifiedBody); // All versions should remain since lodash is in the exclusion list @@ -500,7 +508,10 @@ describe("npmInterceptor minimum package age", async () => { }, }); - const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + originalBody, + ); const modifiedJson = JSON.parse(modifiedBody); // All versions should remain since @aikidosec/* matches @aikidosec/safe-chain @@ -528,7 +539,10 @@ describe("npmInterceptor minimum package age", async () => { }, }); - const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + originalBody, + ); const modifiedJson = JSON.parse(modifiedBody); // Version 2.0.0 should be filtered since @other/package doesn't match @aikidosec/* @@ -555,7 +569,7 @@ describe("npmInterceptor minimum package age", async () => { ["1.0.0"]: getDate(-100), ["2.0.0"]: getDate(-1), }, - }) + }), ); const modifiedJson = JSON.parse(modifiedBody); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js similarity index 95% rename from packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index e1b7c79..5cba422 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -5,7 +5,7 @@ let lastPackage; let malwareResponse = false; let customRegistries = []; -mock.module("../../../scanning/audit/index.js", { +mock.module("../../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { lastPackage = { packageName, version }; @@ -14,7 +14,7 @@ mock.module("../../../scanning/audit/index.js", { }, }); -mock.module("../../../config/settings.js", { +mock.module("../../../../config/settings.js", { namedExports: { LOGGING_SILENT: "silent", LOGGING_NORMAL: "normal", @@ -136,7 +136,7 @@ describe("npmInterceptor", async () => { const interceptor = npmInterceptorForUrl(url); assert.ok( interceptor, - "Interceptor should be created for known npm registry" + "Interceptor should be created for known npm registry", ); await interceptor.handleRequest(url); @@ -153,7 +153,7 @@ describe("npmInterceptor", async () => { assert.equal( interceptor, undefined, - "Interceptor should be undefined for unknown registry" + "Interceptor should be undefined for unknown registry", ); }); @@ -170,12 +170,12 @@ describe("npmInterceptor", async () => { assert.equal( result.blockResponse.statusCode, 403, - "Block response should have status code 403" + "Block response should have status code 403", ); assert.equal( result.blockResponse.message, "Forbidden - blocked by safe-chain", - "Block response should have correct status message" + "Block response should have correct status message", ); }); }); @@ -212,7 +212,7 @@ describe("npmInterceptor with custom registries", async () => { assert.ok( interceptor, - "Interceptor should be created for custom registry with scoped package" + "Interceptor should be created for custom registry with scoped package", ); await interceptor.handleRequest(url); @@ -262,7 +262,7 @@ describe("npmInterceptor with custom registries", async () => { assert.equal( interceptor, undefined, - "Should not create interceptor for unknown registry" + "Should not create interceptor for unknown registry", ); }); }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/parseNpmPackageUrl.js similarity index 100% rename from packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/parseNpmPackageUrl.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pipInterceptor.js similarity index 90% rename from packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pipInterceptor.js index e781e30..47cdee8 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pipInterceptor.js @@ -1,5 +1,5 @@ -import { getPipCustomRegistries } from "../../config/settings.js"; -import { isMalwarePackage } from "../../scanning/audit/index.js"; +import { getPipCustomRegistries } from "../../../config/settings.js"; +import { isMalwarePackage } from "../../../scanning/audit/index.js"; import { interceptRequests } from "./interceptorBuilder.js"; const knownPipRegistries = [ @@ -33,16 +33,18 @@ function buildPipInterceptor(registry) { return interceptRequests(async (reqContext) => { const { packageName, version } = parsePipPackageFromUrl( reqContext.targetUrl, - registry + registry, ); // Normalize underscores to hyphens for DB matching, as PyPI allows underscores in distribution names. // Per python, packages that differ only by hyphen vs underscore are considered the same. - const hyphenName = packageName?.includes("_") ? packageName.replace(/_/g, "-") : packageName; + const hyphenName = packageName?.includes("_") + ? packageName.replace(/_/g, "-") + : packageName; const isMalicious = - await isMalwarePackage(packageName, version) - || await isMalwarePackage(hyphenName, version); + (await isMalwarePackage(packageName, version)) || + (await isMalwarePackage(hyphenName, version)); if (isMalicious) { reqContext.blockMalware(packageName, version); @@ -110,7 +112,8 @@ function parsePipPackageFromUrl(url, registry) { } // Source dist (sdist) and potential metadata sidecars (e.g., .tar.gz.metadata) - const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i; + const sdistExtWithMetadataRe = + /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i; const sdistExtMatch = filename.match(sdistExtWithMetadataRe); if (sdistExtMatch) { const base = filename.replace(sdistExtWithMetadataRe, ""); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js similarity index 88% rename from packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js index fc9c91e..b57218e 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js @@ -6,13 +6,13 @@ describe("pipInterceptor custom registries", async () => { let malwareResponse = false; let customRegistries = []; - mock.module("../../config/settings.js", { + mock.module("../../../config/settings.js", { namedExports: { getPipCustomRegistries: () => customRegistries, }, }); - mock.module("../../scanning/audit/index.js", { + mock.module("../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { lastPackage = { packageName, version }; @@ -30,10 +30,7 @@ describe("pipInterceptor custom registries", async () => { const interceptor = pipInterceptorForUrl(url); - assert.ok( - interceptor, - "Interceptor should be created for custom registry" - ); + assert.ok(interceptor, "Interceptor should be created for custom registry"); }); it("should parse package from custom registry URL", async () => { @@ -69,10 +66,7 @@ describe("pipInterceptor custom registries", async () => { }); it("should handle multiple custom registries", async () => { - customRegistries = [ - "registry-one.example.com", - "registry-two.example.com", - ]; + customRegistries = ["registry-one.example.com", "registry-two.example.com"]; const url1 = "https://registry-one.example.com/packages/package1-1.0.0.tar.gz"; @@ -85,7 +79,7 @@ describe("pipInterceptor custom registries", async () => { assert.ok(interceptor1, "Interceptor should be created for first registry"); assert.ok( interceptor2, - "Interceptor should be created for second registry" + "Interceptor should be created for second registry", ); }); @@ -105,12 +99,12 @@ describe("pipInterceptor custom registries", async () => { assert.equal( result.blockResponse.statusCode, 403, - "Block response should have status code 403" + "Block response should have status code 403", ); assert.equal( result.blockResponse.message, "Forbidden - blocked by safe-chain", - "Block response should have correct status message" + "Block response should have correct status message", ); malwareResponse = false; @@ -126,7 +120,7 @@ describe("pipInterceptor custom registries", async () => { assert.ok( interceptor, - "Interceptor should be created for known registry even with custom registries set" + "Interceptor should be created for known registry even with custom registries set", ); await interceptor.handleRequest(url); @@ -139,14 +133,15 @@ describe("pipInterceptor custom registries", async () => { it("should not create interceptor for unknown registry when custom registries are set", () => { customRegistries = ["my-custom-registry.example.com"]; - const url = "https://unknown-registry.example.com/packages/foobar-1.0.0.tar.gz"; + const url = + "https://unknown-registry.example.com/packages/foobar-1.0.0.tar.gz"; const interceptor = pipInterceptorForUrl(url); assert.equal( interceptor, undefined, - "Interceptor should be undefined for unknown registry" + "Interceptor should be undefined for unknown registry", ); }); @@ -160,7 +155,7 @@ describe("pipInterceptor custom registries", async () => { assert.equal( interceptor, undefined, - "Interceptor should be undefined when no custom registries are configured" + "Interceptor should be undefined when no custom registries are configured", ); }); @@ -196,4 +191,3 @@ describe("pipInterceptor custom registries", async () => { }); }); }); - diff --git a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pipInterceptor.spec.js similarity index 94% rename from packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pipInterceptor.spec.js index 482a800..dd812f1 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pipInterceptor.spec.js @@ -5,7 +5,7 @@ describe("pipInterceptor", async () => { let lastPackage; let malwareResponse = false; - mock.module("../../scanning/audit/index.js", { + mock.module("../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { lastPackage = { packageName, version }; @@ -100,7 +100,7 @@ describe("pipInterceptor", async () => { const interceptor = pipInterceptorForUrl(url); assert.ok( interceptor, - "Interceptor should be created for known npm registry" + "Interceptor should be created for known npm registry", ); await interceptor.handleRequest(url); @@ -117,7 +117,7 @@ describe("pipInterceptor", async () => { assert.equal( interceptor, undefined, - "Interceptor should be undefined for unknown registry" + "Interceptor should be undefined for unknown registry", ); }); @@ -134,12 +134,12 @@ describe("pipInterceptor", async () => { assert.equal( result.blockResponse.statusCode, 403, - "Block response should have status code 403" + "Block response should have status code 403", ); assert.equal( result.blockResponse.message, "Forbidden - blocked by safe-chain", - "Block response should have correct status message" + "Block response should have correct status message", ); }); }); diff --git a/packages/safe-chain/src/registryProxy/isImdsEndpoint.js b/packages/safe-chain/src/registryProxy/builtInProxy/isImdsEndpoint.js similarity index 100% rename from packages/safe-chain/src/registryProxy/isImdsEndpoint.js rename to packages/safe-chain/src/registryProxy/builtInProxy/isImdsEndpoint.js diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/builtInProxy/mitmRequestHandler.js similarity index 96% rename from packages/safe-chain/src/registryProxy/mitmRequestHandler.js rename to packages/safe-chain/src/registryProxy/builtInProxy/mitmRequestHandler.js index 8268559..9a45270 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/mitmRequestHandler.js @@ -1,7 +1,7 @@ import https from "https"; import { generateCertForHost } from "./certUtils.js"; import { HttpsProxyAgent } from "https-proxy-agent"; -import { ui } from "../environment/userInteraction.js"; +import { ui } from "../../environment/userInteraction.js"; import { gunzipSync, gzipSync } from "zlib"; /** @@ -19,7 +19,7 @@ export function mitmConnect(req, clientSocket, interceptor) { clientSocket.on("error", (err) => { ui.writeVerbose( - `Safe-chain: Client socket error for ${req.url}: ${err.message}` + `Safe-chain: Client socket error for ${req.url}: ${err.message}`, ); // NO-OP // This can happen if the client TCP socket sends RST instead of FIN. @@ -89,7 +89,7 @@ function createHttpsServer(hostname, port, interceptor) { key: cert.privateKey, cert: cert.certificate, }, - handleRequest + handleRequest, ); return server; @@ -119,7 +119,7 @@ function forwardRequest(req, hostname, port, res, requestHandler) { proxyReq.on("error", (err) => { ui.writeVerbose( - `Safe-chain: Error occurred while proxying request to ${req.url} for ${hostname}: ${err.message}` + `Safe-chain: Error occurred while proxying request to ${req.url} for ${hostname}: ${err.message}`, ); res.writeHead(502); res.end("Bad Gateway"); @@ -127,7 +127,7 @@ function forwardRequest(req, hostname, port, res, requestHandler) { req.on("error", (err) => { ui.writeError( - `Safe-chain: Error reading client request to ${req.url} for ${hostname}: ${err.message}` + `Safe-chain: Error reading client request to ${req.url} for ${hostname}: ${err.message}`, ); proxyReq.destroy(); }); @@ -138,7 +138,7 @@ function forwardRequest(req, hostname, port, res, requestHandler) { req.on("end", () => { ui.writeVerbose( - `Safe-chain: Finished proxying request to ${req.url} for ${hostname}` + `Safe-chain: Finished proxying request to ${req.url} for ${hostname}`, ); proxyReq.end(); }); @@ -180,7 +180,7 @@ function createProxyRequest(hostname, port, req, res, requestHandler) { const proxyReq = https.request(options, (proxyRes) => { proxyRes.on("error", (err) => { ui.writeError( - `Safe-chain: Error reading upstream response to ${req.url} for ${hostname}: ${err.message}` + `Safe-chain: Error reading upstream response to ${req.url} for ${hostname}: ${err.message}`, ); if (!res.headersSent) { res.writeHead(502); @@ -190,7 +190,7 @@ function createProxyRequest(hostname, port, req, res, requestHandler) { if (!proxyRes.statusCode) { ui.writeError( - `Safe-chain: Proxy response missing status code to ${req.url} for ${hostname}` + `Safe-chain: Proxy response missing status code to ${req.url} for ${hostname}`, ); res.writeHead(500); res.end("Internal Server Error"); diff --git a/packages/safe-chain/src/registryProxy/plainHttpProxy.js b/packages/safe-chain/src/registryProxy/builtInProxy/plainHttpProxy.js similarity index 97% rename from packages/safe-chain/src/registryProxy/plainHttpProxy.js rename to packages/safe-chain/src/registryProxy/builtInProxy/plainHttpProxy.js index 75b9d77..6d74588 100644 --- a/packages/safe-chain/src/registryProxy/plainHttpProxy.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/plainHttpProxy.js @@ -1,6 +1,6 @@ import * as http from "http"; import * as https from "https"; -import { ui } from "../environment/userInteraction.js"; +import { ui } from "../../environment/userInteraction.js"; /** * @param {import("http").IncomingMessage} req @@ -61,7 +61,7 @@ export function handleHttpProxyRequest(req, res) { res.end(); } }); - } + }, ) .on("error", (err) => { if (!res.headersSent) { diff --git a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js b/packages/safe-chain/src/registryProxy/builtInProxy/tunnelRequestHandler.js similarity index 95% rename from packages/safe-chain/src/registryProxy/tunnelRequestHandler.js rename to packages/safe-chain/src/registryProxy/builtInProxy/tunnelRequestHandler.js index 5eac381..f7a3b9d 100644 --- a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/tunnelRequestHandler.js @@ -1,5 +1,5 @@ import * as net from "net"; -import { ui } from "../environment/userInteraction.js"; +import { ui } from "../../environment/userInteraction.js"; import { isImdsEndpoint } from "./isImdsEndpoint.js"; import { getConnectTimeout } from "./getConnectTimeout.js"; @@ -49,11 +49,11 @@ function tunnelRequestToDestination(req, clientSocket, head) { clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); if (isImds) { ui.writeVerbose( - `Safe-chain: Closing connection because previously timedout connect to ${hostname}` + `Safe-chain: Closing connection because previously timedout connect to ${hostname}`, ); } else { ui.writeError( - `Safe-chain: Closing connection because previously timedout connect to ${hostname}` + `Safe-chain: Closing connection because previously timedout connect to ${hostname}`, ); } return; @@ -67,11 +67,11 @@ function tunnelRequestToDestination(req, clientSocket, head) { if (isImds) { timedoutImdsEndpoints.push(hostname); ui.writeVerbose( - `Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms` + `Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`, ); } else { ui.writeError( - `Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms` + `Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`, ); } serverSocket.destroy(); @@ -111,11 +111,11 @@ function tunnelRequestToDestination(req, clientSocket, head) { clearTimeout(connectTimer); if (isImds) { ui.writeVerbose( - `Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}` + `Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`, ); } else { ui.writeError( - `Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}` + `Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`, ); } if (clientSocket.writable) { @@ -173,7 +173,7 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { clientSocket.pipe(proxySocket); } else { ui.writeError( - `Safe-chain: proxy CONNECT failed: ${response.split("\r\n")[0]}` + `Safe-chain: proxy CONNECT failed: ${response.split("\r\n")[0]}`, ); if (clientSocket.writable) { clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); @@ -189,14 +189,14 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { ui.writeError( `Safe-chain: error connecting to proxy ${proxy.hostname}:${ proxy.port || 8080 - } - ${err.message}` + } - ${err.message}`, ); if (clientSocket.writable) { clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); } } else { ui.writeError( - `Safe-chain: proxy socket error after connection - ${err.message}` + `Safe-chain: proxy socket error after connection - ${err.message}`, ); if (clientSocket.writable) { clientSocket.end(); @@ -210,4 +210,3 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { } }); } - diff --git a/packages/safe-chain/src/registryProxy/createBuiltInProxyServer.js b/packages/safe-chain/src/registryProxy/createBuiltInProxyServer.js new file mode 100644 index 0000000..7703978 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/createBuiltInProxyServer.js @@ -0,0 +1,150 @@ +import * as http from "http"; +import { tunnelRequest } from "./tunnelRequestHandler.js"; +import { mitmConnect } from "./mitmRequestHandler.js"; +import { handleHttpProxyRequest } from "./plainHttpProxy.js"; +import { getCombinedCaBundlePath } from "./builtInProxy/certBundle.js"; +import { ui } from "../environment/userInteraction.js"; +import chalk from "chalk"; +import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; +import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; + +/** * + * @returns {import("./registryProxy.js").SafeChainProxy} */ +export function createBuiltInProxyServer() { + const SERVER_STOP_TIMEOUT_MS = 1000; + /** + * @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}} + */ + const state = { + port: null, + blockedRequests: [], + }; + + const server = http.createServer( + // This handles direct HTTP requests (non-CONNECT requests) + // This is normally http-only traffic, but we also handle + // https for clients that don't properly use CONNECT + handleHttpProxyRequest, + ); + + // This handles HTTPS requests via the CONNECT method + server.on("connect", handleConnect); + + return { + startServer: () => startServer(server), + stopServer: () => stopServer(server), + verifyNoMaliciousPackages, + hasSuppressedVersions: getHasSuppressedVersions, + getServerPort: () => state.port, + getCombinedCaBundlePath, + }; + + /** + * @param {import("http").Server} server + * + * @returns {Promise} + */ + 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") { + state.port = address.port; + resolve(); + } else { + reject(new Error("Failed to start proxy server")); + } + }); + + server.on("error", (err) => { + reject(err); + }); + }); + } + + /** + * @param {import("http").Server} server + * + * @returns {Promise} + */ + function stopServer(server) { + return new Promise((resolve) => { + try { + server.close(() => { + resolve(); + }); + } catch { + resolve(); + } + setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS); + }); + } + + /** + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} clientSocket + * @param {Buffer} head + * + * @returns {void} + */ + function handleConnect(req, clientSocket, head) { + // CONNECT method is used for HTTPS requests + // It establishes a tunnel to the server identified by the request URL + + const interceptor = createInterceptorForUrl(req.url || ""); + + if (interceptor) { + // Subscribe to malware blocked events + interceptor.on( + "malwareBlocked", + ( + /** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event, + ) => { + onMalwareBlocked(event.packageName, event.version, event.targetUrl); + }, + ); + + mitmConnect(req, clientSocket, interceptor); + } else { + // For other hosts, just tunnel the request to the destination tcp socket + ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`); + tunnelRequest(req, clientSocket, head); + } + } + + /** + * + * @param {string} packageName + * @param {string} version + * @param {string} url + */ + function onMalwareBlocked(packageName, version, url) { + state.blockedRequests.push({ packageName, version, url }); + } + + function verifyNoMaliciousPackages() { + if (state.blockedRequests.length === 0) { + // No malicious packages were blocked, so nothing to block + return true; + } + + 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.writeExitWithoutInstallingMaliciousPackages(); + ui.emptyLine(); + + return false; + } +} diff --git a/packages/safe-chain/src/registryProxy/createRamaProxy.js b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js similarity index 90% rename from packages/safe-chain/src/registryProxy/createRamaProxy.js rename to packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js index 553e201..42b6549 100644 --- a/packages/safe-chain/src/registryProxy/createRamaProxy.js +++ b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js @@ -1,18 +1,18 @@ -import { ChildProcess, spawn } from "node:child_process"; +import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdtempSync, readFile, writeFile } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; -import { ui } from "../environment/userInteraction.js"; -import { getLoggingLevel, LOGGING_VERBOSE } from "../config/settings.js"; +import { ui } from "../../environment/userInteraction.js"; +import { getLoggingLevel, LOGGING_VERBOSE } from "../../config/settings.js"; const readFilePromise = promisify(readFile); const writeFilePromise = promisify(writeFile); /** * @typedef {Object} RamaProxyInstance - * @property {ChildProcess} process + * @property {import("node:child_process").ChildProcess} process * @property {string} proxyAddress * @property {string} metaAddress * @property {string} certPath @@ -35,7 +35,7 @@ export function getRamaPath() { /** * @param {string} ramaPath * - * @returns {import("./registryProxy.js").SafeChainProxy} */ + * @returns {import("../registryProxy.js").SafeChainProxy} */ export function createRamaProxy(ramaPath) { const tempDir = mkdtempSync(join(tmpdir(), "safe-chain-proxy-")); /** @type {RamaProxyInstance | null} */ @@ -45,7 +45,7 @@ export function createRamaProxy(ramaPath) { startServer: async () => { ramaInstance = await startRama(ramaPath, tempDir); ui.writeVerbose( - `Proxy started at address "${ramaInstance.proxyAddress}"` + `Proxy started at address "${ramaInstance.proxyAddress}"`, ); }, stopServer: async () => { @@ -98,7 +98,7 @@ async function startRama(ramaPath, dataFolder) { const proxyAddress = await readFilePromise(proxyAddrPath, "utf-8"); const metaAddress = await readFilePromise( join(dataFolder, "meta.addr.txt"), - "utf-8" + "utf-8", ); const certResponse = await fetch(`http://${metaAddress}/ca`); diff --git a/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js index ace84ee..a4288f0 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js @@ -14,14 +14,14 @@ const mockIsImdsEndpoint = (host) => { ].includes(host); }; -mock.module("./isImdsEndpoint.js", { +mock.module("./builtInProxy/isImdsEndpoint.js", { namedExports: { isImdsEndpoint: mockIsImdsEndpoint, }, }); // Mock getConnectTimeout to speed up tests -mock.module("./getConnectTimeout.js", { +mock.module("./builtInProxy/getConnectTimeout.js", { namedExports: { getConnectTimeout: (host) => { // IMDS endpoints: 100ms (real: 3s) @@ -56,7 +56,7 @@ describe("registryProxy.connectTunnel", () => { const tunnelResponse = await establishHttpsTunnel( socket, "postman-echo.com", - 443 + 443, ); assert.ok(tunnelResponse.includes("HTTP/1.1 200 Connection Established")); @@ -69,7 +69,7 @@ describe("registryProxy.connectTunnel", () => { const httpsResponse = await sendHttpsRequestThroughTunnel( socket, "GET", - new URL("https://postman-echo.com/status/200") + new URL("https://postman-echo.com/status/200"), ); assert.ok(httpsResponse.includes("HTTP/1.1 200 OK")); @@ -85,25 +85,25 @@ describe("registryProxy.connectTunnel", () => { // without interception by the safe-chain MITM proxy. const certInfo = await getTlsCertificateInfo( socket, - new URL("https://postman-echo.com") + new URL("https://postman-echo.com"), ); // Verify the certificate is NOT issued by our safe-chain CA // Our self-signed CA would have issuer: "Safe-Chain Proxy CA" assert.ok( certInfo.issuer !== undefined, - "Certificate should have an issuer" + "Certificate should have an issuer", ); assert.ok( !certInfo.issuer.includes("Safe-Chain"), - `Tunnel should use destination's real certificate, not safe-chain CA. Issuer: ${certInfo.issuer}` + `Tunnel should use destination's real certificate, not safe-chain CA. Issuer: ${certInfo.issuer}`, ); // Verify it's a real certificate with proper hostname assert.strictEqual( certInfo.subject.includes("postman-echo.com"), true, - `Certificate subject should include postman-echo.com, got: ${certInfo.subject}` + `Certificate subject should include postman-echo.com, got: ${certInfo.subject}`, ); socket.destroy(); @@ -185,13 +185,13 @@ describe("registryProxy.connectTunnel", () => { // Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts) assert.ok( responseData.includes("HTTP/1.1 504 Gateway Timeout"), - "Should return 504 for timeout" + "Should return 504 for timeout", ); // Should timeout around 100ms for IMDS endpoints (allow some margin) assert.ok( duration >= 80 && duration < 200, - `IMDS timeout should be ~80-200ms, got ${duration}ms` + `IMDS timeout should be ~80-200ms, got ${duration}ms`, ); socket.destroy(); @@ -232,13 +232,13 @@ describe("registryProxy.connectTunnel", () => { // Should return 502 immediately (cached timeout) assert.ok( responseData.includes("HTTP/1.1 502 Bad Gateway"), - "Should return 502 for cached timeout" + "Should return 502 for cached timeout", ); // Should be nearly instant (< 50ms) since it's cached assert.ok( duration < 50, - `Cached IMDS timeout should be instant, got ${duration}ms` + `Cached IMDS timeout should be instant, got ${duration}ms`, ); socket2.destroy(); @@ -283,14 +283,14 @@ describe("registryProxy.connectTunnel", () => { // Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts) assert.ok( responseData.includes("HTTP/1.1 504 Gateway Timeout"), - "Should return 504 for timeout" + "Should return 504 for timeout", ); // Should NOT be instant - it should retry the connection (taking ~500ms due to mock timeout) // If it was cached, it would return in < 50ms assert.ok( duration >= 400, - `Non-IMDS timeout should NOT be cached, but got instant response in ${duration}ms` + `Non-IMDS timeout should NOT be cached, but got instant response in ${duration}ms`, ); socket2.destroy(); @@ -343,7 +343,7 @@ function sendHttpsRequestThroughTunnel( socket, verb, url, - rejectUnauthorized = false + rejectUnauthorized = false, ) { return new Promise((resolve, reject) => { const tlsSocket = tls.connect( @@ -356,9 +356,9 @@ function sendHttpsRequestThroughTunnel( }, () => { tlsSocket.write( - `${verb} ${url.pathname} HTTP/1.1\r\nHost: ${url.hostname}\r\nConnection: close\r\n\r\n` + `${verb} ${url.pathname} HTTP/1.1\r\nHost: ${url.hostname}\r\nConnection: close\r\n\r\n`, ); - } + }, ); let tlsData = ""; @@ -404,7 +404,7 @@ function getTlsCertificateInfo(socket, url) { tlsSocket.end(); resolve({ issuer, subject }); - } + }, ); tlsSocket.on("error", (err) => { diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 3f67e6f..fb08398 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -1,13 +1,6 @@ -import * as http from "http"; -import { tunnelRequest } from "./tunnelRequestHandler.js"; -import { mitmConnect } from "./mitmRequestHandler.js"; -import { handleHttpProxyRequest } from "./plainHttpProxy.js"; -import { getCombinedCaBundlePath } from "./certBundle.js"; import { ui } from "../environment/userInteraction.js"; -import chalk from "chalk"; -import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; -import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; -import { createRamaProxy, getRamaPath } from "./createRamaProxy.js"; +import { createRamaProxy, getRamaPath } from "./ramaProxy/createRamaProxy.js"; +import { createBuiltInProxyServer } from "./builtInProxy/createBuiltInProxyServer.js"; /** * @typedef {Object} SafeChainProxy @@ -77,143 +70,3 @@ export function mergeSafeChainProxyEnvironmentVariables(env) { return proxyEnv; } - -/** @returns {SafeChainProxy} */ -function createBuiltInProxyServer() { - const SERVER_STOP_TIMEOUT_MS = 1000; - /** - * @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}} - */ - const state = { - port: null, - blockedRequests: [], - }; - - const server = http.createServer( - // This handles direct HTTP requests (non-CONNECT requests) - // This is normally http-only traffic, but we also handle - // https for clients that don't properly use CONNECT - handleHttpProxyRequest - ); - - // This handles HTTPS requests via the CONNECT method - server.on("connect", handleConnect); - - return { - startServer: () => startServer(server), - stopServer: () => stopServer(server), - verifyNoMaliciousPackages, - hasSuppressedVersions: getHasSuppressedVersions, - getServerPort: () => state.port, - getCombinedCaBundlePath, - }; - - /** - * @param {import("http").Server} server - * - * @returns {Promise} - */ - 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") { - state.port = address.port; - resolve(); - } else { - reject(new Error("Failed to start proxy server")); - } - }); - - server.on("error", (err) => { - reject(err); - }); - }); - } - - /** - * @param {import("http").Server} server - * - * @returns {Promise} - */ - function stopServer(server) { - return new Promise((resolve) => { - try { - server.close(() => { - resolve(); - }); - } catch { - resolve(); - } - setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS); - }); - } - - /** - * @param {import("http").IncomingMessage} req - * @param {import("http").ServerResponse} clientSocket - * @param {Buffer} head - * - * @returns {void} - */ - function handleConnect(req, clientSocket, head) { - // CONNECT method is used for HTTPS requests - // It establishes a tunnel to the server identified by the request URL - - const interceptor = createInterceptorForUrl(req.url || ""); - - if (interceptor) { - // Subscribe to malware blocked events - interceptor.on( - "malwareBlocked", - ( - /** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event - ) => { - onMalwareBlocked(event.packageName, event.version, event.targetUrl); - } - ); - - mitmConnect(req, clientSocket, interceptor); - } else { - // For other hosts, just tunnel the request to the destination tcp socket - ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`); - tunnelRequest(req, clientSocket, head); - } - } - - /** - * - * @param {string} packageName - * @param {string} version - * @param {string} url - */ - function onMalwareBlocked(packageName, version, url) { - state.blockedRequests.push({ packageName, version, url }); - } - - function verifyNoMaliciousPackages() { - if (state.blockedRequests.length === 0) { - // No malicious packages were blocked, so nothing to block - return true; - } - - 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.writeExitWithoutInstallingMaliciousPackages(); - ui.emptyLine(); - - return false; - } -} diff --git a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js index 407aa3c..4ac9fed 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js @@ -7,7 +7,7 @@ import { createSafeChainProxy, mergeSafeChainProxyEnvironmentVariables, } from "./registryProxy.js"; -import { getCaCertPath } from "./certUtils.js"; +import { getCaCertPath } from "./builtInProxy/certUtils.js"; import { setEcoSystem, ECOSYSTEM_JS,