Merge pull request #286 from AikidoSec/new-proxy-beta

Use ramaproxy if it's available.
This commit is contained in:
Sander Declerck 2026-03-09 09:59:57 +01:00 committed by GitHub
commit cf63134761
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 483 additions and 465 deletions

View file

@ -237,6 +237,9 @@ parse_arguments() {
--include-python)
warn "--include-python is deprecated and ignored. Python ecosystem is now included by default."
;;
--experimental-rama-proxy)
USE_RAMA_PROXY=true
;;
*)
error "Unknown argument: $arg"
;;
@ -248,6 +251,7 @@ parse_arguments() {
main() {
# Initialize argument flags
USE_CI_SETUP=false
USE_RAMA_PROXY=false
# Parse command-line arguments
parse_arguments "$@"
@ -328,6 +332,33 @@ main() {
info "Binary installed to: $FINAL_FILE"
# Download safechain-proxy
if [ "$USE_RAMA_PROXY" = "true" ] && { [ "$OS" = "macos" ] || [ "$OS" = "linux" ] || [ "$OS" = "linuxstatic" ]; }; then
info "Downloading safechain-proxy..."
if [ "$OS" = "macos" ]; then
if [ "$ARCH" = "arm64" ]; then
PROXY_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.0.0/safechain-proxy-darwin-arm64"
else
PROXY_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.0.0/safechain-proxy-darwin-amd64"
fi
else
# Linux (both linux and linuxstatic)
if [ "$ARCH" = "x64" ]; then
PROXY_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.0.0/safechain-proxy-linux-amd64"
else
PROXY_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.0.0/safechain-proxy-linux-arm64"
fi
fi
if [ -n "$PROXY_URL" ]; then
PROXY_FILE="${INSTALL_DIR}/safechain-proxy"
download "$PROXY_URL" "$PROXY_FILE"
chmod +x "$PROXY_FILE" || error "Failed to make proxy executable"
info "Proxy installed to: $PROXY_FILE"
fi
fi
# Build setup command based on arguments
SETUP_CMD="setup"
SETUP_ARGS=""

View file

@ -1,7 +1,9 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
import {
getProxySettings,
mergeSafeChainProxyEnvironmentVariables,
} from "../../registryProxy/registryProxy.js";
import { PIP_COMMAND, PIP3_COMMAND, PYTHON_COMMAND, PYTHON3_COMMAND } from "./pipSettings.js";
import fs from "node:fs/promises";
import fsSync from "node:fs";
@ -99,7 +101,7 @@ export async function runPip(command, args) {
// Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots + user certs)
// so that any network request made by pip, including those outside explicit CLI args,
// validates correctly under both MITM'd and tunneled HTTPS.
const combinedCaPath = getCombinedCaBundlePath();
const combinedCaPath = getProxySettings().caCertBundlePath;
// Commands that need access to persistent config/cache/state files
// These should not have PIP_CONFIG_FILE overridden as it would prevent them from

View file

@ -45,6 +45,12 @@ describe("runPipCommand environment variable handling", () => {
HTTPS_PROXY: "http://localhost:8080",
HTTP_PROXY: "",
}),
getProxySettings: () => {
return {
proxyUrl: "http://localhost:8080",
caCertBundlePath: "/tmp/test-combined-ca.pem",
};
},
},
});

View file

@ -1,7 +1,9 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
import {
getProxySettings,
mergeSafeChainProxyEnvironmentVariables,
} from "../../registryProxy/registryProxy.js";
/**
* Sets CA bundle environment variables used by Python libraries and pipx.
@ -41,7 +43,7 @@ export async function runPipX(command, args) {
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
const combinedCaPath = getCombinedCaBundlePath();
const combinedCaPath = getProxySettings().caCertBundlePath;
const modifiedEnv = getPipXCaBundleEnvironmentVariables(env, combinedCaPath);
// Note: pipx uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration

View file

@ -38,6 +38,12 @@ describe("runPipXCommand", () => {
mergeCalls.push(env);
return { ...env, ...mergedEnvReturn };
},
getProxySettings: () => {
return {
proxyUrl: "",
caCertBundlePath: "/tmp/test-combined-ca.pem",
};
},
},
});

View file

@ -1,7 +1,9 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
import {
getProxySettings,
mergeSafeChainProxyEnvironmentVariables,
} from "../../registryProxy/registryProxy.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
@ -56,7 +58,7 @@ async function runPoetryCommand(args) {
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
const combinedCaPath = getCombinedCaBundlePath();
const combinedCaPath = getProxySettings().caCertBundlePath;
setPoetryCaBundleEnvironmentVariables(env, combinedCaPath);
const result = await safeSpawn("poetry", args, {

View file

@ -1,7 +1,9 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
import {
getProxySettings,
mergeSafeChainProxyEnvironmentVariables,
} from "../../registryProxy/registryProxy.js";
/**
* Sets CA bundle environment variables used by Python libraries and uv.
@ -47,7 +49,7 @@ export async function runUv(command, args) {
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
const combinedCaPath = getCombinedCaBundlePath();
const combinedCaPath = getProxySettings().caCertBundlePath;
setUvCaBundleEnvironmentVariables(env, combinedCaPath);
// Note: uv uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration

View file

@ -0,0 +1,160 @@
import * as http from "http";
import { tunnelRequest } from "./tunnelRequestHandler.js";
import { mitmConnect } from "./mitmRequestHandler.js";
import { handleHttpProxyRequest } from "./plainHttpProxy.js";
import { ui } from "../../environment/userInteraction.js";
import chalk from "chalk";
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
import { getCaCertPath } from "./certUtils.js";
import { readFileSync } from "fs";
/** *
* @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,
getCaCert,
};
/**
* @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;
}
function getCaCert() {
try {
const safeChainPath = getCaCertPath();
return readFileSync(safeChainPath, "utf8");
} catch {
return null;
}
}
}

View file

@ -2,7 +2,7 @@ import {
ECOSYSTEM_JS,
ECOSYSTEM_PY,
getEcoSystem,
} from "../../config/settings.js";
} from "../../../config/settings.js";
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
import { pipInterceptorForUrl } from "./pipInterceptor.js";

View file

@ -1,5 +1,8 @@
import { getMinimumPackageAgeHours, getNpmMinimumPackageAgeExclusions } from "../../../config/settings.js";
import { ui } from "../../../environment/userInteraction.js";
import {
getMinimumPackageAgeHours,
getNpmMinimumPackageAgeExclusions,
} from "../../../../config/settings.js";
import { ui } from "../../../../environment/userInteraction.js";
import { getHeaderValueAsString } from "../../http-utils.js";
const state = {

View file

@ -1,8 +1,8 @@
import {
getNpmCustomRegistries,
skipMinimumPackageAge,
} from "../../../config/settings.js";
import { isMalwarePackage } from "../../../scanning/audit/index.js";
} from "../../../../config/settings.js";
import { isMalwarePackage } from "../../../../scanning/audit/index.js";
import { interceptRequests } from "../interceptorBuilder.js";
import {
isPackageInfoUrl,

View file

@ -6,7 +6,7 @@ describe("npmInterceptor minimum package age", async () => {
let skipMinimumPackageAgeSetting = false;
let minimumPackageAgeExclusionsSetting = [];
mock.module("../../../config/settings.js", {
mock.module("../../../../config/settings.js", {
namedExports: {
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
@ -15,14 +15,14 @@ describe("npmInterceptor minimum package age", async () => {
},
});
mock.module("../../../scanning/audit/index.js", {
mock.module("../../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async () => {
return false;
},
},
});
mock.module("../../../environment/userInteraction.js", {
mock.module("../../../../environment/userInteraction.js", {
namedExports: {
ui: {
startProcess: () => {},

View file

@ -5,7 +5,7 @@ let lastPackage;
let malwareResponse = false;
let customRegistries = [];
mock.module("../../../scanning/audit/index.js", {
mock.module("../../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };
@ -14,7 +14,7 @@ mock.module("../../../scanning/audit/index.js", {
},
});
mock.module("../../../config/settings.js", {
mock.module("../../../../config/settings.js", {
namedExports: {
LOGGING_SILENT: "silent",
LOGGING_NORMAL: "normal",

View file

@ -1,5 +1,5 @@
import { getPipCustomRegistries } from "../../config/settings.js";
import { isMalwarePackage } from "../../scanning/audit/index.js";
import { getPipCustomRegistries } from "../../../config/settings.js";
import { isMalwarePackage } from "../../../scanning/audit/index.js";
import { interceptRequests } from "./interceptorBuilder.js";
const knownPipRegistries = [

View file

@ -6,13 +6,13 @@ describe("pipInterceptor custom registries", async () => {
let malwareResponse = false;
let customRegistries = [];
mock.module("../../config/settings.js", {
mock.module("../../../config/settings.js", {
namedExports: {
getPipCustomRegistries: () => customRegistries,
},
});
mock.module("../../scanning/audit/index.js", {
mock.module("../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };
@ -196,4 +196,3 @@ describe("pipInterceptor custom registries", async () => {
});
});
});

View file

@ -5,7 +5,7 @@ describe("pipInterceptor", async () => {
let lastPackage;
let malwareResponse = false;
mock.module("../../scanning/audit/index.js", {
mock.module("../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };

View file

@ -1,7 +1,7 @@
import https from "https";
import { generateCertForHost } from "./certUtils.js";
import { HttpsProxyAgent } from "https-proxy-agent";
import { ui } from "../environment/userInteraction.js";
import { ui } from "../../environment/userInteraction.js";
import { gunzipSync, gzipSync } from "zlib";
/**

View file

@ -1,6 +1,6 @@
import * as http from "http";
import * as https from "https";
import { ui } from "../environment/userInteraction.js";
import { ui } from "../../environment/userInteraction.js";
/**
* @param {import("http").IncomingMessage} req

View file

@ -1,5 +1,5 @@
import * as net from "net";
import { ui } from "../environment/userInteraction.js";
import { ui } from "../../environment/userInteraction.js";
import { isImdsEndpoint } from "./isImdsEndpoint.js";
import { getConnectTimeout } from "./getConnectTimeout.js";
@ -210,4 +210,3 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
}
});
}

View file

@ -5,7 +5,6 @@ import path from "node:path";
import certifi from "certifi";
import tls from "node:tls";
import { X509Certificate } from "node:crypto";
import { getCaCertPath } from "./certUtils.js";
import { ui } from "../environment/userInteraction.js";
/**
@ -50,20 +49,14 @@ function isParsable(pem) {
* - Mozilla roots via certifi (for public HTTPS)
* - Node's built-in root certificates (fallback)
* - User's custom certificates (if NODE_EXTRA_CA_CERTS environment variable is set)
*
* @param {string | null} proxyCaCert
*
* @returns {string} Path to the combined CA bundle PEM file
*/
export function getCombinedCaBundlePath() {
const parts = [];
export function getCombinedCaBundlePath(proxyCaCert) {
// 1) Safe Chain CA (for MITM'd registries)
const safeChainPath = getCaCertPath();
try {
const safeChainPem = fs.readFileSync(safeChainPath, "utf8");
if (isParsable(safeChainPem)) parts.push(safeChainPem.trim());
} catch {
// Ignore if Safe Chain CA is not available
}
const parts = [];
if (proxyCaCert && isParsable(proxyCaCert)) parts.push(proxyCaCert.trim());
// 2) certifi (Mozilla CA bundle for all public HTTPS)
try {
@ -178,4 +171,3 @@ function readUserCertificateFile(certPath) {
}
}

View file

@ -17,8 +17,14 @@ function removeBundleIfExists() {
// Utility to get a valid PEM certificate for testing
function getValidCert() {
const cert = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : "";
assert.ok(cert.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test");
const cert =
typeof tls.rootCertificates?.[0] === "string"
? tls.rootCertificates[0]
: "";
assert.ok(
cert.includes("BEGIN CERTIFICATE"),
"Environment lacks Node root certificates for test",
);
return cert;
}
@ -30,26 +36,25 @@ describe("certBundle.getCombinedCaBundlePath", () => {
it("includes Safe Chain CA when parsable and produces a PEM bundle", async () => {
// Prepare a temporary Safe Chain CA file with a recognizable marker and a valid cert block
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-"));
const safeChainPath = path.join(tmpDir, "safechain-ca.pem");
const marker = "# SAFE_CHAIN_TEST_MARKER";
const rootPem = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : "";
assert.ok(rootPem.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test");
fs.writeFileSync(safeChainPath, `${marker}\n${rootPem}`, "utf8");
// Mock the certUtils.getCaCertPath to return our temp file
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const rootPem =
typeof tls.rootCertificates?.[0] === "string"
? tls.rootCertificates[0]
: "";
assert.ok(
rootPem.includes("BEGIN CERTIFICATE"),
"Environment lacks Node root certificates for test",
);
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
const bundlePath = getCombinedCaBundlePath(`${marker}\n${rootPem}`);
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
assert.match(contents, /-----BEGIN CERTIFICATE-----/);
assert.ok(contents.includes(marker), "Bundle should include Safe Chain CA content when parsable");
assert.ok(
contents.includes(marker),
"Bundle should include Safe Chain CA content when parsable",
);
});
it("ignores invalid Safe Chain CA but still builds from other sources", async () => {
@ -59,21 +64,21 @@ describe("certBundle.getCombinedCaBundlePath", () => {
const invalidMarker = "INVALID_SAFE_CHAIN_CONTENT";
fs.writeFileSync(safeChainPath, invalidMarker, "utf8");
// Mock the certUtils.getCaCertPath to return our invalid file
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
// Ensure fresh build
removeBundleIfExists();
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
const bundlePath = getCombinedCaBundlePath(invalidMarker);
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Bundle should contain certificate blocks from certifi/Node roots");
assert.ok(!contents.includes(invalidMarker), "Bundle should not include invalid Safe Chain content");
assert.match(
contents,
/-----BEGIN CERTIFICATE-----/,
"Bundle should contain certificate blocks from certifi/Node roots",
);
assert.ok(
!contents.includes(invalidMarker),
"Bundle should not include invalid Safe Chain content",
);
});
});
@ -84,34 +89,28 @@ describe("certBundle.getCombinedCaBundlePath with user certs", () => {
});
it("returns a path with full CA bundle (Safe Chain + Mozilla + Node roots) when no user cert in env", async () => {
// Mock getCaCertPath to return valid cert
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
const bundlePath = getCombinedCaBundlePath(getValidCert());
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain certificate blocks");
assert.match(
contents,
/-----BEGIN CERTIFICATE-----/,
"Should contain certificate blocks",
);
// Should include base bundle (Safe Chain + Mozilla/Node roots)
assert.ok(contents.length > 1000, "Bundle should be substantial with Mozilla/Node roots included");
assert.ok(
contents.length > 1000,
"Bundle should be substantial with Mozilla/Node roots included",
);
});
it("merges user cert with full base bundle (Safe Chain CA + Mozilla + Node roots)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
// Create Safe Chain CA
const safeChainPath = path.join(tmpDir, "safechain.pem");
const safeChainCert = getValidCert();
fs.writeFileSync(safeChainPath, safeChainCert, "utf8");
// Create user cert file
const userCertPath = path.join(tmpDir, "user-cert.pem");
@ -119,261 +118,63 @@ describe("certBundle.getCombinedCaBundlePath with user certs", () => {
fs.writeFileSync(userCertPath, userCert, "utf8");
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
const bundlePath = getCombinedCaBundlePath(safeChainCert);
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Both certs should be in the bundle
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
assert.ok(certCount >= 2, "Bundle should contain both Safe Chain and user certificates");
});
it("ignores non-existent user cert path", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
process.env.NODE_EXTRA_CA_CERTS = "/nonexistent/path.pem";
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should still have Safe Chain CA
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || [])
.length;
assert.ok(
certCount >= 2,
"Bundle should contain both Safe Chain and user certificates",
);
});
it("ignores invalid PEM user cert", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
const userCertPath = path.join(tmpDir, "invalid.pem");
fs.writeFileSync(userCertPath, "NOT A VALID PEM", "utf8");
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
const bundlePath = getCombinedCaBundlePath(getValidCert());
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should still have Safe Chain CA only
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
assert.ok(!contents.includes("NOT A VALID"), "Should not include invalid cert");
});
it("rejects user cert with path traversal attempts", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
process.env.NODE_EXTRA_CA_CERTS = "../../../etc/passwd";
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should only have Safe Chain CA, rejected the traversal path
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
});
it("rejects user cert with symlink", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
// Create a target file and a symlink to it
const targetCert = path.join(tmpDir, "target.pem");
fs.writeFileSync(targetCert, getValidCert(), "utf8");
const symlinkPath = path.join(tmpDir, "symlink.pem");
try {
fs.symlinkSync(targetCert, symlinkPath);
} catch {
// Skip test if symlinks are not supported (e.g., on Windows without admin)
return;
}
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
process.env.NODE_EXTRA_CA_CERTS = symlinkPath;
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should only have Safe Chain CA, symlinks are rejected
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
});
it("rejects user cert that is a directory", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
const certDir = path.join(tmpDir, "certs");
fs.mkdirSync(certDir);
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
process.env.NODE_EXTRA_CA_CERTS = certDir;
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should only have Safe Chain CA
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
});
it("handles empty string user cert path", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
process.env.NODE_EXTRA_CA_CERTS = " ";
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
assert.match(
contents,
/-----BEGIN CERTIFICATE-----/,
"Should contain Safe Chain CA",
);
assert.ok(
!contents.includes("NOT A VALID"),
"Should not include invalid cert",
);
});
it("accepts files with CRLF line endings (Windows-style)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
// Create a real file with CRLF content to test Windows line ending support
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const userCertPath = path.join(tmpDir, "user-cert-crlf.pem");
const userCert = getValidCert();
const certWithCRLF = userCert.replace(/\n/g, "\r\n");
fs.writeFileSync(userCertPath, certWithCRLF, "utf8");
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
const bundlePath = getCombinedCaBundlePath(getValidCert());
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
assert.ok(certCount >= 2, "Bundle should contain Safe Chain and user certificates with CRLF");
});
it("detects and handles Windows-style path syntax (drive letters and UNC)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
// Test that Windows path syntax is recognized (even if files don't exist on macOS/Linux)
// These should gracefully fail (return Safe Chain CA only) rather than crash
const winPaths = [
"C:\\temp\\cert.pem",
"D:\\Users\\name\\certs\\ca.pem",
"\\\\server\\share\\cert.pem"
];
for (const winPath of winPaths) {
process.env.NODE_EXTRA_CA_CERTS = winPath;
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), `Bundle should exist for ${winPath}`);
const contents = fs.readFileSync(bundlePath, "utf8");
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
}
});
it("rejects path traversal with Windows-style paths (C:\\temp\\..\\etc\\passwd)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
// Test various Windows-style traversal attempts
const traversalPaths = [
"C:\\temp\\..\\etc\\passwd",
"D:\\Users\\..\\..\\Windows\\System32",
"\\\\server\\share\\..\\admin",
"../../../etc/passwd", // Unix-style for comparison
];
// First, get baseline bundle without user certs to know expected cert count
delete process.env.NODE_EXTRA_CA_CERTS;
const baselineBundlePath = getCombinedCaBundlePath();
const baselineContents = fs.readFileSync(baselineBundlePath, "utf8");
const baselineCertCount = (baselineContents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
for (const badPath of traversalPaths) {
process.env.NODE_EXTRA_CA_CERTS = badPath;
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should contain base bundle (Safe Chain + Mozilla + Node roots) but NOT user cert
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
assert.strictEqual(certCount, baselineCertCount, `Traversal path ${badPath} should be rejected; base bundle only (no user cert added)`);
}
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || [])
.length;
assert.ok(
certCount >= 2,
"Bundle should contain Safe Chain and user certificates with CRLF",
);
});
});

View file

@ -0,0 +1,108 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtempSync, readFile } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { ui } from "../../environment/userInteraction.js";
import { getLoggingLevel, LOGGING_VERBOSE } from "../../config/settings.js";
const readFilePromise = promisify(readFile);
/**
* @typedef {Object} RamaProxyInstance
* @property {import("node:child_process").ChildProcess} process
* @property {string} proxyAddress
* @property {string} metaAddress
* @property {string} caCert
*/
/**
* @returns {String | null}
*/
export function getRamaPath() {
const executableDir = dirname(process.execPath);
const ramaPath = join(executableDir, "safechain-proxy");
if (existsSync(ramaPath)) {
return ramaPath;
}
return null;
}
/**
* @param {string} ramaPath
*
* @returns {import("../registryProxy.js").SafeChainProxy} */
export function createRamaProxy(ramaPath) {
const tempDir = mkdtempSync(join(tmpdir(), "safe-chain-proxy-"));
/** @type {RamaProxyInstance | null} */
let ramaInstance = null;
return {
startServer: async () => {
ramaInstance = await startRama(ramaPath, tempDir);
ui.writeVerbose(
`Proxy started at address "${ramaInstance.proxyAddress}"`,
);
},
stopServer: async () => {
if (ramaInstance) {
ramaInstance.process.kill();
}
return Promise.resolve();
},
verifyNoMaliciousPackages: () => true,
hasSuppressedVersions: () => false,
getServerPort: () => {
if (!ramaInstance) return null;
const url = new URL(`http://${ramaInstance.proxyAddress}`);
return url.port ? parseInt(url.port, 10) : null;
},
getCaCert: () => ramaInstance?.caCert ?? null,
};
}
/**
* @param {string} ramaPath
* @param {string} dataFolder
* @returns {Promise<RamaProxyInstance>}
*/
async function startRama(ramaPath, dataFolder) {
const startTime = Date.now();
const args = ["--secrets", "memory", "--data", dataFolder];
const stdio = getLoggingLevel() === LOGGING_VERBOSE ? "inherit" : "pipe";
const process = spawn(ramaPath, args, { stdio: stdio });
// wait for the proxy process to start (poll for proxy.addr.txt file)
const proxyAddrPath = join(dataFolder, "proxy.addr.txt");
const maxWaitTime = 60000; // 60 seconds
const pollInterval = 500; // 500 ms
while (!existsSync(proxyAddrPath)) {
if (Date.now() - startTime > maxWaitTime) {
throw new Error("Timeout waiting for proxy to start");
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
const elapsedTime = Date.now() - startTime;
ui.writeVerbose(`Proxy started in ${elapsedTime}ms`);
const proxyAddress = await readFilePromise(proxyAddrPath, "utf-8");
const metaAddress = await readFilePromise(
join(dataFolder, "meta.addr.txt"),
"utf-8",
);
const certResponse = await fetch(`http://${metaAddress}/ca`);
const caCert = await certResponse.text();
return {
process,
proxyAddress,
metaAddress,
caCert,
};
}

View file

@ -14,14 +14,14 @@ const mockIsImdsEndpoint = (host) => {
].includes(host);
};
mock.module("./isImdsEndpoint.js", {
mock.module("./builtInProxy/isImdsEndpoint.js", {
namedExports: {
isImdsEndpoint: mockIsImdsEndpoint,
},
});
// Mock getConnectTimeout to speed up tests
mock.module("./getConnectTimeout.js", {
mock.module("./builtInProxy/getConnectTimeout.js", {
namedExports: {
getConnectTimeout: (host) => {
// IMDS endpoints: 100ms (real: 3s)
@ -185,13 +185,13 @@ describe("registryProxy.connectTunnel", () => {
// Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts)
assert.ok(
responseData.includes("HTTP/1.1 504 Gateway Timeout"),
"Should return 504 for timeout"
"Should return 504 for timeout",
);
// Should timeout around 100ms for IMDS endpoints (allow some margin)
assert.ok(
duration >= 80 && duration < 200,
`IMDS timeout should be ~80-200ms, got ${duration}ms`
`IMDS timeout should be ~80-200ms, got ${duration}ms`,
);
socket.destroy();

View file

@ -1,30 +1,59 @@
import * as http from "http";
import { tunnelRequest } from "./tunnelRequestHandler.js";
import { mitmConnect } from "./mitmRequestHandler.js";
import { handleHttpProxyRequest } from "./plainHttpProxy.js";
import { getCombinedCaBundlePath } from "./certBundle.js";
import { ui } from "../environment/userInteraction.js";
import chalk from "chalk";
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
import { createRamaProxy, getRamaPath } from "./ramaProxy/createRamaProxy.js";
import { createBuiltInProxyServer } from "./builtInProxy/createBuiltInProxyServer.js";
import { getCombinedCaBundlePath } from "./certBundle.js";
const SERVER_STOP_TIMEOUT_MS = 1000;
/**
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}}
* @typedef {Object} SafeChainProxy
* @prop {() => Promise<void>} startServer
* @prop {() => Promise<void>} stopServer
* @prop {() => boolean} verifyNoMaliciousPackages
* @prop {() => boolean} hasSuppressedVersions
* @prop {() => Number | null} getServerPort
* @prop {() => string | null} getCaCert
*
* @typedef {Object} ProxySettings
* @prop {string | null} proxyUrl
* @prop {string} caCertBundlePath
*/
const state = {
port: null,
blockedRequests: [],
};
/** @type {SafeChainProxy} */
let server;
export function createSafeChainProxy() {
const server = createProxyServer();
if (server) {
return server;
}
let ramaPath = getRamaPath();
if (ramaPath) {
ui.writeInformation("Starting safe-chain rama proxy");
server = createRamaProxy(ramaPath);
} else {
server = createBuiltInProxyServer();
}
return server;
}
/**
* @returns {ProxySettings}
*/
export function getProxySettings() {
if (!server || !server.getServerPort()) {
return {
proxyUrl: null,
caCertBundlePath: getCombinedCaBundlePath(null),
};
}
const proxyUrl = `http://localhost:${server.getServerPort()}`;
const caCert = server.getCaCert();
const caCertBundlePath = getCombinedCaBundlePath(caCert);
return {
startServer: () => startServer(server),
stopServer: () => stopServer(server),
verifyNoMaliciousPackages,
hasSuppressedVersions: getHasSuppressedVersions,
proxyUrl,
caCertBundlePath,
};
}
@ -32,17 +61,16 @@ export function createSafeChainProxy() {
* @returns {Record<string, string>}
*/
function getSafeChainProxyEnvironmentVariables() {
if (!state.port) {
if (!server || !server.getServerPort()) {
return {};
}
const proxyUrl = `http://localhost:${state.port}`;
const caCertPath = getCombinedCaBundlePath();
const proxySettings = getProxySettings();
return {
HTTPS_PROXY: proxyUrl,
GLOBAL_AGENT_HTTP_PROXY: proxyUrl,
NODE_EXTRA_CA_CERTS: caCertPath,
HTTPS_PROXY: proxySettings.proxyUrl ?? "",
GLOBAL_AGENT_HTTP_PROXY: proxySettings.proxyUrl ?? "",
NODE_EXTRA_CA_CERTS: proxySettings.caCertBundlePath,
};
}
@ -67,126 +95,3 @@ export function mergeSafeChainProxyEnvironmentVariables(env) {
return proxyEnv;
}
function createProxyServer() {
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 server;
}
/**
* @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;
}

View file

@ -7,7 +7,7 @@ import {
createSafeChainProxy,
mergeSafeChainProxyEnvironmentVariables,
} from "./registryProxy.js";
import { getCaCertPath } from "./certUtils.js";
import { getCaCertPath } from "./builtInProxy/certUtils.js";
import {
setEcoSystem,
ECOSYSTEM_JS,