mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge pull request #286 from AikidoSec/new-proxy-beta
Use ramaproxy if it's available.
This commit is contained in:
commit
cf63134761
31 changed files with 483 additions and 465 deletions
|
|
@ -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=""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ describe("runPipXCommand", () => {
|
|||
mergeCalls.push(env);
|
||||
return { ...env, ...mergedEnvReturn };
|
||||
},
|
||||
getProxySettings: () => {
|
||||
return {
|
||||
proxyUrl: "",
|
||||
caCertBundlePath: "/tmp/test-combined-ca.pem",
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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 = {
|
||||
|
|
@ -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,
|
||||
|
|
@ -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: () => {},
|
||||
|
|
@ -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",
|
||||
|
|
@ -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 = [
|
||||
|
|
@ -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 () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
@ -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
|
||||
|
|
@ -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) {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue