mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Move existing proxy files to builtInProxy folder
This commit is contained in:
parent
03ecd0dfb9
commit
ca071729be
31 changed files with 766 additions and 397 deletions
|
|
@ -1,8 +1,13 @@
|
||||||
import { ui } from "../../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||||
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
|
import { getCombinedCaBundlePath } from "../../registryProxy/builtInProxy/certBundle.js";
|
||||||
import { PIP_COMMAND, PIP3_COMMAND, PYTHON_COMMAND, PYTHON3_COMMAND } from "./pipSettings.js";
|
import {
|
||||||
|
PIP_COMMAND,
|
||||||
|
PIP3_COMMAND,
|
||||||
|
PYTHON_COMMAND,
|
||||||
|
PYTHON3_COMMAND,
|
||||||
|
} from "./pipSettings.js";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import fsSync from "node:fs";
|
import fsSync from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
@ -20,7 +25,11 @@ import { spawn } from "child_process";
|
||||||
export function shouldBypassSafeChain(command, args) {
|
export function shouldBypassSafeChain(command, args) {
|
||||||
if (command === PYTHON_COMMAND || command === PYTHON3_COMMAND) {
|
if (command === PYTHON_COMMAND || command === PYTHON3_COMMAND) {
|
||||||
// Check if args start with -m pip
|
// 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 false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -39,19 +48,25 @@ export function shouldBypassSafeChain(command, args) {
|
||||||
function setFallbackCaBundleEnvironmentVariables(env, combinedCaPath) {
|
function setFallbackCaBundleEnvironmentVariables(env, combinedCaPath) {
|
||||||
// REQUESTS_CA_BUNDLE: Used by the popular 'requests' library
|
// REQUESTS_CA_BUNDLE: Used by the popular 'requests' library
|
||||||
if (env.REQUESTS_CA_BUNDLE) {
|
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;
|
env.REQUESTS_CA_BUNDLE = combinedCaPath;
|
||||||
|
|
||||||
// SSL_CERT_FILE: Used by some Python SSL libraries and urllib
|
// SSL_CERT_FILE: Used by some Python SSL libraries and urllib
|
||||||
if (env.SSL_CERT_FILE) {
|
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;
|
env.SSL_CERT_FILE = combinedCaPath;
|
||||||
|
|
||||||
// PIP_CERT: Pip's own environment variable for certificate verification
|
// PIP_CERT: Pip's own environment variable for certificate verification
|
||||||
if (env.PIP_CERT) {
|
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;
|
env.PIP_CERT = combinedCaPath;
|
||||||
}
|
}
|
||||||
|
|
@ -76,12 +91,16 @@ function setFallbackCaBundleEnvironmentVariables(env, combinedCaPath) {
|
||||||
export async function runPip(command, args) {
|
export async function runPip(command, args) {
|
||||||
// Check if we should bypass safe-chain (python/python3 without -m pip)
|
// Check if we should bypass safe-chain (python/python3 without -m pip)
|
||||||
if (shouldBypassSafeChain(command, args)) {
|
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
|
// Spawn the ORIGINAL command with ORIGINAL args
|
||||||
return new Promise((_resolve) => {
|
return new Promise((_resolve) => {
|
||||||
const proc = spawn(command, args, { stdio: "inherit" });
|
const proc = spawn(command, args, { stdio: "inherit" });
|
||||||
proc.on("exit", (/** @type {number | null} */ code) => {
|
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();
|
ui.writeBufferedLogsAndStopBuffering();
|
||||||
process.exit(code ?? 0);
|
process.exit(code ?? 0);
|
||||||
});
|
});
|
||||||
|
|
@ -104,22 +123,26 @@ export async function runPip(command, args) {
|
||||||
// Commands that need access to persistent config/cache/state files
|
// Commands that need access to persistent config/cache/state files
|
||||||
// These should not have PIP_CONFIG_FILE overridden as it would prevent them from
|
// 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
|
// reading/writing to the user's actual pip configuration and cache directories
|
||||||
const configRelatedCommands = ['config', 'cache', 'debug', 'completion'];
|
const configRelatedCommands = ["config", "cache", "debug", "completion"];
|
||||||
const isConfigRelatedCommand = args.length > 0 && configRelatedCommands.includes(args[0]);
|
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)
|
// 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.
|
// 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),
|
// 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
|
// 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 tmpDir = os.tmpdir();
|
||||||
const pipConfigPath = path.join(tmpDir, `safe-chain-pip-${Date.now()}.ini`);
|
const pipConfigPath = path.join(tmpDir, `safe-chain-pip-${Date.now()}.ini`);
|
||||||
let cleanupConfigPath = null; // Track temp file for cleanup
|
let cleanupConfigPath = null; // Track temp file for cleanup
|
||||||
|
|
||||||
if (isConfigRelatedCommand) {
|
if (isConfigRelatedCommand) {
|
||||||
ui.writeVerbose(`Safe-chain: Skipping PIP_CONFIG_FILE override for 'pip ${args[0]}' command to allow persistent config/cache access.`);
|
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
|
// Still set the fallback CA bundle environment variables to avoid edge cases where a
|
||||||
// plugin or extension triggers a network call during config introspection
|
// plugin or extension triggers a network call during config introspection
|
||||||
|
|
@ -145,9 +168,10 @@ export async function runPip(command, args) {
|
||||||
await fs.writeFile(pipConfigPath, pipConfig);
|
await fs.writeFile(pipConfigPath, pipConfig);
|
||||||
env.PIP_CONFIG_FILE = pipConfigPath;
|
env.PIP_CONFIG_FILE = pipConfigPath;
|
||||||
cleanupConfigPath = pipConfigPath;
|
cleanupConfigPath = pipConfigPath;
|
||||||
|
|
||||||
} else if (fsSync.existsSync(env.PIP_CONFIG_FILE)) {
|
} 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;
|
const userConfig = env.PIP_CONFIG_FILE;
|
||||||
|
|
||||||
// Read the existing config without modifying it
|
// Read the existing config without modifying it
|
||||||
|
|
@ -159,13 +183,17 @@ export async function runPip(command, args) {
|
||||||
|
|
||||||
// Cert
|
// Cert
|
||||||
if (typeof parsed.global.cert !== "undefined") {
|
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;
|
parsed.global.cert = combinedCaPath;
|
||||||
|
|
||||||
// Proxy
|
// Proxy
|
||||||
if (typeof parsed.global.proxy !== "undefined") {
|
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) {
|
if (proxy) {
|
||||||
parsed.global.proxy = proxy;
|
parsed.global.proxy = proxy;
|
||||||
|
|
@ -177,7 +205,6 @@ export async function runPip(command, args) {
|
||||||
await fs.writeFile(pipConfigPath, updated, "utf-8");
|
await fs.writeFile(pipConfigPath, updated, "utf-8");
|
||||||
env.PIP_CONFIG_FILE = pipConfigPath;
|
env.PIP_CONFIG_FILE = pipConfigPath;
|
||||||
cleanupConfigPath = pipConfigPath;
|
cleanupConfigPath = pipConfigPath;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// The user provided PIP_CONFIG_FILE does not exist on disk
|
// The user provided PIP_CONFIG_FILE does not exist on disk
|
||||||
// PIP will handle this as an error and inform the user
|
// PIP will handle this as an error and inform the user
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,10 @@ describe("runPipCommand environment variable handling", () => {
|
||||||
// Capture the config file content before the function cleans it up
|
// Capture the config file content before the function cleans it up
|
||||||
if (options.env.PIP_CONFIG_FILE) {
|
if (options.env.PIP_CONFIG_FILE) {
|
||||||
try {
|
try {
|
||||||
capturedConfigContent = await fs.readFile(options.env.PIP_CONFIG_FILE, "utf-8");
|
capturedConfigContent = await fs.readFile(
|
||||||
|
options.env.PIP_CONFIG_FILE,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore if file doesn't exist or can't be read
|
// 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 certBundle to return a test combined bundle path
|
||||||
mock.module("../../registryProxy/certBundle.js", {
|
mock.module("../../registryProxy/builtInProxy/certBundle.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem",
|
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 () => {
|
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.strictEqual(res.status, 0);
|
||||||
assert.ok(capturedArgs, "safeSpawn should have been called");
|
assert.ok(capturedArgs, "safeSpawn should have been called");
|
||||||
|
|
||||||
|
|
@ -73,24 +81,24 @@ describe("runPipCommand environment variable handling", () => {
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.PIP_CONFIG_FILE,
|
capturedArgs.options.env.PIP_CONFIG_FILE,
|
||||||
undefined,
|
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
|
// But CA environment variables should still be set
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
||||||
"/tmp/test-combined-ca.pem",
|
"/tmp/test-combined-ca.pem",
|
||||||
"REQUESTS_CA_BUNDLE should still be set"
|
"REQUESTS_CA_BUNDLE should still be set",
|
||||||
);
|
);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.SSL_CERT_FILE,
|
capturedArgs.options.env.SSL_CERT_FILE,
|
||||||
"/tmp/test-combined-ca.pem",
|
"/tmp/test-combined-ca.pem",
|
||||||
"SSL_CERT_FILE should still be set"
|
"SSL_CERT_FILE should still be set",
|
||||||
);
|
);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.PIP_CERT,
|
capturedArgs.options.env.PIP_CERT,
|
||||||
"/tmp/test-combined-ca.pem",
|
"/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(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.PIP_CONFIG_FILE,
|
capturedArgs.options.env.PIP_CONFIG_FILE,
|
||||||
undefined,
|
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(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.PIP_CONFIG_FILE,
|
capturedArgs.options.env.PIP_CONFIG_FILE,
|
||||||
undefined,
|
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(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.PIP_CONFIG_FILE,
|
capturedArgs.options.env.PIP_CONFIG_FILE,
|
||||||
undefined,
|
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
|
// CA env vars should still be set
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.SSL_CERT_FILE,
|
capturedArgs.options.env.SSL_CERT_FILE,
|
||||||
"/tmp/test-combined-ca.pem",
|
"/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(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.PIP_CONFIG_FILE,
|
capturedArgs.options.env.PIP_CONFIG_FILE,
|
||||||
undefined,
|
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(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.PIP_CONFIG_FILE,
|
capturedArgs.options.env.PIP_CONFIG_FILE,
|
||||||
undefined,
|
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(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.PIP_CERT,
|
capturedArgs.options.env.PIP_CERT,
|
||||||
"/tmp/test-combined-ca.pem",
|
"/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
|
// Check PIP_CONFIG_FILE env var exists and is a non-empty string
|
||||||
const configPath = capturedArgs.options.env.PIP_CONFIG_FILE;
|
const configPath = capturedArgs.options.env.PIP_CONFIG_FILE;
|
||||||
assert.ok(configPath, "PIP_CONFIG_FILE should be set");
|
assert.ok(configPath, "PIP_CONFIG_FILE should be set");
|
||||||
assert.strictEqual(typeof configPath, "string", "PIP_CONFIG_FILE should be a string");
|
assert.strictEqual(
|
||||||
assert.ok(configPath.length > 0, "PIP_CONFIG_FILE should be a non-empty path");
|
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 () => {
|
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(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
||||||
"/tmp/test-combined-ca.pem",
|
"/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(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.SSL_CERT_FILE,
|
capturedArgs.options.env.SSL_CERT_FILE,
|
||||||
"/tmp/test-combined-ca.pem",
|
"/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
|
// Env vars should be set unconditionally
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
||||||
"/tmp/test-combined-ca.pem"
|
"/tmp/test-combined-ca.pem",
|
||||||
);
|
);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.SSL_CERT_FILE,
|
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)
|
// Environment variables still set (pip CLI --cert takes precedence)
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
||||||
"/tmp/test-combined-ca.pem"
|
"/tmp/test-combined-ca.pem",
|
||||||
);
|
);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.SSL_CERT_FILE,
|
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(
|
assert.strictEqual(
|
||||||
capturedArgs.options.env.HTTPS_PROXY,
|
capturedArgs.options.env.HTTPS_PROXY,
|
||||||
"http://localhost:8080",
|
"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 () => {
|
it("should create a new temp config when existing config exists (original file untouched)", async () => {
|
||||||
const tmpDir = os.tmpdir();
|
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";
|
const initial = "[global]\nindex-url = https://example.com/simple\n";
|
||||||
await fs.writeFile(userCfgPath, initial, "utf-8");
|
await fs.writeFile(userCfgPath, initial, "utf-8");
|
||||||
|
|
||||||
|
|
@ -253,19 +271,42 @@ describe("runPipCommand environment variable handling", () => {
|
||||||
const res = await runPip("pip3", ["install", "requests"]);
|
const res = await runPip("pip3", ["install", "requests"]);
|
||||||
assert.strictEqual(res.status, 0);
|
assert.strictEqual(res.status, 0);
|
||||||
const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE;
|
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
|
// Original file unchanged
|
||||||
const originalContent = await fs.readFile(userCfgPath, "utf-8");
|
const originalContent = await fs.readFile(userCfgPath, "utf-8");
|
||||||
const originalParsed = ini.parse(originalContent);
|
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)
|
// 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);
|
const newParsed = ini.parse(capturedConfigContent);
|
||||||
assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "new config should include cert");
|
assert.strictEqual(
|
||||||
assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "new config should include proxy from env");
|
newParsed.global.cert,
|
||||||
assert.strictEqual(newParsed.global["index-url"], "https://example.com/simple", "index-url should be preserved");
|
"/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;
|
customEnv = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -274,24 +315,30 @@ describe("runPipCommand environment variable handling", () => {
|
||||||
const res = await runPip("pip3", ["install", "requests"]);
|
const res = await runPip("pip3", ["install", "requests"]);
|
||||||
assert.strictEqual(res.status, 0);
|
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);
|
const parsed = ini.parse(capturedConfigContent);
|
||||||
assert.ok(parsed.global, "[global] should exist after creation");
|
assert.ok(parsed.global, "[global] should exist after creation");
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
parsed.global.proxy,
|
parsed.global.proxy,
|
||||||
"http://localhost:8080",
|
"http://localhost:8080",
|
||||||
"proxy should be set from merged env"
|
"proxy should be set from merged env",
|
||||||
);
|
);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
parsed.global.cert,
|
parsed.global.cert,
|
||||||
"/tmp/test-combined-ca.pem",
|
"/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 () => {
|
it("should create new temp config adding cert but preserving existing proxy (original file unchanged)", async () => {
|
||||||
const tmpDir = os.tmpdir();
|
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";
|
const initial = "[global]\nproxy = http://original:9999\n";
|
||||||
await fs.writeFile(userCfgPath, initial, "utf-8");
|
await fs.writeFile(userCfgPath, initial, "utf-8");
|
||||||
|
|
||||||
|
|
@ -299,18 +346,41 @@ describe("runPipCommand environment variable handling", () => {
|
||||||
const res = await runPip("pip3", ["install", "requests"]);
|
const res = await runPip("pip3", ["install", "requests"]);
|
||||||
assert.strictEqual(res.status, 0);
|
assert.strictEqual(res.status, 0);
|
||||||
const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE;
|
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
|
// Original file unchanged
|
||||||
const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8"));
|
const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8"));
|
||||||
assert.strictEqual(originalParsed.global.cert, undefined, "original file should not gain cert");
|
assert.strictEqual(
|
||||||
assert.strictEqual(originalParsed.global.proxy, "http://original:9999", "original proxy remains");
|
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)
|
// 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);
|
const newParsed = ini.parse(capturedConfigContent);
|
||||||
assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config");
|
assert.strictEqual(
|
||||||
assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config");
|
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;
|
customEnv = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -321,7 +391,7 @@ describe("runPipCommand environment variable handling", () => {
|
||||||
"[global]",
|
"[global]",
|
||||||
"cert = /path/to/existing.pem",
|
"cert = /path/to/existing.pem",
|
||||||
"proxy = http://original:9999",
|
"proxy = http://original:9999",
|
||||||
""
|
"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
await fs.writeFile(cfgPath, initialIni, "utf-8");
|
await fs.writeFile(cfgPath, initialIni, "utf-8");
|
||||||
|
|
||||||
|
|
@ -329,25 +399,51 @@ describe("runPipCommand environment variable handling", () => {
|
||||||
const res = await runPip("pip3", ["install", "requests"]);
|
const res = await runPip("pip3", ["install", "requests"]);
|
||||||
assert.strictEqual(res.status, 0, "execution should succeed");
|
assert.strictEqual(res.status, 0, "execution should succeed");
|
||||||
const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE;
|
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
|
// Original file stays untouched
|
||||||
const originalContent = await fs.readFile(cfgPath, "utf-8");
|
const originalContent = await fs.readFile(cfgPath, "utf-8");
|
||||||
const originalParsed = ini.parse(originalContent);
|
const originalParsed = ini.parse(originalContent);
|
||||||
assert.strictEqual(originalParsed.global.cert, "/path/to/existing.pem", "original cert preserved");
|
assert.strictEqual(
|
||||||
assert.strictEqual(originalParsed.global.proxy, "http://original:9999", "original proxy preserved");
|
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)
|
// New temp config: cert and proxy always overwritten (read from captured content)
|
||||||
assert.ok(capturedConfigContent, "config content should have been captured");
|
assert.ok(
|
||||||
const newParsed = ini.parse(capturedConfigContent);
|
capturedConfigContent,
|
||||||
assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config");
|
"config content should have been captured",
|
||||||
assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config");
|
);
|
||||||
|
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;
|
customEnv = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create new temp config preserving existing cert and adding missing proxy", async () => {
|
it("should create new temp config preserving existing cert and adding missing proxy", async () => {
|
||||||
const tmpDir = os.tmpdir();
|
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";
|
const initial = "[global]\ncert = /path/to/existing.pem\n";
|
||||||
await fs.writeFile(userCfgPath, initial, "utf-8");
|
await fs.writeFile(userCfgPath, initial, "utf-8");
|
||||||
|
|
||||||
|
|
@ -355,29 +451,55 @@ describe("runPipCommand environment variable handling", () => {
|
||||||
const res = await runPip("pip3", ["install", "requests"]);
|
const res = await runPip("pip3", ["install", "requests"]);
|
||||||
assert.strictEqual(res.status, 0);
|
assert.strictEqual(res.status, 0);
|
||||||
const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE;
|
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
|
// Original remains unchanged
|
||||||
const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8"));
|
const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8"));
|
||||||
assert.strictEqual(originalParsed.global.cert, "/path/to/existing.pem", "original cert unchanged");
|
assert.strictEqual(
|
||||||
assert.strictEqual(originalParsed.global.proxy, undefined, "original proxy still missing");
|
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)
|
// New file: cert and proxy always overwritten (read from captured content)
|
||||||
assert.ok(capturedConfigContent, "config content should have been captured");
|
assert.ok(
|
||||||
const newParsed = ini.parse(capturedConfigContent);
|
capturedConfigContent,
|
||||||
assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config");
|
"config content should have been captured",
|
||||||
assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config");
|
);
|
||||||
|
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;
|
customEnv = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should log warnings when cert and proxy are already set in user config file", async () => {
|
it("should log warnings when cert and proxy are already set in user config file", async () => {
|
||||||
const tmpDir = os.tmpdir();
|
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 = [
|
const initialIni = [
|
||||||
"[global]",
|
"[global]",
|
||||||
"cert = /user/cert.pem",
|
"cert = /user/cert.pem",
|
||||||
"proxy = http://user-proxy:9999",
|
"proxy = http://user-proxy:9999",
|
||||||
""
|
"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
await fs.writeFile(cfgPath, initialIni, "utf-8");
|
await fs.writeFile(cfgPath, initialIni, "utf-8");
|
||||||
|
|
||||||
|
|
@ -387,16 +509,28 @@ describe("runPipCommand environment variable handling", () => {
|
||||||
let output = "";
|
let output = "";
|
||||||
const originalWrite = process.stdout.write;
|
const originalWrite = process.stdout.write;
|
||||||
const originalError = process.stderr.write;
|
const originalError = process.stderr.write;
|
||||||
process.stdout.write = (chunk, ...args) => { output += chunk; return originalWrite.apply(process.stdout, [chunk, ...args]); };
|
process.stdout.write = (chunk, ...args) => {
|
||||||
process.stderr.write = (chunk, ...args) => { output += chunk; return originalError.apply(process.stderr, [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"]);
|
await runPip("pip3", ["install", "requests"]);
|
||||||
|
|
||||||
process.stdout.write = originalWrite;
|
process.stdout.write = originalWrite;
|
||||||
process.stderr.write = originalError;
|
process.stderr.write = originalError;
|
||||||
|
|
||||||
assert.ok(output.includes("cert found in PIP_CONFIG_FILE"), "Should warn about cert overwrite in output");
|
assert.ok(
|
||||||
assert.ok(output.includes("proxy found in PIP_CONFIG_FILE"), "Should warn about proxy overwrite in output");
|
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;
|
customEnv = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -407,13 +541,18 @@ describe("runPipCommand environment variable handling", () => {
|
||||||
assert.strictEqual(shouldBypassSafeChain("python", ["--version"]), true);
|
assert.strictEqual(shouldBypassSafeChain("python", ["--version"]), true);
|
||||||
assert.strictEqual(shouldBypassSafeChain("python3", ["--version"]), true);
|
assert.strictEqual(shouldBypassSafeChain("python3", ["--version"]), true);
|
||||||
|
|
||||||
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "http.server"]), true);
|
assert.strictEqual(
|
||||||
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "http.server"]), true);
|
shouldBypassSafeChain("python", ["-m", "http.server"]),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
shouldBypassSafeChain("python3", ["-m", "http.server"]),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip"]), false);
|
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip"]), false);
|
||||||
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip"]), false);
|
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip"]), false);
|
||||||
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip3"]), false);
|
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip3"]), false);
|
||||||
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip3"]), false);
|
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip3"]), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ui } from "../../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.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.
|
* Sets CA bundle environment variables used by Python libraries and pipx.
|
||||||
|
|
@ -14,17 +14,23 @@ function getPipXCaBundleEnvironmentVariables(env, combinedCaPath) {
|
||||||
let retVal = { ...env };
|
let retVal = { ...env };
|
||||||
|
|
||||||
if (env.SSL_CERT_FILE) {
|
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;
|
retVal.SSL_CERT_FILE = combinedCaPath;
|
||||||
|
|
||||||
if (env.REQUESTS_CA_BUNDLE) {
|
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;
|
retVal.REQUESTS_CA_BUNDLE = combinedCaPath;
|
||||||
|
|
||||||
if (env.PIP_CERT) {
|
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;
|
retVal.PIP_CERT = combinedCaPath;
|
||||||
return retVal;
|
return retVal;
|
||||||
|
|
@ -42,7 +48,10 @@ export async function runPipX(command, args) {
|
||||||
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
|
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
|
||||||
|
|
||||||
const combinedCaPath = getCombinedCaBundlePath();
|
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
|
// Note: pipx uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration
|
||||||
// These are already set by mergeSafeChainProxyEnvironmentVariables
|
// These are already set by mergeSafeChainProxyEnvironmentVariables
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ describe("runPipXCommand", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
mock.module("../../registryProxy/certBundle.js", {
|
mock.module("../../registryProxy/builtInProxy/certBundle.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem",
|
getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem",
|
||||||
},
|
},
|
||||||
|
|
@ -65,7 +65,11 @@ describe("runPipXCommand", () => {
|
||||||
const res = await runPipX("pipx", ["install", "ruff"]);
|
const res = await runPipX("pipx", ["install", "ruff"]);
|
||||||
|
|
||||||
assert.strictEqual(res.status, 0);
|
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 [, , options] = safeSpawnMock.mock.calls[0].arguments;
|
||||||
const env = options.env;
|
const env = options.env;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ui } from "../../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.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}
|
* @returns {import("../currentPackageManager.js").PackageManager}
|
||||||
|
|
@ -26,19 +26,25 @@ export function createPoetryPackageManager() {
|
||||||
function setPoetryCaBundleEnvironmentVariables(env, combinedCaPath) {
|
function setPoetryCaBundleEnvironmentVariables(env, combinedCaPath) {
|
||||||
// SSL_CERT_FILE: Used by Python SSL libraries and requests
|
// SSL_CERT_FILE: Used by Python SSL libraries and requests
|
||||||
if (env.SSL_CERT_FILE) {
|
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;
|
env.SSL_CERT_FILE = combinedCaPath;
|
||||||
|
|
||||||
// REQUESTS_CA_BUNDLE: Used by the requests library (which Poetry uses)
|
// REQUESTS_CA_BUNDLE: Used by the requests library (which Poetry uses)
|
||||||
if (env.REQUESTS_CA_BUNDLE) {
|
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;
|
env.REQUESTS_CA_BUNDLE = combinedCaPath;
|
||||||
|
|
||||||
// PIP_CERT: Poetry may use pip internally
|
// PIP_CERT: Poetry may use pip internally
|
||||||
if (env.PIP_CERT) {
|
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;
|
env.PIP_CERT = combinedCaPath;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ui } from "../../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.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.
|
* Sets CA bundle environment variables used by Python libraries and uv.
|
||||||
|
|
@ -12,19 +12,25 @@ import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
|
||||||
function setUvCaBundleEnvironmentVariables(env, combinedCaPath) {
|
function setUvCaBundleEnvironmentVariables(env, combinedCaPath) {
|
||||||
// SSL_CERT_FILE: Used by Python SSL libraries and underlying HTTP clients
|
// SSL_CERT_FILE: Used by Python SSL libraries and underlying HTTP clients
|
||||||
if (env.SSL_CERT_FILE) {
|
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;
|
env.SSL_CERT_FILE = combinedCaPath;
|
||||||
|
|
||||||
// REQUESTS_CA_BUNDLE: Used by the requests library (which uv may use internally)
|
// REQUESTS_CA_BUNDLE: Used by the requests library (which uv may use internally)
|
||||||
if (env.REQUESTS_CA_BUNDLE) {
|
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;
|
env.REQUESTS_CA_BUNDLE = combinedCaPath;
|
||||||
|
|
||||||
// PIP_CERT: Some underlying pip operations may respect this
|
// PIP_CERT: Some underlying pip operations may respect this
|
||||||
if (env.PIP_CERT) {
|
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;
|
env.PIP_CERT = combinedCaPath;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import certifi from "certifi";
|
||||||
import tls from "node:tls";
|
import tls from "node:tls";
|
||||||
import { X509Certificate } from "node:crypto";
|
import { X509Certificate } from "node:crypto";
|
||||||
import { getCaCertPath } from "./certUtils.js";
|
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.
|
* Check if a PEM string contains only parsable cert blocks.
|
||||||
|
|
@ -92,14 +92,21 @@ export function getCombinedCaBundlePath() {
|
||||||
const userPem = readUserCertificateFile(userCertPath);
|
const userPem = readUserCertificateFile(userCertPath);
|
||||||
if (userPem) {
|
if (userPem) {
|
||||||
parts.push(userPem.trim());
|
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 {
|
} 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 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" });
|
fs.writeFileSync(target, combined, { encoding: "utf8" });
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
@ -177,5 +184,3 @@ function readUserCertificateFile(certPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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<void>}
|
||||||
|
*/
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import {
|
||||||
ECOSYSTEM_JS,
|
ECOSYSTEM_JS,
|
||||||
ECOSYSTEM_PY,
|
ECOSYSTEM_PY,
|
||||||
getEcoSystem,
|
getEcoSystem,
|
||||||
} from "../../config/settings.js";
|
} from "../../../config/settings.js";
|
||||||
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
|
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
|
||||||
import { pipInterceptorForUrl } from "./pipInterceptor.js";
|
import { pipInterceptorForUrl } from "./pipInterceptor.js";
|
||||||
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { getMinimumPackageAgeHours, getNpmMinimumPackageAgeExclusions } from "../../../config/settings.js";
|
import {
|
||||||
import { ui } from "../../../environment/userInteraction.js";
|
getMinimumPackageAgeHours,
|
||||||
|
getNpmMinimumPackageAgeExclusions,
|
||||||
|
} from "../../../../config/settings.js";
|
||||||
|
import { ui } from "../../../../environment/userInteraction.js";
|
||||||
import { getHeaderValueAsString } from "../../http-utils.js";
|
import { getHeaderValueAsString } from "../../http-utils.js";
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
|
|
@ -68,15 +71,20 @@ export function modifyNpmInfoResponse(body, headers) {
|
||||||
// Check if this package is excluded from minimum age filtering
|
// Check if this package is excluded from minimum age filtering
|
||||||
const packageName = bodyJson.name;
|
const packageName = bodyJson.name;
|
||||||
const exclusions = getNpmMinimumPackageAgeExclusions();
|
const exclusions = getNpmMinimumPackageAgeExclusions();
|
||||||
if (packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern))) {
|
if (
|
||||||
|
packageName &&
|
||||||
|
exclusions.some((pattern) =>
|
||||||
|
matchesExclusionPattern(packageName, pattern),
|
||||||
|
)
|
||||||
|
) {
|
||||||
ui.writeVerbose(
|
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;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cutOff = new Date(
|
const cutOff = new Date(
|
||||||
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000
|
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasLatestTag = !!bodyJson["dist-tags"]["latest"];
|
const hasLatestTag = !!bodyJson["dist-tags"]["latest"];
|
||||||
|
|
@ -113,7 +121,7 @@ export function modifyNpmInfoResponse(body, headers) {
|
||||||
return Buffer.from(JSON.stringify(bodyJson));
|
return Buffer.from(JSON.stringify(bodyJson));
|
||||||
} catch (/** @type {any} */ err) {
|
} catch (/** @type {any} */ err) {
|
||||||
ui.writeVerbose(
|
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;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
@ -129,7 +137,7 @@ function deleteVersionFromJson(json, version) {
|
||||||
const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
|
const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
|
||||||
|
|
||||||
ui.writeVerbose(
|
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];
|
delete json.time[version];
|
||||||
|
|
@ -148,18 +156,20 @@ function deleteVersionFromJson(json, version) {
|
||||||
*/
|
*/
|
||||||
function calculateLatestTag(tagList) {
|
function calculateLatestTag(tagList) {
|
||||||
const entries = Object.entries(tagList).filter(
|
const entries = Object.entries(tagList).filter(
|
||||||
([version, _]) => version !== "created" && version !== "modified"
|
([version, _]) => version !== "created" && version !== "modified",
|
||||||
);
|
);
|
||||||
|
|
||||||
const latestFullRelease = getMostRecentTag(
|
const latestFullRelease = getMostRecentTag(
|
||||||
Object.fromEntries(entries.filter(([version, _]) => !version.includes("-")))
|
Object.fromEntries(
|
||||||
|
entries.filter(([version, _]) => !version.includes("-")),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (latestFullRelease) {
|
if (latestFullRelease) {
|
||||||
return latestFullRelease;
|
return latestFullRelease;
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestPrerelease = getMostRecentTag(
|
const latestPrerelease = getMostRecentTag(
|
||||||
Object.fromEntries(entries.filter(([version, _]) => version.includes("-")))
|
Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))),
|
||||||
);
|
);
|
||||||
return latestPrerelease;
|
return latestPrerelease;
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import {
|
import {
|
||||||
getNpmCustomRegistries,
|
getNpmCustomRegistries,
|
||||||
skipMinimumPackageAge,
|
skipMinimumPackageAge,
|
||||||
} from "../../../config/settings.js";
|
} from "../../../../config/settings.js";
|
||||||
import { isMalwarePackage } from "../../../scanning/audit/index.js";
|
import { isMalwarePackage } from "../../../../scanning/audit/index.js";
|
||||||
import { interceptRequests } from "../interceptorBuilder.js";
|
import { interceptRequests } from "../interceptorBuilder.js";
|
||||||
import {
|
import {
|
||||||
isPackageInfoUrl,
|
isPackageInfoUrl,
|
||||||
|
|
@ -23,7 +23,7 @@ const knownJsRegistries = [
|
||||||
*/
|
*/
|
||||||
export function npmInterceptorForUrl(url) {
|
export function npmInterceptorForUrl(url) {
|
||||||
const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find(
|
const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find(
|
||||||
(reg) => url.includes(reg)
|
(reg) => url.includes(reg),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (registry) {
|
if (registry) {
|
||||||
|
|
@ -41,7 +41,7 @@ function buildNpmInterceptor(registry) {
|
||||||
return interceptRequests(async (reqContext) => {
|
return interceptRequests(async (reqContext) => {
|
||||||
const { packageName, version } = parseNpmPackageUrl(
|
const { packageName, version } = parseNpmPackageUrl(
|
||||||
reqContext.targetUrl,
|
reqContext.targetUrl,
|
||||||
registry
|
registry,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (await isMalwarePackage(packageName, version)) {
|
if (await isMalwarePackage(packageName, version)) {
|
||||||
|
|
@ -6,23 +6,24 @@ describe("npmInterceptor minimum package age", async () => {
|
||||||
let skipMinimumPackageAgeSetting = false;
|
let skipMinimumPackageAgeSetting = false;
|
||||||
let minimumPackageAgeExclusionsSetting = [];
|
let minimumPackageAgeExclusionsSetting = [];
|
||||||
|
|
||||||
mock.module("../../../config/settings.js", {
|
mock.module("../../../../config/settings.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
|
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
|
||||||
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
||||||
getNpmCustomRegistries: () => [],
|
getNpmCustomRegistries: () => [],
|
||||||
getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
|
getNpmMinimumPackageAgeExclusions: () =>
|
||||||
|
minimumPackageAgeExclusionsSetting,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
mock.module("../../../scanning/audit/index.js", {
|
mock.module("../../../../scanning/audit/index.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
isMalwarePackage: async () => {
|
isMalwarePackage: async () => {
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
mock.module("../../../environment/userInteraction.js", {
|
mock.module("../../../../environment/userInteraction.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
ui: {
|
ui: {
|
||||||
startProcess: () => {},
|
startProcess: () => {},
|
||||||
|
|
@ -64,9 +65,8 @@ describe("npmInterceptor minimum package age", async () => {
|
||||||
]) {
|
]) {
|
||||||
it(`modifyResponse should be true for package info requests: ${packageInfoUrl}`, async () => {
|
it(`modifyResponse should be true for package info requests: ${packageInfoUrl}`, async () => {
|
||||||
const interceptor = npmInterceptorForUrl(packageInfoUrl);
|
const interceptor = npmInterceptorForUrl(packageInfoUrl);
|
||||||
const requestInterceptor = await interceptor.handleRequest(
|
const requestInterceptor =
|
||||||
packageInfoUrl
|
await interceptor.handleRequest(packageInfoUrl);
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(requestInterceptor.modifiesResponse(), true);
|
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 () => {
|
it(`modifyResponse should be false for special endpoints: ${specialEndpoint}`, async () => {
|
||||||
const interceptor = npmInterceptorForUrl(specialEndpoint);
|
const interceptor = npmInterceptorForUrl(specialEndpoint);
|
||||||
const requestInterceptor = await interceptor.handleRequest(
|
const requestInterceptor =
|
||||||
specialEndpoint
|
await interceptor.handleRequest(specialEndpoint);
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(requestInterceptor.modifiesResponse(), false);
|
assert.equal(requestInterceptor.modifiesResponse(), false);
|
||||||
});
|
});
|
||||||
|
|
@ -152,7 +151,7 @@ describe("npmInterceptor minimum package age", async () => {
|
||||||
["2.0.0"]: getDate(-4),
|
["2.0.0"]: getDate(-4),
|
||||||
["3.0.0"]: getDate(-3),
|
["3.0.0"]: getDate(-3),
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const modifiedJson = JSON.parse(modifiedBody);
|
const modifiedJson = JSON.parse(modifiedBody);
|
||||||
|
|
@ -193,7 +192,7 @@ describe("npmInterceptor minimum package age", async () => {
|
||||||
["2.0.0"]: getDate(-4),
|
["2.0.0"]: getDate(-4),
|
||||||
["3.0.0"]: getDate(-3),
|
["3.0.0"]: getDate(-3),
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const modifiedJson = JSON.parse(modifiedBody);
|
const modifiedJson = JSON.parse(modifiedBody);
|
||||||
|
|
@ -225,7 +224,7 @@ describe("npmInterceptor minimum package age", async () => {
|
||||||
// cutoff-date here
|
// cutoff-date here
|
||||||
["2.0.0-alpha"]: getDate(-4),
|
["2.0.0-alpha"]: getDate(-4),
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const modifiedJson = JSON.parse(modifiedBody);
|
const modifiedJson = JSON.parse(modifiedBody);
|
||||||
|
|
@ -261,7 +260,7 @@ describe("npmInterceptor minimum package age", async () => {
|
||||||
|
|
||||||
const modifiedBody = await runModifyNpmInfoRequest(
|
const modifiedBody = await runModifyNpmInfoRequest(
|
||||||
packageUrl,
|
packageUrl,
|
||||||
originalBody
|
originalBody,
|
||||||
);
|
);
|
||||||
|
|
||||||
const modifiedJson = JSON.parse(modifiedBody);
|
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
|
["3.0.0"]: getDate(-40), // ~1.7 days old - should be removed
|
||||||
["4.0.0"]: getDate(-24), // 1 day old - should be removed
|
["4.0.0"]: getDate(-24), // 1 day old - should be removed
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const modifiedJson = JSON.parse(modifiedBody);
|
const modifiedJson = JSON.parse(modifiedBody);
|
||||||
|
|
@ -347,7 +346,7 @@ describe("npmInterceptor minimum package age", async () => {
|
||||||
// 1-hour cutoff here
|
// 1-hour cutoff here
|
||||||
["3.0.0"]: getDate(0), // just published - should be removed
|
["3.0.0"]: getDate(0), // just published - should be removed
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const modifiedJson = JSON.parse(modifiedBody);
|
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);
|
const modifiedJson = JSON.parse(modifiedBody);
|
||||||
|
|
||||||
// All versions should remain unchanged since lodash is excluded
|
// All versions should remain unchanged since lodash is excluded
|
||||||
|
|
@ -416,7 +418,7 @@ describe("npmInterceptor minimum package age", async () => {
|
||||||
["1.0.0"]: getDate(-7),
|
["1.0.0"]: getDate(-7),
|
||||||
["3.0.0"]: getDate(-3),
|
["3.0.0"]: getDate(-3),
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const modifiedJson = JSON.parse(modifiedBody);
|
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);
|
const modifiedJson = JSON.parse(modifiedBody);
|
||||||
|
|
||||||
// All versions should remain for excluded scoped package
|
// 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);
|
const modifiedJson = JSON.parse(modifiedBody);
|
||||||
|
|
||||||
// All versions should remain since lodash is in the exclusion list
|
// 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);
|
const modifiedJson = JSON.parse(modifiedBody);
|
||||||
|
|
||||||
// All versions should remain since @aikidosec/* matches @aikidosec/safe-chain
|
// 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);
|
const modifiedJson = JSON.parse(modifiedBody);
|
||||||
|
|
||||||
// Version 2.0.0 should be filtered since @other/package doesn't match @aikidosec/*
|
// 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),
|
["1.0.0"]: getDate(-100),
|
||||||
["2.0.0"]: getDate(-1),
|
["2.0.0"]: getDate(-1),
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const modifiedJson = JSON.parse(modifiedBody);
|
const modifiedJson = JSON.parse(modifiedBody);
|
||||||
|
|
@ -5,7 +5,7 @@ let lastPackage;
|
||||||
let malwareResponse = false;
|
let malwareResponse = false;
|
||||||
let customRegistries = [];
|
let customRegistries = [];
|
||||||
|
|
||||||
mock.module("../../../scanning/audit/index.js", {
|
mock.module("../../../../scanning/audit/index.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
isMalwarePackage: async (packageName, version) => {
|
isMalwarePackage: async (packageName, version) => {
|
||||||
lastPackage = { 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: {
|
namedExports: {
|
||||||
LOGGING_SILENT: "silent",
|
LOGGING_SILENT: "silent",
|
||||||
LOGGING_NORMAL: "normal",
|
LOGGING_NORMAL: "normal",
|
||||||
|
|
@ -136,7 +136,7 @@ describe("npmInterceptor", async () => {
|
||||||
const interceptor = npmInterceptorForUrl(url);
|
const interceptor = npmInterceptorForUrl(url);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
interceptor,
|
interceptor,
|
||||||
"Interceptor should be created for known npm registry"
|
"Interceptor should be created for known npm registry",
|
||||||
);
|
);
|
||||||
|
|
||||||
await interceptor.handleRequest(url);
|
await interceptor.handleRequest(url);
|
||||||
|
|
@ -153,7 +153,7 @@ describe("npmInterceptor", async () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
interceptor,
|
interceptor,
|
||||||
undefined,
|
undefined,
|
||||||
"Interceptor should be undefined for unknown registry"
|
"Interceptor should be undefined for unknown registry",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -170,12 +170,12 @@ describe("npmInterceptor", async () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
result.blockResponse.statusCode,
|
result.blockResponse.statusCode,
|
||||||
403,
|
403,
|
||||||
"Block response should have status code 403"
|
"Block response should have status code 403",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
result.blockResponse.message,
|
result.blockResponse.message,
|
||||||
"Forbidden - blocked by safe-chain",
|
"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(
|
assert.ok(
|
||||||
interceptor,
|
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);
|
await interceptor.handleRequest(url);
|
||||||
|
|
@ -262,7 +262,7 @@ describe("npmInterceptor with custom registries", async () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
interceptor,
|
interceptor,
|
||||||
undefined,
|
undefined,
|
||||||
"Should not create interceptor for unknown registry"
|
"Should not create interceptor for unknown registry",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { getPipCustomRegistries } from "../../config/settings.js";
|
import { getPipCustomRegistries } from "../../../config/settings.js";
|
||||||
import { isMalwarePackage } from "../../scanning/audit/index.js";
|
import { isMalwarePackage } from "../../../scanning/audit/index.js";
|
||||||
import { interceptRequests } from "./interceptorBuilder.js";
|
import { interceptRequests } from "./interceptorBuilder.js";
|
||||||
|
|
||||||
const knownPipRegistries = [
|
const knownPipRegistries = [
|
||||||
|
|
@ -33,16 +33,18 @@ function buildPipInterceptor(registry) {
|
||||||
return interceptRequests(async (reqContext) => {
|
return interceptRequests(async (reqContext) => {
|
||||||
const { packageName, version } = parsePipPackageFromUrl(
|
const { packageName, version } = parsePipPackageFromUrl(
|
||||||
reqContext.targetUrl,
|
reqContext.targetUrl,
|
||||||
registry
|
registry,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Normalize underscores to hyphens for DB matching, as PyPI allows underscores in distribution names.
|
// 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.
|
// 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 =
|
const isMalicious =
|
||||||
await isMalwarePackage(packageName, version)
|
(await isMalwarePackage(packageName, version)) ||
|
||||||
|| await isMalwarePackage(hyphenName, version);
|
(await isMalwarePackage(hyphenName, version));
|
||||||
|
|
||||||
if (isMalicious) {
|
if (isMalicious) {
|
||||||
reqContext.blockMalware(packageName, version);
|
reqContext.blockMalware(packageName, version);
|
||||||
|
|
@ -110,7 +112,8 @@ function parsePipPackageFromUrl(url, registry) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source dist (sdist) and potential metadata sidecars (e.g., .tar.gz.metadata)
|
// 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);
|
const sdistExtMatch = filename.match(sdistExtWithMetadataRe);
|
||||||
if (sdistExtMatch) {
|
if (sdistExtMatch) {
|
||||||
const base = filename.replace(sdistExtWithMetadataRe, "");
|
const base = filename.replace(sdistExtWithMetadataRe, "");
|
||||||
|
|
@ -6,13 +6,13 @@ describe("pipInterceptor custom registries", async () => {
|
||||||
let malwareResponse = false;
|
let malwareResponse = false;
|
||||||
let customRegistries = [];
|
let customRegistries = [];
|
||||||
|
|
||||||
mock.module("../../config/settings.js", {
|
mock.module("../../../config/settings.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
getPipCustomRegistries: () => customRegistries,
|
getPipCustomRegistries: () => customRegistries,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
mock.module("../../scanning/audit/index.js", {
|
mock.module("../../../scanning/audit/index.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
isMalwarePackage: async (packageName, version) => {
|
isMalwarePackage: async (packageName, version) => {
|
||||||
lastPackage = { packageName, version };
|
lastPackage = { packageName, version };
|
||||||
|
|
@ -30,10 +30,7 @@ describe("pipInterceptor custom registries", async () => {
|
||||||
|
|
||||||
const interceptor = pipInterceptorForUrl(url);
|
const interceptor = pipInterceptorForUrl(url);
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(interceptor, "Interceptor should be created for custom registry");
|
||||||
interceptor,
|
|
||||||
"Interceptor should be created for custom registry"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse package from custom registry URL", async () => {
|
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 () => {
|
it("should handle multiple custom registries", async () => {
|
||||||
customRegistries = [
|
customRegistries = ["registry-one.example.com", "registry-two.example.com"];
|
||||||
"registry-one.example.com",
|
|
||||||
"registry-two.example.com",
|
|
||||||
];
|
|
||||||
|
|
||||||
const url1 =
|
const url1 =
|
||||||
"https://registry-one.example.com/packages/package1-1.0.0.tar.gz";
|
"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(interceptor1, "Interceptor should be created for first registry");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
interceptor2,
|
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(
|
assert.equal(
|
||||||
result.blockResponse.statusCode,
|
result.blockResponse.statusCode,
|
||||||
403,
|
403,
|
||||||
"Block response should have status code 403"
|
"Block response should have status code 403",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
result.blockResponse.message,
|
result.blockResponse.message,
|
||||||
"Forbidden - blocked by safe-chain",
|
"Forbidden - blocked by safe-chain",
|
||||||
"Block response should have correct status message"
|
"Block response should have correct status message",
|
||||||
);
|
);
|
||||||
|
|
||||||
malwareResponse = false;
|
malwareResponse = false;
|
||||||
|
|
@ -126,7 +120,7 @@ describe("pipInterceptor custom registries", async () => {
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
interceptor,
|
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);
|
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", () => {
|
it("should not create interceptor for unknown registry when custom registries are set", () => {
|
||||||
customRegistries = ["my-custom-registry.example.com"];
|
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);
|
const interceptor = pipInterceptorForUrl(url);
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
interceptor,
|
interceptor,
|
||||||
undefined,
|
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(
|
assert.equal(
|
||||||
interceptor,
|
interceptor,
|
||||||
undefined,
|
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 () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -5,7 +5,7 @@ describe("pipInterceptor", async () => {
|
||||||
let lastPackage;
|
let lastPackage;
|
||||||
let malwareResponse = false;
|
let malwareResponse = false;
|
||||||
|
|
||||||
mock.module("../../scanning/audit/index.js", {
|
mock.module("../../../scanning/audit/index.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
isMalwarePackage: async (packageName, version) => {
|
isMalwarePackage: async (packageName, version) => {
|
||||||
lastPackage = { packageName, version };
|
lastPackage = { packageName, version };
|
||||||
|
|
@ -100,7 +100,7 @@ describe("pipInterceptor", async () => {
|
||||||
const interceptor = pipInterceptorForUrl(url);
|
const interceptor = pipInterceptorForUrl(url);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
interceptor,
|
interceptor,
|
||||||
"Interceptor should be created for known npm registry"
|
"Interceptor should be created for known npm registry",
|
||||||
);
|
);
|
||||||
|
|
||||||
await interceptor.handleRequest(url);
|
await interceptor.handleRequest(url);
|
||||||
|
|
@ -117,7 +117,7 @@ describe("pipInterceptor", async () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
interceptor,
|
interceptor,
|
||||||
undefined,
|
undefined,
|
||||||
"Interceptor should be undefined for unknown registry"
|
"Interceptor should be undefined for unknown registry",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -134,12 +134,12 @@ describe("pipInterceptor", async () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
result.blockResponse.statusCode,
|
result.blockResponse.statusCode,
|
||||||
403,
|
403,
|
||||||
"Block response should have status code 403"
|
"Block response should have status code 403",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
result.blockResponse.message,
|
result.blockResponse.message,
|
||||||
"Forbidden - blocked by safe-chain",
|
"Forbidden - blocked by safe-chain",
|
||||||
"Block response should have correct status message"
|
"Block response should have correct status message",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import https from "https";
|
import https from "https";
|
||||||
import { generateCertForHost } from "./certUtils.js";
|
import { generateCertForHost } from "./certUtils.js";
|
||||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
import { gunzipSync, gzipSync } from "zlib";
|
import { gunzipSync, gzipSync } from "zlib";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -19,7 +19,7 @@ export function mitmConnect(req, clientSocket, interceptor) {
|
||||||
|
|
||||||
clientSocket.on("error", (err) => {
|
clientSocket.on("error", (err) => {
|
||||||
ui.writeVerbose(
|
ui.writeVerbose(
|
||||||
`Safe-chain: Client socket error for ${req.url}: ${err.message}`
|
`Safe-chain: Client socket error for ${req.url}: ${err.message}`,
|
||||||
);
|
);
|
||||||
// NO-OP
|
// NO-OP
|
||||||
// This can happen if the client TCP socket sends RST instead of FIN.
|
// 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,
|
key: cert.privateKey,
|
||||||
cert: cert.certificate,
|
cert: cert.certificate,
|
||||||
},
|
},
|
||||||
handleRequest
|
handleRequest,
|
||||||
);
|
);
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
|
|
@ -119,7 +119,7 @@ function forwardRequest(req, hostname, port, res, requestHandler) {
|
||||||
|
|
||||||
proxyReq.on("error", (err) => {
|
proxyReq.on("error", (err) => {
|
||||||
ui.writeVerbose(
|
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.writeHead(502);
|
||||||
res.end("Bad Gateway");
|
res.end("Bad Gateway");
|
||||||
|
|
@ -127,7 +127,7 @@ function forwardRequest(req, hostname, port, res, requestHandler) {
|
||||||
|
|
||||||
req.on("error", (err) => {
|
req.on("error", (err) => {
|
||||||
ui.writeError(
|
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();
|
proxyReq.destroy();
|
||||||
});
|
});
|
||||||
|
|
@ -138,7 +138,7 @@ function forwardRequest(req, hostname, port, res, requestHandler) {
|
||||||
|
|
||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
ui.writeVerbose(
|
ui.writeVerbose(
|
||||||
`Safe-chain: Finished proxying request to ${req.url} for ${hostname}`
|
`Safe-chain: Finished proxying request to ${req.url} for ${hostname}`,
|
||||||
);
|
);
|
||||||
proxyReq.end();
|
proxyReq.end();
|
||||||
});
|
});
|
||||||
|
|
@ -180,7 +180,7 @@ function createProxyRequest(hostname, port, req, res, requestHandler) {
|
||||||
const proxyReq = https.request(options, (proxyRes) => {
|
const proxyReq = https.request(options, (proxyRes) => {
|
||||||
proxyRes.on("error", (err) => {
|
proxyRes.on("error", (err) => {
|
||||||
ui.writeError(
|
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) {
|
if (!res.headersSent) {
|
||||||
res.writeHead(502);
|
res.writeHead(502);
|
||||||
|
|
@ -190,7 +190,7 @@ function createProxyRequest(hostname, port, req, res, requestHandler) {
|
||||||
|
|
||||||
if (!proxyRes.statusCode) {
|
if (!proxyRes.statusCode) {
|
||||||
ui.writeError(
|
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.writeHead(500);
|
||||||
res.end("Internal Server Error");
|
res.end("Internal Server Error");
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as http from "http";
|
import * as http from "http";
|
||||||
import * as https from "https";
|
import * as https from "https";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("http").IncomingMessage} req
|
* @param {import("http").IncomingMessage} req
|
||||||
|
|
@ -61,7 +61,7 @@ export function handleHttpProxyRequest(req, res) {
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
.on("error", (err) => {
|
.on("error", (err) => {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import * as net from "net";
|
import * as net from "net";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
import { isImdsEndpoint } from "./isImdsEndpoint.js";
|
import { isImdsEndpoint } from "./isImdsEndpoint.js";
|
||||||
import { getConnectTimeout } from "./getConnectTimeout.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");
|
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
if (isImds) {
|
if (isImds) {
|
||||||
ui.writeVerbose(
|
ui.writeVerbose(
|
||||||
`Safe-chain: Closing connection because previously timedout connect to ${hostname}`
|
`Safe-chain: Closing connection because previously timedout connect to ${hostname}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ui.writeError(
|
ui.writeError(
|
||||||
`Safe-chain: Closing connection because previously timedout connect to ${hostname}`
|
`Safe-chain: Closing connection because previously timedout connect to ${hostname}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -67,11 +67,11 @@ function tunnelRequestToDestination(req, clientSocket, head) {
|
||||||
if (isImds) {
|
if (isImds) {
|
||||||
timedoutImdsEndpoints.push(hostname);
|
timedoutImdsEndpoints.push(hostname);
|
||||||
ui.writeVerbose(
|
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 {
|
} else {
|
||||||
ui.writeError(
|
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();
|
serverSocket.destroy();
|
||||||
|
|
@ -111,11 +111,11 @@ function tunnelRequestToDestination(req, clientSocket, head) {
|
||||||
clearTimeout(connectTimer);
|
clearTimeout(connectTimer);
|
||||||
if (isImds) {
|
if (isImds) {
|
||||||
ui.writeVerbose(
|
ui.writeVerbose(
|
||||||
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`
|
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ui.writeError(
|
ui.writeError(
|
||||||
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`
|
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (clientSocket.writable) {
|
if (clientSocket.writable) {
|
||||||
|
|
@ -173,7 +173,7 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
|
||||||
clientSocket.pipe(proxySocket);
|
clientSocket.pipe(proxySocket);
|
||||||
} else {
|
} else {
|
||||||
ui.writeError(
|
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) {
|
if (clientSocket.writable) {
|
||||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
|
|
@ -189,14 +189,14 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
|
||||||
ui.writeError(
|
ui.writeError(
|
||||||
`Safe-chain: error connecting to proxy ${proxy.hostname}:${
|
`Safe-chain: error connecting to proxy ${proxy.hostname}:${
|
||||||
proxy.port || 8080
|
proxy.port || 8080
|
||||||
} - ${err.message}`
|
} - ${err.message}`,
|
||||||
);
|
);
|
||||||
if (clientSocket.writable) {
|
if (clientSocket.writable) {
|
||||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ui.writeError(
|
ui.writeError(
|
||||||
`Safe-chain: proxy socket error after connection - ${err.message}`
|
`Safe-chain: proxy socket error after connection - ${err.message}`,
|
||||||
);
|
);
|
||||||
if (clientSocket.writable) {
|
if (clientSocket.writable) {
|
||||||
clientSocket.end();
|
clientSocket.end();
|
||||||
|
|
@ -210,4 +210,3 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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<void>}
|
||||||
|
*/
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { ChildProcess, spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { mkdtempSync, readFile, writeFile } from "node:fs";
|
import { mkdtempSync, readFile, writeFile } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
import { getLoggingLevel, LOGGING_VERBOSE } from "../config/settings.js";
|
import { getLoggingLevel, LOGGING_VERBOSE } from "../../config/settings.js";
|
||||||
|
|
||||||
const readFilePromise = promisify(readFile);
|
const readFilePromise = promisify(readFile);
|
||||||
const writeFilePromise = promisify(writeFile);
|
const writeFilePromise = promisify(writeFile);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} RamaProxyInstance
|
* @typedef {Object} RamaProxyInstance
|
||||||
* @property {ChildProcess} process
|
* @property {import("node:child_process").ChildProcess} process
|
||||||
* @property {string} proxyAddress
|
* @property {string} proxyAddress
|
||||||
* @property {string} metaAddress
|
* @property {string} metaAddress
|
||||||
* @property {string} certPath
|
* @property {string} certPath
|
||||||
|
|
@ -35,7 +35,7 @@ export function getRamaPath() {
|
||||||
/**
|
/**
|
||||||
* @param {string} ramaPath
|
* @param {string} ramaPath
|
||||||
*
|
*
|
||||||
* @returns {import("./registryProxy.js").SafeChainProxy} */
|
* @returns {import("../registryProxy.js").SafeChainProxy} */
|
||||||
export function createRamaProxy(ramaPath) {
|
export function createRamaProxy(ramaPath) {
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), "safe-chain-proxy-"));
|
const tempDir = mkdtempSync(join(tmpdir(), "safe-chain-proxy-"));
|
||||||
/** @type {RamaProxyInstance | null} */
|
/** @type {RamaProxyInstance | null} */
|
||||||
|
|
@ -45,7 +45,7 @@ export function createRamaProxy(ramaPath) {
|
||||||
startServer: async () => {
|
startServer: async () => {
|
||||||
ramaInstance = await startRama(ramaPath, tempDir);
|
ramaInstance = await startRama(ramaPath, tempDir);
|
||||||
ui.writeVerbose(
|
ui.writeVerbose(
|
||||||
`Proxy started at address "${ramaInstance.proxyAddress}"`
|
`Proxy started at address "${ramaInstance.proxyAddress}"`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
stopServer: async () => {
|
stopServer: async () => {
|
||||||
|
|
@ -98,7 +98,7 @@ async function startRama(ramaPath, dataFolder) {
|
||||||
const proxyAddress = await readFilePromise(proxyAddrPath, "utf-8");
|
const proxyAddress = await readFilePromise(proxyAddrPath, "utf-8");
|
||||||
const metaAddress = await readFilePromise(
|
const metaAddress = await readFilePromise(
|
||||||
join(dataFolder, "meta.addr.txt"),
|
join(dataFolder, "meta.addr.txt"),
|
||||||
"utf-8"
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
const certResponse = await fetch(`http://${metaAddress}/ca`);
|
const certResponse = await fetch(`http://${metaAddress}/ca`);
|
||||||
|
|
@ -14,14 +14,14 @@ const mockIsImdsEndpoint = (host) => {
|
||||||
].includes(host);
|
].includes(host);
|
||||||
};
|
};
|
||||||
|
|
||||||
mock.module("./isImdsEndpoint.js", {
|
mock.module("./builtInProxy/isImdsEndpoint.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
isImdsEndpoint: mockIsImdsEndpoint,
|
isImdsEndpoint: mockIsImdsEndpoint,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock getConnectTimeout to speed up tests
|
// Mock getConnectTimeout to speed up tests
|
||||||
mock.module("./getConnectTimeout.js", {
|
mock.module("./builtInProxy/getConnectTimeout.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
getConnectTimeout: (host) => {
|
getConnectTimeout: (host) => {
|
||||||
// IMDS endpoints: 100ms (real: 3s)
|
// IMDS endpoints: 100ms (real: 3s)
|
||||||
|
|
@ -56,7 +56,7 @@ describe("registryProxy.connectTunnel", () => {
|
||||||
const tunnelResponse = await establishHttpsTunnel(
|
const tunnelResponse = await establishHttpsTunnel(
|
||||||
socket,
|
socket,
|
||||||
"postman-echo.com",
|
"postman-echo.com",
|
||||||
443
|
443,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(tunnelResponse.includes("HTTP/1.1 200 Connection Established"));
|
assert.ok(tunnelResponse.includes("HTTP/1.1 200 Connection Established"));
|
||||||
|
|
@ -69,7 +69,7 @@ describe("registryProxy.connectTunnel", () => {
|
||||||
const httpsResponse = await sendHttpsRequestThroughTunnel(
|
const httpsResponse = await sendHttpsRequestThroughTunnel(
|
||||||
socket,
|
socket,
|
||||||
"GET",
|
"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"));
|
assert.ok(httpsResponse.includes("HTTP/1.1 200 OK"));
|
||||||
|
|
@ -85,25 +85,25 @@ describe("registryProxy.connectTunnel", () => {
|
||||||
// without interception by the safe-chain MITM proxy.
|
// without interception by the safe-chain MITM proxy.
|
||||||
const certInfo = await getTlsCertificateInfo(
|
const certInfo = await getTlsCertificateInfo(
|
||||||
socket,
|
socket,
|
||||||
new URL("https://postman-echo.com")
|
new URL("https://postman-echo.com"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify the certificate is NOT issued by our safe-chain CA
|
// Verify the certificate is NOT issued by our safe-chain CA
|
||||||
// Our self-signed CA would have issuer: "Safe-Chain Proxy CA"
|
// Our self-signed CA would have issuer: "Safe-Chain Proxy CA"
|
||||||
assert.ok(
|
assert.ok(
|
||||||
certInfo.issuer !== undefined,
|
certInfo.issuer !== undefined,
|
||||||
"Certificate should have an issuer"
|
"Certificate should have an issuer",
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!certInfo.issuer.includes("Safe-Chain"),
|
!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
|
// Verify it's a real certificate with proper hostname
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
certInfo.subject.includes("postman-echo.com"),
|
certInfo.subject.includes("postman-echo.com"),
|
||||||
true,
|
true,
|
||||||
`Certificate subject should include postman-echo.com, got: ${certInfo.subject}`
|
`Certificate subject should include postman-echo.com, got: ${certInfo.subject}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
|
|
@ -185,13 +185,13 @@ describe("registryProxy.connectTunnel", () => {
|
||||||
// Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts)
|
// Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
responseData.includes("HTTP/1.1 504 Gateway Timeout"),
|
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)
|
// Should timeout around 100ms for IMDS endpoints (allow some margin)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
duration >= 80 && duration < 200,
|
duration >= 80 && duration < 200,
|
||||||
`IMDS timeout should be ~80-200ms, got ${duration}ms`
|
`IMDS timeout should be ~80-200ms, got ${duration}ms`,
|
||||||
);
|
);
|
||||||
|
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
|
|
@ -232,13 +232,13 @@ describe("registryProxy.connectTunnel", () => {
|
||||||
// Should return 502 immediately (cached timeout)
|
// Should return 502 immediately (cached timeout)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
responseData.includes("HTTP/1.1 502 Bad Gateway"),
|
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
|
// Should be nearly instant (< 50ms) since it's cached
|
||||||
assert.ok(
|
assert.ok(
|
||||||
duration < 50,
|
duration < 50,
|
||||||
`Cached IMDS timeout should be instant, got ${duration}ms`
|
`Cached IMDS timeout should be instant, got ${duration}ms`,
|
||||||
);
|
);
|
||||||
|
|
||||||
socket2.destroy();
|
socket2.destroy();
|
||||||
|
|
@ -283,14 +283,14 @@ describe("registryProxy.connectTunnel", () => {
|
||||||
// Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts)
|
// Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
responseData.includes("HTTP/1.1 504 Gateway Timeout"),
|
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)
|
// Should NOT be instant - it should retry the connection (taking ~500ms due to mock timeout)
|
||||||
// If it was cached, it would return in < 50ms
|
// If it was cached, it would return in < 50ms
|
||||||
assert.ok(
|
assert.ok(
|
||||||
duration >= 400,
|
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();
|
socket2.destroy();
|
||||||
|
|
@ -343,7 +343,7 @@ function sendHttpsRequestThroughTunnel(
|
||||||
socket,
|
socket,
|
||||||
verb,
|
verb,
|
||||||
url,
|
url,
|
||||||
rejectUnauthorized = false
|
rejectUnauthorized = false,
|
||||||
) {
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tlsSocket = tls.connect(
|
const tlsSocket = tls.connect(
|
||||||
|
|
@ -356,9 +356,9 @@ function sendHttpsRequestThroughTunnel(
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
tlsSocket.write(
|
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 = "";
|
let tlsData = "";
|
||||||
|
|
@ -404,7 +404,7 @@ function getTlsCertificateInfo(socket, url) {
|
||||||
|
|
||||||
tlsSocket.end();
|
tlsSocket.end();
|
||||||
resolve({ issuer, subject });
|
resolve({ issuer, subject });
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
tlsSocket.on("error", (err) => {
|
tlsSocket.on("error", (err) => {
|
||||||
|
|
|
||||||
|
|
@ -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 { ui } from "../environment/userInteraction.js";
|
||||||
import chalk from "chalk";
|
import { createRamaProxy, getRamaPath } from "./ramaProxy/createRamaProxy.js";
|
||||||
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
|
import { createBuiltInProxyServer } from "./builtInProxy/createBuiltInProxyServer.js";
|
||||||
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
|
|
||||||
import { createRamaProxy, getRamaPath } from "./createRamaProxy.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} SafeChainProxy
|
* @typedef {Object} SafeChainProxy
|
||||||
|
|
@ -77,143 +70,3 @@ export function mergeSafeChainProxyEnvironmentVariables(env) {
|
||||||
|
|
||||||
return proxyEnv;
|
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<void>}
|
|
||||||
*/
|
|
||||||
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<void>}
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
createSafeChainProxy,
|
createSafeChainProxy,
|
||||||
mergeSafeChainProxyEnvironmentVariables,
|
mergeSafeChainProxyEnvironmentVariables,
|
||||||
} from "./registryProxy.js";
|
} from "./registryProxy.js";
|
||||||
import { getCaCertPath } from "./certUtils.js";
|
import { getCaCertPath } from "./builtInProxy/certUtils.js";
|
||||||
import {
|
import {
|
||||||
setEcoSystem,
|
setEcoSystem,
|
||||||
ECOSYSTEM_JS,
|
ECOSYSTEM_JS,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue