This commit is contained in:
Sander Declerck 2026-05-22 16:54:57 +08:00 committed by GitHub
commit f6e46aa614
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1481 additions and 645 deletions

View file

@ -429,6 +429,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: $1"
;;
@ -444,6 +447,7 @@ parse_arguments() {
main() {
# Initialize argument flags
USE_CI_SETUP=false
USE_RAMA_PROXY=false
# Parse command-line arguments
parse_arguments "$@"
@ -503,6 +507,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
run_setup_command "$FINAL_FILE"
}

View file

@ -20,8 +20,22 @@ export async function main(args) {
process.on("SIGINT", handleProcessTermination);
process.on("SIGTERM", handleProcessTermination);
/** @type {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} */
let malwareBlockedEvents = [];
/** @type {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} */
let minPackageAgeBlocks = [];
/** @type {import("./registryProxy/registryProxy.js").MinPackageAgeSuppressionEvent[]} */
let suppressedVersionEvents = [];
const proxy = createSafeChainProxy();
await proxy.startServer();
proxy.addListener("malwareBlocked", (ev) => malwareBlockedEvents.push(ev));
proxy.addListener("minPackageAgeVersionsSuppressed", (ev) =>
suppressedVersionEvents.push(ev),
);
proxy.addListener("minimumAgeRequestBlocked", (ev) =>
minPackageAgeBlocks.push(ev),
);
// Global error handlers to log unhandled errors
process.on("uncaughtException", (error) => {
@ -64,11 +78,13 @@ export async function main(args) {
// Write all buffered logs
ui.writeBufferedLogsAndStopBuffering();
if (proxy.hasBlockedMaliciousPackages()) {
if (malwareBlockedEvents.length > 0) {
printBlockedMalware(malwareBlockedEvents);
return 1;
}
if (proxy.hasBlockedMinimumAgeRequests()) {
if (minPackageAgeBlocks.length > 0) {
printMinPackageAgeBlocks(minPackageAgeBlocks);
return 1;
}
@ -81,17 +97,8 @@ export async function main(args) {
);
}
if (proxy.hasSuppressedVersions()) {
ui.writeInformation(
`${chalk.yellow(
"",
)} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age.`,
);
ui.writeInformation(
` To disable this check, use: ${chalk.cyan(
"--safe-chain-skip-minimum-package-age",
)}`,
);
if (suppressedVersionEvents.length > 0) {
printSuppressedVersions(suppressedVersionEvents);
}
// Returning the exit code back to the caller allows the promise
@ -121,3 +128,77 @@ function isSafeChainVerify(args) {
return true;
}
}
/**
*
* @param {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} malwareBlockedEvents
*/
function printBlockedMalware(malwareBlockedEvents) {
ui.emptyLine();
ui.writeInformation(
`Safe-chain: ${chalk.bold(
`blocked ${malwareBlockedEvents.length} malicious package downloads`,
)}:`,
);
for (const ev of malwareBlockedEvents) {
ui.writeInformation(` - ${ev.packageName}@${ev.packageVersion}`);
}
ui.emptyLine();
ui.writeExitWithoutInstallingMaliciousPackages();
ui.emptyLine();
}
/**
*
* @param {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} minPackageAgeBlocks
*/
function printMinPackageAgeBlocks(minPackageAgeBlocks) {
ui.emptyLine();
ui.writeInformation(
`Safe-chain: ${chalk.bold(
`blocked ${minPackageAgeBlocks.length} direct package download request(s) due to minimum package age`,
)}:`,
);
for (const req of minPackageAgeBlocks) {
ui.writeInformation(` - ${req.packageName}@${req.packageVersion}`);
}
ui.writeInformation(
` To disable this check, use: ${chalk.cyan(
"--safe-chain-skip-minimum-package-age",
)}`,
);
ui.emptyLine();
ui.writeError(
"Safe-chain: Exiting without installing packages blocked by the direct download minimum package age check.",
);
ui.emptyLine();
}
/**
*
* @param {import("./registryProxy/registryProxy.js").MinPackageAgeSuppressionEvent[]} minPackageAgeSuppressionEvents
*/
function printSuppressedVersions(minPackageAgeSuppressionEvents) {
ui.writeVerbose(
`${chalk.yellow(
"",
)} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age:`,
);
for (const ev of minPackageAgeSuppressionEvents) {
ui.writeVerbose(` - ${ev.packageName} (${ev.packageVersions.join(", ")})`);
}
ui.writeVerbose(
` To disable this check, use: ${chalk.cyan(
"--safe-chain-skip-minimum-package-age",
)}`,
);
}

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";
@ -100,7 +102,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";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
@ -42,7 +44,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";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
@ -57,7 +59,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";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
@ -48,7 +50,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

@ -1,7 +1,7 @@
import forge from "node-forge";
import path from "path";
import fs from "fs";
import { getCertsDir } from "../config/safeChainDir.js";
import { getCertsDir } from "../../config/safeChainDir.js";
const ca = loadCa();

View file

@ -6,7 +6,7 @@ describe("certUtils", () => {
beforeEach(() => {
installedSafeChainDir = undefined;
mock.module("../config/safeChainDir.js", {
mock.module("../../config/safeChainDir.js", {
namedExports: {
getSafeChainBaseDir: () => installedSafeChainDir ?? "/home/test/.safe-chain",
getCertsDir: () => `${installedSafeChainDir ?? "/home/test/.safe-chain"}/certs`,

View file

@ -0,0 +1,156 @@
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 { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
import { getCaCertPath } from "./certUtils.js";
import { readFileSync } from "fs";
import EventEmitter from "events";
import { modifyResponseEventEmitter } from "./interceptors/npm/modifyNpmInfo.js";
import { modifyPipResponseEventEmitter } from "./interceptors/pip/modifyPipInfo.js";
import { cleanupCertBundle } from "../certBundle.js";
/** *
* @returns {import("../registryProxy.js").SafeChainProxy} */
export function createBuiltInProxyServer() {
const SERVER_STOP_TIMEOUT_MS = 1000;
/**
* @type {{port: number | null}}
*/
const state = {
port: null,
};
/** @type {EventEmitter<import("../registryProxy.js").ProxyServerEvents>} */
const emitter = new EventEmitter();
modifyResponseEventEmitter.addListener("versionsRemoved", (ev) => {
emitter.emit("minPackageAgeVersionsSuppressed", ev);
});
modifyPipResponseEventEmitter.addListener("versionsRemoved", (ev) => {
emitter.emit("minPackageAgeVersionsSuppressed", ev);
});
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 Object.assign(emitter, {
startServer: () => startServer(server),
stopServer: () => stopServer(server),
getServerPort: () => state.port,
getCaCert,
});
/**
* @param {import("http").Server} server
*
* @returns {Promise<void>}
*/
function startServer(server) {
return new Promise((resolve, reject) => {
// Bind to loopback only. Without an explicit host, Node listens on every
// interface, turning the proxy into an unauthenticated forward proxy that
// anyone reachable on the network can use to hit the victim's localhost,
// intranet, or cloud metadata endpoints. Port 0 lets the OS pick a port.
server.listen(0, "127.0.0.1", () => {
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(() => {
cleanupCertBundle();
resolve();
});
} catch {
resolve();
}
setTimeout(() => {
cleanupCertBundle();
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,
) => {
emitter.emit("malwareBlocked", {
packageName: event.packageName,
packageVersion: event.version,
});
},
);
interceptor.on(
"minimumAgeRequestBlocked",
(
/** @type {import("./interceptors/interceptorBuilder.js").MinimumAgeRequestBlockedEvent} */ event,
) => {
emitter.emit("minimumAgeRequestBlocked", {
packageName: event.packageName,
packageVersion: event.version,
});
},
);
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);
}
}
function getCaCert() {
try {
const safeChainPath = getCaCertPath();
return readFileSync(safeChainPath, "utf8");
} catch {
return null;
}
}
}

View file

@ -0,0 +1,125 @@
import { describe, it, before, after, mock } from "node:test";
import assert from "node:assert";
import EventEmitter from "events";
// Mock dependencies before importing the module under test
const mockMitmConnect = mock.fn();
const mockTunnelRequest = mock.fn();
const mockUi = { writeVerbose: mock.fn() };
const mockGetCaCertPath = mock.fn(() => "/fake/cert/path");
const mockModifyResponseEventEmitter = new EventEmitter();
/** @type {import("./interceptors/interceptorBuilder.js").Interceptor | undefined} */
let mockInterceptor;
mock.module("./mitmRequestHandler.js", {
namedExports: { mitmConnect: mockMitmConnect },
});
mock.module("./tunnelRequestHandler.js", {
namedExports: { tunnelRequest: mockTunnelRequest },
});
mock.module("./plainHttpProxy.js", {
namedExports: { handleHttpProxyRequest: mock.fn() },
});
mock.module("../../environment/userInteraction.js", {
namedExports: { ui: mockUi },
});
mock.module("./interceptors/createInterceptorForEcoSystem.js", {
namedExports: {
createInterceptorForUrl: mock.fn(() => mockInterceptor),
},
});
mock.module("./interceptors/npm/modifyNpmInfo.js", {
namedExports: { modifyResponseEventEmitter: mockModifyResponseEventEmitter },
});
mock.module("./certUtils.js", {
namedExports: { getCaCertPath: mockGetCaCertPath },
});
const { createBuiltInProxyServer } = await import(
"./createBuiltInProxyServer.js"
);
describe("createBuiltInProxyServer event emission", () => {
/** @type {ReturnType<typeof createBuiltInProxyServer>} */
let proxy;
before(async () => {
proxy = createBuiltInProxyServer();
await proxy.startServer();
});
after(async () => {
await proxy.stopServer();
});
it("emits malwareBlocked when the interceptor fires a malwareBlocked event", async () => {
// Create a real EventEmitter-based interceptor that we can trigger
const interceptorEmitter = new EventEmitter();
mockInterceptor = Object.assign(interceptorEmitter, {
handleRequest: mock.fn(async () => ({
blockResponse: { statusCode: 403, message: "blocked" },
modifyRequestHeaders: (/** @type {any} */ h) => h,
modifiesResponse: () => false,
modifyBody: (/** @type {any} */ b) => b,
})),
});
const eventPromise = new Promise((resolve) => {
proxy.once("malwareBlocked", resolve);
});
// Trigger a CONNECT request to the proxy to wire up the interceptor
const port = proxy.getServerPort();
assert.ok(port, "Server should have a port");
const net = await import("net");
const socket = net.connect(port, "127.0.0.1", () => {
socket.write(
"CONNECT registry.npmjs.org:443 HTTP/1.1\r\nHost: registry.npmjs.org:443\r\n\r\n",
);
});
// Wait for the CONNECT handler to run and subscribe to the interceptor
await new Promise((resolve) => setTimeout(resolve, 100));
// Now fire the malwareBlocked event on the interceptor
interceptorEmitter.emit("malwareBlocked", {
packageName: "evil-package",
version: "1.0.0",
targetUrl: "https://registry.npmjs.org/evil-package/-/evil-package-1.0.0.tgz",
timestamp: Date.now(),
});
const received = await eventPromise;
assert.deepStrictEqual(received, {
packageName: "evil-package",
packageVersion: "1.0.0",
});
socket.destroy();
});
it("does not emit malwareBlocked for non-intercepted hosts", async () => {
// No interceptor for this URL
mockInterceptor = undefined;
let emitted = false;
proxy.on("malwareBlocked", () => {
emitted = true;
});
const port = proxy.getServerPort();
const net = await import("net");
const socket = net.connect(port, "127.0.0.1", () => {
socket.write(
"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n",
);
});
await new Promise((resolve) => setTimeout(resolve, 100));
assert.strictEqual(emitted, false, "Should not emit for non-intercepted hosts");
socket.destroy();
});
});

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 "./pip/pipInterceptor.js";

View file

@ -1,5 +1,5 @@
import { getMinimumPackageAgeExclusions, getEcoSystem } from "../../config/settings.js";
import { getEquivalentPackageNames } from "../../scanning/packageNameVariants.js";
import { getMinimumPackageAgeExclusions, getEcoSystem } from "../../../config/settings.js";
import { getEquivalentPackageNames } from "../../../scanning/packageNameVariants.js";
/**
* Checks if a package name matches an exclusion pattern.

View file

@ -1,7 +1,13 @@
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
import { ui } from "../../../environment/userInteraction.js";
import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js";
import { recordSuppressedVersion } from "../suppressedVersionsState.js";
import { EventEmitter } from "events";
import { getMinimumPackageAgeHours } from "../../../../config/settings.js";
import { ui } from "../../../../environment/userInteraction.js";
import {
clearCachingHeaders,
getHeaderValueAsString,
} from "../../http-utils.js";
/** @type {EventEmitter<{ versionsRemoved: [{packageName: string, packageVersions: string[]}] }>} */
export const modifyResponseEventEmitter = new EventEmitter();
/**
* @param {NodeJS.Dict<string | string[]>} headers
@ -62,8 +68,10 @@ export function modifyNpmInfoResponse(body, headers) {
return body;
}
const packageName = bodyJson.name;
const cutOff = new Date(
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000,
);
const hasLatestTag = !!bodyJson["dist-tags"]["latest"];
@ -75,9 +83,13 @@ export function modifyNpmInfoResponse(body, headers) {
}))
.filter((x) => x.version !== "created" && x.version !== "modified");
const removedVersions = [];
for (const { version, timestamp } of versions) {
const timestampValue = new Date(timestamp);
if (timestampValue > cutOff) {
removedVersions.push(version);
deleteVersionFromJson(bodyJson, version);
clearCachingHeaders(headers);
}
@ -89,10 +101,17 @@ export function modifyNpmInfoResponse(body, headers) {
bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson.time);
}
if (removedVersions.length > 0) {
modifyResponseEventEmitter.emit("versionsRemoved", {
packageName: packageName,
packageVersions: removedVersions,
});
}
return Buffer.from(JSON.stringify(bodyJson));
} catch (/** @type {any} */ err) {
ui.writeVerbose(
`Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}`
`Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}`,
);
return body;
}
@ -103,12 +122,10 @@ export function modifyNpmInfoResponse(body, headers) {
* @param {string} version
*/
function deleteVersionFromJson(json, version) {
recordSuppressedVersion();
const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
ui.writeVerbose(
`Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
`Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`,
);
delete json.time[version];
@ -127,18 +144,20 @@ function deleteVersionFromJson(json, version) {
*/
function calculateLatestTag(tagList) {
const entries = Object.entries(tagList).filter(
([version, _]) => version !== "created" && version !== "modified"
([version, _]) => version !== "created" && version !== "modified",
);
const latestFullRelease = getMostRecentTag(
Object.fromEntries(entries.filter(([version, _]) => !version.includes("-")))
Object.fromEntries(
entries.filter(([version, _]) => !version.includes("-")),
),
);
if (latestFullRelease) {
return latestFullRelease;
}
const latestPrerelease = getMostRecentTag(
Object.fromEntries(entries.filter(([version, _]) => version.includes("-")))
Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))),
);
return latestPrerelease;
}

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 {
getPackageNameFromMetadataResponse,
@ -11,7 +11,7 @@ import {
modifyNpmInfoResponse,
} from "./modifyNpmInfo.js";
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
import { openNewPackagesDatabase } from "../../../../scanning/newPackagesListCache.js";
import {
isExcludedFromMinimumPackageAge,
} from "../minimumPackageAgeExclusions.js";

View file

@ -7,7 +7,7 @@ describe("npmInterceptor minimum package age", async () => {
let minimumPackageAgeExclusionsSetting = [];
let newlyReleasedPackages = new Set();
mock.module("../../../config/settings.js", {
mock.module("../../../../config/settings.js", {
namedExports: {
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
@ -18,7 +18,7 @@ describe("npmInterceptor minimum package age", async () => {
getEcoSystem: () => "js",
},
});
mock.module("../../../scanning/newPackagesListCache.js", {
mock.module("../../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: (name, version) =>
@ -27,14 +27,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

@ -7,7 +7,7 @@ let customRegistries = [];
let newlyReleasedPackages = new Set();
let skipMinimumPackageAgeSetting = false;
mock.module("../../../scanning/audit/index.js", {
mock.module("../../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };
@ -16,7 +16,7 @@ mock.module("../../../scanning/audit/index.js", {
},
});
mock.module("../../../config/settings.js", {
mock.module("../../../../config/settings.js", {
namedExports: {
LOGGING_SILENT: "silent",
LOGGING_NORMAL: "normal",
@ -32,7 +32,7 @@ mock.module("../../../config/settings.js", {
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
},
});
mock.module("../../../scanning/newPackagesListCache.js", {
mock.module("../../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: (name, version) =>

View file

@ -1,11 +1,15 @@
import { ui } from "../../../environment/userInteraction.js";
import { EventEmitter } from "events";
import { ui } from "../../../../environment/userInteraction.js";
import { clearCachingHeaders } from "../../http-utils.js";
import { normalizePipPackageName } from "../../../scanning/packageNameVariants.js";
import { normalizePipPackageName } from "../../../../scanning/packageNameVariants.js";
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.js";
import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js";
import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js";
/** @type {EventEmitter<{ versionsRemoved: [{packageName: string, packageVersions: string[]}] }>} */
export const modifyPipResponseEventEmitter = new EventEmitter();
/**
* Strip conditional GET headers so PyPI always returns a full 200 response
* with a body we can rewrite. Without this, pip sends If-None-Match /
@ -50,33 +54,42 @@ export function modifyPipInfoResponse(
return body;
}
/** @type {{ buffer: Buffer, suppressedVersions: string[] } | undefined} */
let result;
if (
contentType.includes("html") ||
contentType.includes("application/vnd.pypi.simple.v1+html")
) {
return modifyHtmlSimpleResponse(
result = modifyHtmlSimpleResponse(
body,
headers,
metadataUrl,
isNewlyReleasedPackage,
packageName
);
}
if (
} else if (
contentType.includes("json") ||
contentType.includes("application/vnd.pypi.simple.v1+json")
) {
return modifyJsonResponse(
result = modifyJsonResponse(
body,
headers,
metadataUrl,
isNewlyReleasedPackage,
packageName
);
} else {
return body;
}
return body;
if (result.suppressedVersions.length > 0) {
modifyPipResponseEventEmitter.emit("versionsRemoved", {
packageName,
packageVersions: result.suppressedVersions,
});
}
return result.buffer;
} catch (/** @type {any} */ err) {
ui.writeVerbose(
`Safe-chain: PyPI package metadata not in expected format - bypassing modification. Error: ${err.message}`
@ -91,7 +104,7 @@ export function modifyPipInfoResponse(
* @param {string} metadataUrl
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @returns {Buffer}
* @returns {{ buffer: Buffer, suppressedVersions: string[] }}
*/
function modifyHtmlSimpleResponse(
body,
@ -101,35 +114,35 @@ function modifyHtmlSimpleResponse(
packageName
) {
const html = body.toString("utf8");
let modified = false;
const suppressedVersions = /** @type {string[]} */ ([]);
const rewriteHtmlAnchor = createHtmlAnchorRewriter(
metadataUrl,
isNewlyReleasedPackage,
packageName,
() => {
modified = true;
(version) => {
suppressedVersions.push(version);
}
);
const updatedHtml = html.replace(HTML_ANCHOR_HREF_RE, rewriteHtmlAnchor);
if (!modified) return body;
if (suppressedVersions.length === 0) return { buffer: body, suppressedVersions: [] };
const modifiedBuffer = Buffer.from(updatedHtml);
clearCachingHeaders(headers);
return modifiedBuffer;
return { buffer: modifiedBuffer, suppressedVersions: [...new Set(suppressedVersions)] };
}
/**
* @param {string} metadataUrl
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @param {() => void} onModified
* @param {(version: string) => void} onVersionSuppressed
* @returns {(anchor: string, quote: string, href: string) => string}
*/
function createHtmlAnchorRewriter(
metadataUrl,
isNewlyReleasedPackage,
packageName,
onModified
onVersionSuppressed
) {
return (anchor, _quote, href) => {
const resolvedHref = new URL(href, metadataUrl).toString();
@ -145,8 +158,8 @@ function createHtmlAnchorRewriter(
version &&
isNewlyReleasedPackage(packageName, version)
) {
onModified();
logSuppressedVersion(packageName, version);
onVersionSuppressed(version);
return "";
}
@ -160,7 +173,7 @@ function createHtmlAnchorRewriter(
* @param {string} metadataUrl
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @returns {Buffer}
* @returns {{ buffer: Buffer, suppressedVersions: string[] }}
*/
function modifyJsonResponse(
body,
@ -170,15 +183,15 @@ function modifyJsonResponse(
packageName
) {
const json = JSON.parse(body.toString("utf8"));
const modified = modifyPipJsonResponse(
const { suppressedVersions, wasModified } = modifyPipJsonResponse(
json,
metadataUrl,
isNewlyReleasedPackage,
packageName
);
if (!modified) return body;
if (!wasModified) return { buffer: body, suppressedVersions: [] };
const modifiedBuffer = Buffer.from(JSON.stringify(json));
clearCachingHeaders(headers);
return modifiedBuffer;
return { buffer: modifiedBuffer, suppressedVersions };
}

View file

@ -2,14 +2,14 @@ import { describe, it, mock } from "node:test";
import assert from "node:assert";
describe("modifyPipInfo", async () => {
mock.module("../../../config/settings.js", {
mock.module("../../../../config/settings.js", {
namedExports: {
getMinimumPackageAgeHours: () => 48,
ECOSYSTEM_PY: "py",
},
});
mock.module("../../../environment/userInteraction.js", {
mock.module("../../../../environment/userInteraction.js", {
namedExports: {
ui: {
writeVerbose: () => {},

View file

@ -10,7 +10,7 @@ import { logSuppressedVersion } from "./pipMetadataResponseUtils.js";
* @param {string} metadataUrl
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @returns {boolean}
* @returns {{ suppressedVersions: string[], wasModified: boolean }}
*/
export function modifyPipJsonResponse(
json,
@ -18,18 +18,18 @@ export function modifyPipJsonResponse(
isNewlyReleasedPackage,
packageName
) {
const filesModified = filterJsonMetadataFiles(
const filesSuppressed = filterJsonMetadataFiles(
json,
metadataUrl,
isNewlyReleasedPackage,
packageName
);
const releasesModified = removeJsonMetadataReleases(
const releasesSuppressed = removeJsonMetadataReleases(
json,
isNewlyReleasedPackage,
packageName
);
const urlsModified = filterJsonMetadataUrls(
const urlsSuppressed = filterJsonMetadataUrls(
json,
metadataUrl,
isNewlyReleasedPackage,
@ -37,7 +37,11 @@ export function modifyPipJsonResponse(
);
const versionModified = updateJsonInfoVersion(json, metadataUrl);
return filesModified || releasesModified || urlsModified || versionModified;
const suppressedVersions = [
...new Set([...filesSuppressed, ...releasesSuppressed, ...urlsSuppressed]),
];
return { suppressedVersions, wasModified: suppressedVersions.length > 0 || versionModified };
}
/**
@ -45,7 +49,7 @@ export function modifyPipJsonResponse(
* @param {string} metadataUrl
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @returns {boolean}
* @returns {string[]}
*/
function filterJsonMetadataFiles(
json,
@ -54,19 +58,17 @@ function filterJsonMetadataFiles(
packageName
) {
if (!Array.isArray(json.files)) {
return false;
return [];
}
let modified = false;
const loggedVersions = new Set();
const suppressed = new Set();
json.files = json.files.filter((/** @type {any} */ file) => {
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
if (version && isNewlyReleasedPackage(packageName, version)) {
modified = true;
if (!loggedVersions.has(version)) {
if (!suppressed.has(version)) {
logSuppressedVersion(packageName, version);
loggedVersions.add(version);
suppressed.add(version);
}
return false;
}
@ -74,21 +76,21 @@ function filterJsonMetadataFiles(
return true;
});
return modified;
return [...suppressed];
}
/**
* @param {any} json
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @returns {boolean}
* @returns {string[]}
*/
function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) {
if (!json.releases || typeof json.releases !== "object") {
return false;
return [];
}
let modified = false;
const suppressed = [];
for (const [version, files] of Object.entries(json.releases)) {
if (
@ -96,12 +98,12 @@ function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) {
isNewlyReleasedPackage(packageName, version)
) {
delete json.releases[version];
modified = true;
logSuppressedVersion(packageName, version);
suppressed.push(version);
}
}
return modified;
return suppressed;
}
/**
@ -109,7 +111,7 @@ function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) {
* @param {string} metadataUrl
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @returns {boolean}
* @returns {string[]}
*/
function filterJsonMetadataUrls(
json,
@ -118,19 +120,17 @@ function filterJsonMetadataUrls(
packageName
) {
if (!Array.isArray(json.urls)) {
return false;
return [];
}
let modified = false;
const loggedVersions = new Set();
const suppressed = new Set();
json.urls = json.urls.filter((/** @type {any} */ file) => {
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
if (version && isNewlyReleasedPackage(packageName, version)) {
modified = true;
if (!loggedVersions.has(version)) {
if (!suppressed.has(version)) {
logSuppressedVersion(packageName, version);
loggedVersions.add(version);
suppressed.add(version);
}
return false;
}
@ -138,7 +138,7 @@ function filterJsonMetadataUrls(
return true;
});
return modified;
return [...suppressed];
}
/**

View file

@ -6,7 +6,7 @@ describe("pipInterceptor custom registries", async () => {
let malwareResponse = false;
let customRegistries = [];
mock.module("../../../config/settings.js", {
mock.module("../../../../config/settings.js", {
namedExports: {
ECOSYSTEM_PY: "py",
getEcoSystem: () => "py",
@ -20,7 +20,7 @@ describe("pipInterceptor custom registries", async () => {
},
});
mock.module("../../../scanning/newPackagesListCache.js", {
mock.module("../../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: () => false,
@ -28,7 +28,7 @@ describe("pipInterceptor custom registries", async () => {
},
});
mock.module("../../../scanning/audit/index.js", {
mock.module("../../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
scannedPackages.push({ packageName, version });
@ -202,4 +202,4 @@ describe("pipInterceptor custom registries", async () => {
)
);
});
});
});

View file

@ -2,10 +2,10 @@ import {
ECOSYSTEM_PY,
getPipCustomRegistries,
skipMinimumPackageAge,
} from "../../../config/settings.js";
import { isMalwarePackage } from "../../../scanning/audit/index.js";
import { getEquivalentPackageNames } from "../../../scanning/packageNameVariants.js";
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
} from "../../../../config/settings.js";
import { isMalwarePackage } from "../../../../scanning/audit/index.js";
import { getEquivalentPackageNames } from "../../../../scanning/packageNameVariants.js";
import { openNewPackagesDatabase } from "../../../../scanning/newPackagesListCache.js";
import { interceptRequests } from "../interceptorBuilder.js";
import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js";
import {

View file

@ -6,13 +6,13 @@ describe("pipInterceptor minimum package age", async () => {
let newlyReleasedPackageResponse = false;
let minimumPackageAgeExclusionsSetting = [];
mock.module("../../../scanning/audit/index.js", {
mock.module("../../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async () => false,
},
});
mock.module("../../../scanning/newPackagesListCache.js", {
mock.module("../../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: (packageName, version) => {
@ -26,7 +26,7 @@ describe("pipInterceptor minimum package age", async () => {
},
});
mock.module("../../../config/settings.js", {
mock.module("../../../../config/settings.js", {
namedExports: {
ECOSYSTEM_PY: "py",
getEcoSystem: () => "py",

View file

@ -5,7 +5,7 @@ describe("pipInterceptor", async () => {
let scannedPackages;
let malwareResponse = false;
mock.module("../../../scanning/audit/index.js", {
mock.module("../../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
scannedPackages.push({ packageName, version });
@ -14,7 +14,7 @@ describe("pipInterceptor", async () => {
},
});
mock.module("../../../scanning/newPackagesListCache.js", {
mock.module("../../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: () => false,
@ -22,7 +22,7 @@ describe("pipInterceptor", async () => {
},
});
mock.module("../../../config/settings.js", {
mock.module("../../../../config/settings.js", {
namedExports: {
ECOSYSTEM_PY: "py",
getEcoSystem: () => "py",

View file

@ -0,0 +1,163 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
describe("pipInterceptor", async () => {
let scannedPackages;
let malwareResponse = false;
mock.module("../../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
scannedPackages.push({ packageName, version });
return malwareResponse;
},
},
});
mock.module("../../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: () => false,
}),
},
});
mock.module("../../../../config/settings.js", {
namedExports: {
ECOSYSTEM_PY: "py",
getEcoSystem: () => "py",
getLoggingLevel: () => "silent",
getMinimumPackageAgeHours: () => 48,
getMinimumPackageAgeExclusions: () => [],
getPipCustomRegistries: () => [],
LOGGING_SILENT: "silent",
LOGGING_VERBOSE: "verbose",
skipMinimumPackageAge: () => false,
},
});
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
const parserCases = [
{
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz",
expected: { packageName: "foobar", version: "1.2.3" },
},
{
url: "https://pypi.org/packages/source/f/foobar/foobar-1.2.3.tar.gz",
expected: { packageName: "foobar", version: "1.2.3" },
},
{
url: "https://pypi.org/packages/source/f/foo-bar/foo-bar-0.9.0.tar.gz",
expected: { packageName: "foo-bar", version: "0.9.0" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-py3-none-any.whl",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
{
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl.metadata",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
{
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
{
url: "https://pypi.org/packages/source/f/foo.bar/foo.bar-1.0.0.tar.gz",
expected: { packageName: "foo.bar", version: "1.0.0" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0b1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0b1" },
},
{
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz.metadata",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0rc1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0rc1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.post1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0.post1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.dev1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0.dev1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0a1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
{
url: "https://pypi.org/simple/",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://pypi.org/project/foobar/",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-latest.tar.gz",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-latest.tar.gz",
expected: { packageName: undefined, version: undefined },
},
];
parserCases.forEach(({ url, expected }, index) => {
it(`should parse URL ${index + 1}: ${url}`, async () => {
scannedPackages = [];
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created for known pip registry");
await interceptor.handleRequest(url);
if (expected.packageName === undefined) {
assert.deepEqual(scannedPackages, []);
return;
}
assert.ok(
scannedPackages.some(
({ packageName, version }) =>
packageName === expected.packageName &&
version === expected.version
)
);
});
});
it("should not create interceptor for unknown registry", () => {
const url = "https://example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
const interceptor = pipInterceptorForUrl(url);
assert.equal(interceptor, undefined);
});
it("should block malicious package", async () => {
scannedPackages = [];
const url =
"https://files.pythonhosted.org/packages/xx/yy/malicious_package-1.0.0.tar.gz";
malwareResponse = true;
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse);
assert.equal(result.blockResponse.statusCode, 403);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain"
);
malwareResponse = false;
});
});

View file

@ -1,7 +1,6 @@
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
import { ui } from "../../../environment/userInteraction.js";
import { getMinimumPackageAgeHours } from "../../../../config/settings.js";
import { ui } from "../../../../environment/userInteraction.js";
import { getHeaderValueAsString } from "../../http-utils.js";
import { recordSuppressedVersion } from "../suppressedVersionsState.js";
/**
* @param {NodeJS.Dict<string | string[]> | undefined} headers
@ -20,7 +19,6 @@ export function getPipMetadataContentType(headers) {
* @returns {void}
*/
export function logSuppressedVersion(packageName, version) {
recordSuppressedVersion();
ui.writeVerbose(
`Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
);

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 } from "zlib";
import { omitHeaders } from "./http-utils.js";

View file

@ -62,7 +62,7 @@ describe("mitmRequestHandler", async () => {
},
});
mock.module("../environment/userInteraction.js", {
mock.module("../../environment/userInteraction.js", {
namedExports: {
ui: {
writeVerbose: () => {},

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";
/** @type {string | null} */
@ -53,25 +52,19 @@ 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() {
export function getCombinedCaBundlePath(proxyCaCert) {
if (bundlePath)
{
return bundlePath;
}
const parts = [];
// 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 {
@ -200,4 +193,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

@ -1,21 +0,0 @@
const state = {
hasSuppressedVersions: false,
};
/**
* Tracks whether any rewritten metadata response suppressed versions during the
* current process lifetime. This is intentional shared state used only for the
* end-of-run summary message exposed through the proxy API.
*
* @returns {void}
*/
export function recordSuppressedVersion() {
state.hasSuppressedVersions = true;
}
/**
* @returns {boolean}
*/
export function getHasSuppressedVersions() {
return state.hasSuppressedVersions;
}

View file

@ -0,0 +1,149 @@
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";
import { getReportingServer } from "./reportingServer.js";
import EventEmitter from "node:events";
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-"));
const reportingServer = getReportingServer();
/** @type {EventEmitter<import("../registryProxy.js").ProxyServerEvents>} */
const emitter = new EventEmitter();
/** @type {RamaProxyInstance | null} */
let ramaInstance = null;
return Object.assign(emitter, {
startServer: async () => {
await reportingServer.start();
reportingServer.addListener("blockReceived", (ev) => {
if (ev.block_reason === "new_package") {
emitter.emit("minimumAgeRequestBlocked", {
packageName: ev.artifact.identifier,
packageVersion: ev.artifact.version,
});
}
else {
emitter.emit("malwareBlocked", {
packageName: ev.artifact.identifier,
packageVersion: ev.artifact.version,
});
}
});
reportingServer.addListener("minPackageAgeSuppressionReceived", (ev) =>
emitter.emit("minPackageAgeVersionsSuppressed", {
packageName: ev.artifact.identifier,
packageVersions: ev.suppressed_versions,
}),
);
ui.writeVerbose(
`Started reporting server at ${reportingServer.getAddress()}`,
);
ramaInstance = await startRama(
ramaPath,
tempDir,
reportingServer.getAddress(),
);
ui.writeVerbose(
`Proxy started at address "${ramaInstance.proxyAddress}"`,
);
},
stopServer: async () => {
await reportingServer.stop();
if (ramaInstance) {
ramaInstance.process.kill();
}
return Promise.resolve();
},
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
* @param {string} reportingUrl
* @returns {Promise<RamaProxyInstance>}
*/
async function startRama(ramaPath, dataFolder, reportingUrl) {
const startTime = Date.now();
const args = [
"--secrets",
"memory",
"--data",
dataFolder,
"--reporting-endpoint",
reportingUrl,
];
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

@ -0,0 +1,177 @@
import { describe, it, before, after, mock } from "node:test";
import assert from "node:assert";
import EventEmitter from "node:events";
// --- Mock setup ---
const mockReportingServer = Object.assign(new EventEmitter(), {
start: mock.fn(async () => {}),
stop: mock.fn(async () => {}),
getAddress: mock.fn(() => "http://127.0.0.1:9999"),
});
mock.module("./reportingServer.js", {
namedExports: {
getReportingServer: () => mockReportingServer,
},
});
const mockKill = mock.fn();
mock.module("node:child_process", {
namedExports: {
spawn: mock.fn(() => ({ kill: mockKill })),
},
});
const mockExistsSync = mock.fn(() => true);
const mockMkdtempSync = mock.fn(() => "/tmp/safe-chain-proxy-abc");
const mockReadFile = mock.fn(
(/** @type {string} */ path, /** @type {string} */ _encoding, /** @type {Function} */ cb) => {
if (path.endsWith("proxy.addr.txt")) {
cb(null, "127.0.0.1:8080");
} else if (path.endsWith("meta.addr.txt")) {
cb(null, "127.0.0.1:8081");
} else {
cb(new Error("unknown file"));
}
},
);
mock.module("node:fs", {
namedExports: {
existsSync: mockExistsSync,
mkdtempSync: mockMkdtempSync,
readFile: mockReadFile,
},
});
mock.module("../../environment/userInteraction.js", {
namedExports: { ui: { writeVerbose: mock.fn() } },
});
mock.module("../../config/settings.js", {
namedExports: {
getLoggingLevel: mock.fn(() => "default"),
LOGGING_VERBOSE: "verbose",
},
});
const mockFetch = mock.method(globalThis, "fetch", async () => ({
text: async () => "MOCK_CA_CERT_PEM",
}));
const { getRamaPath, createRamaProxy } = await import(
"./createRamaProxy.js"
);
describe("getRamaPath", () => {
it("returns path ending in safechain-proxy when existsSync returns true", () => {
mockExistsSync.mock.resetCalls();
mockExistsSync.mock.mockImplementation(() => true);
const result = getRamaPath();
assert.ok(result?.endsWith("safechain-proxy"), `Expected path ending in safechain-proxy, got ${result}`);
});
it("returns null when existsSync returns false", () => {
mockExistsSync.mock.mockImplementation(() => false);
const result = getRamaPath();
assert.strictEqual(result, null);
// Restore for other tests
mockExistsSync.mock.mockImplementation(() => true);
});
});
describe("createRamaProxy — before startServer", () => {
/** @type {ReturnType<typeof createRamaProxy>} */
let proxy;
before(() => {
proxy = createRamaProxy("/fake/path/safechain-proxy");
});
it("getServerPort() returns null", () => {
assert.strictEqual(proxy.getServerPort(), null);
});
it("getCaCert() returns null", () => {
assert.strictEqual(proxy.getCaCert(), null);
});
it("hasSuppressedVersions() returns false", () => {
assert.strictEqual(proxy.hasSuppressedVersions(), false);
});
});
describe("createRamaProxy — after startServer", () => {
/** @type {ReturnType<typeof createRamaProxy>} */
let proxy;
before(async () => {
mockReportingServer.start.mock.resetCalls();
mockReportingServer.stop.mock.resetCalls();
mockKill.mock.resetCalls();
mockFetch.mock.resetCalls();
proxy = createRamaProxy("/fake/path/safechain-proxy");
await proxy.startServer();
});
after(async () => {
await proxy.stopServer();
});
it("transforms blockReceived into malwareBlocked event", async () => {
const eventPromise = new Promise((resolve) => {
proxy.once("malwareBlocked", resolve);
});
mockReportingServer.emit("blockReceived", {
ts_ms: Date.now(),
artifact: {
product: "npm",
identifier: "evil-pkg",
version: "2.0.0",
},
});
const received = await eventPromise;
assert.deepStrictEqual(received, {
packageName: "evil-pkg",
packageVersion: "2.0.0",
});
});
it("getServerPort() returns the correct port", () => {
assert.strictEqual(proxy.getServerPort(), 8080);
});
it("getCaCert() returns the mocked certificate", () => {
assert.strictEqual(proxy.getCaCert(), "MOCK_CA_CERT_PEM");
});
});
describe("createRamaProxy — stopServer", () => {
it("calls kill on spawned process and stop on reporting server", async () => {
mockReportingServer.start.mock.resetCalls();
mockReportingServer.stop.mock.resetCalls();
mockKill.mock.resetCalls();
const proxy = createRamaProxy("/fake/path/safechain-proxy");
await proxy.startServer();
await proxy.stopServer();
assert.strictEqual(mockKill.mock.callCount(), 1);
assert.strictEqual(mockReportingServer.stop.mock.callCount(), 1);
});
it("is safe to call when server was never started", async () => {
mockReportingServer.stop.mock.resetCalls();
const proxy = createRamaProxy("/fake/path/safechain-proxy");
// Should not throw
await proxy.stopServer();
});
});

View file

@ -0,0 +1,139 @@
import * as http from "node:http";
import { EventEmitter } from "node:events";
const SERVER_STOP_TIMEOUT_MS = 1000;
/**
* @typedef {Object} BlockEvent
* @property {number} ts_ms
* @property {{ product: string, identifier: string, version: string }} artifact
* @property {string} block_reason
*/
/**
* @typedef {Object} MinPackageAgeEvent
* @property {number} ts_ms
* @property {{ product: string, identifier: string }} artifact
* @property {string[]} suppressed_versions
*/
/**
* @typedef {{ blockReceived: [BlockEvent], minPackageAgeSuppressionReceived: [MinPackageAgeEvent] }} ReportingServerEvents
*/
/**
* @typedef {EventEmitter<ReportingServerEvents> & {
* start: () => Promise<void>,
* stop: () => Promise<void>,
* getAddress: () => string,
* }} ReportingServer
*/
/**
* @returns {ReportingServer}
*/
export function getReportingServer() {
/** @type {EventEmitter<ReportingServerEvents>} */
const emitter = new EventEmitter();
/** @type {{server: http.Server | null, address: string }} */
let state = {server: null, address: ""};
/** @param {http.IncomingMessage} req @param {http.ServerResponse} res */
async function handleRequest(req, res) {
if (req.method === "POST" && req.url?.startsWith("/events/block")) {
await parseBlockEventFromRequest(req).then((blockEvent) => {
emitter.emit("blockReceived", blockEvent);
});
}
else if (req.method === "POST" && req.url?.startsWith("/events/min-package-age")) {
await parseMinPackageAgeEventFromRequest(req).then((minPackageAgeEvent) => {
emitter.emit("minPackageAgeSuppressionReceived", minPackageAgeEvent);
});
}
res.writeHead(200);
res.end();
}
async function start() {
state = await startReportingServer(handleRequest);
}
/**
*
* @returns {Promise<void>}
*/
function stop() {
return new Promise((resolve) => {
if (!state.server) {
resolve();
return;
}
const timeout = setTimeout(resolve, SERVER_STOP_TIMEOUT_MS);
state.server.close(() => {
clearTimeout(timeout);
resolve();
});
});
}
function getAddress() {
return state.address;
}
return Object.assign(emitter, { start, stop, getAddress });
}
/**
* @param {http.IncomingMessage} req
* @returns {Promise<BlockEvent>}
*/
async function parseBlockEventFromRequest(req) {
const requestData = await getRequestDataAsString(req);
return JSON.parse(requestData);
}
/**
* @param {http.IncomingMessage} req
* @returns {Promise<MinPackageAgeEvent>}
*/
async function parseMinPackageAgeEventFromRequest(req) {
const requestData = await getRequestDataAsString(req);
return JSON.parse(requestData);
}
/**
* @param {http.IncomingMessage} req
* @returns {Promise<string>}
*/
function getRequestDataAsString(req) {
return new Promise((resolve, reject) => {
/** @type {Buffer[]} */
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
req.on("error", reject);
});
}
/**
* @param {http.RequestListener} requestListener
* @returns {Promise<{server: http.Server, address: string}>}
*/
function startReportingServer(requestListener) {
const server = http.createServer(requestListener);
return new Promise((resolve, reject) => {
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (address && typeof address === "object") {
resolve({
address: `http://${address.address}:${address.port}`,
server,
});
} else {
reject(new Error("Failed to start proxy server"));
}
});
});
}

View file

@ -0,0 +1,134 @@
import { describe, it, after, before } from "node:test";
import assert from "node:assert";
import { getReportingServer } from "./reportingServer.js";
/**
* Helper: POST JSON to a URL and return the response status code.
* @param {string} url
* @param {string} body
* @returns {Promise<number>} HTTP status code
*/
async function postJson(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
return res.status;
}
describe("reportingServer", () => {
/** @type {ReturnType<typeof getReportingServer>} */
let server;
before(async () => {
server = getReportingServer();
await server.start();
});
after(async () => {
await server.stop();
});
describe("start / getAddress", () => {
it("returns a valid http://127.0.0.1:<port> address after starting", () => {
const address = server.getAddress();
assert.match(
address,
/^http:\/\/127\.0\.0\.1:\d+$/,
"Address should be http://127.0.0.1:<port>",
);
});
});
describe("POST /events/block", () => {
it("emits a blockReceived event with the parsed JSON body", async () => {
const blockEvent = {
ts_ms: Date.now(),
artifact: {
product: "npm",
identifier: "malicious-pkg",
version: "1.0.0",
},
};
const eventPromise = new Promise((resolve) => {
server.once("blockReceived", resolve);
});
const status = await postJson(
`${server.getAddress()}/events/block`,
JSON.stringify(blockEvent),
);
assert.strictEqual(status, 200);
const received = await eventPromise;
assert.deepStrictEqual(received, blockEvent);
});
});
describe("non-matching routes", () => {
it("returns 200 for GET requests but does not emit blockReceived", async () => {
let emitted = false;
const listener = () => {
emitted = true;
};
server.on("blockReceived", listener);
const res = await fetch(`${server.getAddress()}/other-route`);
assert.strictEqual(res.status, 200);
// Give a tick for any event to fire
await new Promise((resolve) => setTimeout(resolve, 50));
assert.strictEqual(emitted, false, "Should not emit blockReceived for non-matching routes");
server.off("blockReceived", listener);
});
it("returns 200 for POST to a different path but does not emit blockReceived", async () => {
let emitted = false;
const listener = () => {
emitted = true;
};
server.on("blockReceived", listener);
const status = await postJson(
`${server.getAddress()}/other-path`,
JSON.stringify({ foo: "bar" }),
);
assert.strictEqual(status, 200);
await new Promise((resolve) => setTimeout(resolve, 50));
assert.strictEqual(emitted, false, "Should not emit blockReceived for non-block paths");
server.off("blockReceived", listener);
});
});
});
describe("reportingServer stop", () => {
it("stops cleanly and frees the port", async () => {
const server = getReportingServer();
await server.start();
const address = server.getAddress();
assert.ok(address, "Server should have an address");
await server.stop();
// After stopping, the server should no longer accept connections
try {
await fetch(`${address}/events/block`);
assert.fail("Should not be able to connect to stopped server");
} catch (err) {
// Expected: connection refused or similar
assert.ok(err, "Fetch should throw after server stops");
}
});
it("stop is safe to call when server was never started", async () => {
const server = getReportingServer();
// Should not throw
await server.stop();
});
});

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)
@ -111,6 +111,9 @@ describe("registryProxy.connectTunnel", () => {
describe("Error Handling", () => {
it("should return 502 Bad Gateway for invalid hostname", async () => {
// We need to make sure we're not running behind an existing safe-chain installation to allow this test to work
const https_proxy = process.env.HTTPS_PROXY;
delete process.env.HTTPS_PROXY;
const socket = await connectToProxy(proxyHost, proxyPort);
const connectRequest = `CONNECT invalid.hostname.that.does.not.exist:443 HTTP/1.1\r\nHost: invalid.hostname.that.does.not.exist:443\r\n\r\n`;
socket.write(connectRequest);
@ -123,8 +126,11 @@ describe("registryProxy.connectTunnel", () => {
});
});
assert.ok(responseData.includes("HTTP/1.1 502 Bad Gateway"));
assert.ok(responseData.includes("HTTP/1.1 502 Bad Gateway"), responseData);
socket.destroy();
if (https_proxy) {
process.env.HTTPS_PROXY = https_proxy;
}
});
it("should handle client disconnect during tunnel establishment", async () => {
@ -185,13 +191,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,36 +1,74 @@
import * as http from "http";
import { tunnelRequest } from "./tunnelRequestHandler.js";
import { mitmConnect } from "./mitmRequestHandler.js";
import { handleHttpProxyRequest } from "./plainHttpProxy.js";
import { getCombinedCaBundlePath, cleanupCertBundle } from "./certBundle.js";
import { ui } from "../environment/userInteraction.js";
import chalk from "chalk";
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
import { getHasSuppressedVersions } from "./interceptors/suppressedVersionsState.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}[],
* blockedMinimumAgeRequests: {packageName: string, version: string, url: string}[]
* }}
* @typedef {Object} PackageBlockedEvent
* @prop {string} packageName
* @prop {string} packageVersion
*
* @typedef {Object} MinPackageAgeSuppressionEvent
* @prop {string} packageName
* @prop {string[]} packageVersions
*
* @typedef {{
* malwareBlocked: [PackageBlockedEvent],
* minimumAgeRequestBlocked: [PackageBlockedEvent]
* minPackageAgeVersionsSuppressed: [MinPackageAgeSuppressionEvent]
* }} ProxyServerEvents
*
* @import { EventEmitter } from "node:stream"
* @typedef {EventEmitter<ProxyServerEvents> & {
* startServer: () => Promise<void>
* stopServer: () => Promise<void>
* getServerPort: () => Number | null
* getCaCert: () => string | null
* }} SafeChainProxy
*
* @typedef {Object} ProxySettings
* @prop {string | null} proxyUrl
* @prop {string} caCertBundlePath
*/
const state = {
port: null,
blockedRequests: [],
blockedMinimumAgeRequests: [],
};
/** @type {SafeChainProxy} */
let server;
export function createSafeChainProxy() {
const server = createProxyServer();
if (server) {
return server;
}
let ramaPath = getRamaPath();
if (ramaPath) {
ui.writeVerbose("Starting safe-chain rama proxy");
server = createRamaProxy(ramaPath);
} else {
ui.writeVerbose("Starting built-in proxy");
server = createBuiltInProxyServer();
}
return server;
}
/**
* @returns {ProxySettings}
*/
export function getProxySettings() {
if (!server || !server.getServerPort()) {
return {
proxyUrl: null,
caCertBundlePath: getCombinedCaBundlePath(null),
};
}
const proxyUrl = `http://127.0.0.1:${server.getServerPort()}`;
const caCert = server.getCaCert();
const caCertBundlePath = getCombinedCaBundlePath(caCert);
return {
startServer: () => startServer(server),
stopServer: () => stopServer(server),
hasBlockedMaliciousPackages,
hasBlockedMinimumAgeRequests,
hasSuppressedVersions: getHasSuppressedVersions,
proxyUrl,
caCertBundlePath,
};
}
@ -38,17 +76,16 @@ export function createSafeChainProxy() {
* @returns {Record<string, string>}
*/
function getSafeChainProxyEnvironmentVariables() {
if (!state.port) {
if (!server || !server.getServerPort()) {
return {};
}
const proxyUrl = `http://127.0.0.1:${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,
};
}
@ -73,186 +110,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) => {
// Bind to loopback only. Without an explicit host, Node listens on every
// interface, turning the proxy into an unauthenticated forward proxy that
// anyone reachable on the network can use to hit the victim's localhost,
// intranet, or cloud metadata endpoints. Port 0 lets the OS pick a port.
server.listen(0, "127.0.0.1", () => {
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(() => {
cleanupCertBundle();
resolve();
});
} catch {
resolve();
}
setTimeout(() => {
cleanupCertBundle();
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);
}
);
interceptor.on(
"minimumAgeRequestBlocked",
(
/** @type {import("./interceptors/interceptorBuilder.js").MinimumAgeRequestBlockedEvent} */ event
) => {
onMinimumAgeRequestBlocked(
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 });
}
/**
*
* @param {string} packageName
* @param {string} version
* @param {string} url
*/
function onMinimumAgeRequestBlocked(packageName, version, url) {
state.blockedMinimumAgeRequests.push({ packageName, version, url });
}
function hasBlockedMaliciousPackages() {
if (state.blockedRequests.length === 0) {
return false;
}
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 true;
}
function hasBlockedMinimumAgeRequests() {
if (state.blockedMinimumAgeRequests.length === 0) {
return false;
}
ui.emptyLine();
ui.writeInformation(
`Safe-chain: ${chalk.bold(
`blocked ${state.blockedMinimumAgeRequests.length} direct package download request(s) due to minimum package age`
)}:`
);
for (const req of state.blockedMinimumAgeRequests) {
ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`);
}
ui.writeInformation(
` To disable this check, use: ${chalk.cyan(
"--safe-chain-skip-minimum-package-age"
)}`
);
ui.emptyLine();
ui.writeError(
"Safe-chain: Exiting without installing packages blocked by the direct download minimum package age check."
);
ui.emptyLine();
return true;
}

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,