diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 7ee07c2..5d00bdb 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -317,6 +317,22 @@ main() { info "Binary installed to: $FINAL_FILE" + # Download safechain-proxy for macOS only + if [ "$OS" = "macos" ]; then + info "Downloading safechain-proxy..." + + if [ "$ARCH" = "arm64" ]; then + PROXY_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v0.2.0/safechain-proxy-darwin-arm64" + else + PROXY_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v0.2.0/safechain-proxy-darwin-amd64" + fi + + 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 + # Build setup command based on arguments SETUP_CMD="setup" SETUP_ARGS="" diff --git a/packages/safe-chain/src/registryProxy/createRamaProxy.js b/packages/safe-chain/src/registryProxy/createRamaProxy.js new file mode 100644 index 0000000..90742ad --- /dev/null +++ b/packages/safe-chain/src/registryProxy/createRamaProxy.js @@ -0,0 +1,102 @@ +import { ChildProcess, spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtempSync, readFile, writeFile } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { ui } from "../environment/userInteraction.js"; + +const readFilePromise = promisify(readFile); +const writeFilePromise = promisify(writeFile); + +/** + * @typedef {Object} RamaProxyInstance + * @property {ChildProcess} process + * @property {string} proxyAddress + * @property {string} metaAddress + * @property {string} certPath + */ + +/** + * @returns {String | null} + */ +export function getRamaPath() { + const executableDir = dirname(process.execPath); + const ramaPath = join(executableDir, "safechain-proxy"); + + ui.writeWarning(ramaPath); + + 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.writeInformation( + `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; + }, + getCombinedCaBundlePath: () => ramaInstance?.certPath ?? "", + }; +} + +/** + * @param {string} ramaPath + * @param {string} dataFolder + * @returns {Promise} + */ +async function startRama(ramaPath, dataFolder) { + let process = spawn(ramaPath, ["--secrets", "memory", "--data", dataFolder], { + stdio: "inherit", + }); + + // wait some time to allow the proxy process to start + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const proxyAddress = await readFilePromise( + join(dataFolder, "proxy.addr.txt"), + "utf-8" + ); + const metaAddress = await readFilePromise( + join(dataFolder, "meta.addr.txt"), + "utf-8" + ); + + const certResponse = await fetch(`http://${metaAddress}/ca`); + const cert = await certResponse.text(); + const certPath = join(dataFolder, "cert.ca"); + await writeFilePromise(certPath, cert); + + return { + process, + proxyAddress, + metaAddress, + certPath, + }; +} diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 47ec256..3f67e6f 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -7,37 +7,47 @@ import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; +import { createRamaProxy, getRamaPath } from "./createRamaProxy.js"; -const SERVER_STOP_TIMEOUT_MS = 1000; /** - * @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}} + * @typedef {Object} SafeChainProxy + * @prop {() => Promise} startServer + * @prop {() => Promise} stopServer + * @prop {() => boolean} verifyNoMaliciousPackages + * @prop {() => boolean} hasSuppressedVersions + * @prop {() => Number | null} getServerPort + * @prop {() => string} getCombinedCaBundlePath */ -const state = { - port: null, - blockedRequests: [], -}; + +/** @type {SafeChainProxy} */ +let server; export function createSafeChainProxy() { - const server = createProxyServer(); + if (server) { + return server; + } - return { - startServer: () => startServer(server), - stopServer: () => stopServer(server), - verifyNoMaliciousPackages, - hasSuppressedVersions: getHasSuppressedVersions, - }; + let ramaPath = getRamaPath(); + if (ramaPath) { + ui.writeInformation("Starting safe-chain rama proxy"); + server = createRamaProxy(ramaPath); + } else { + server = createBuiltInProxyServer(); + } + + return server; } /** * @returns {Record} */ function getSafeChainProxyEnvironmentVariables() { - if (!state.port) { + if (!server || !server.getServerPort()) { return {}; } - const proxyUrl = `http://localhost:${state.port}`; - const caCertPath = getCombinedCaBundlePath(); + const proxyUrl = `http://localhost:${server.getServerPort()}`; + const caCertPath = server.getCombinedCaBundlePath(); return { HTTPS_PROXY: proxyUrl, @@ -68,7 +78,17 @@ export function mergeSafeChainProxyEnvironmentVariables(env) { return proxyEnv; } -function createProxyServer() { +/** @returns {SafeChainProxy} */ +function createBuiltInProxyServer() { + const SERVER_STOP_TIMEOUT_MS = 1000; + /** + * @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}} + */ + const state = { + port: null, + blockedRequests: [], + }; + const server = http.createServer( // This handles direct HTTP requests (non-CONNECT requests) // This is normally http-only traffic, but we also handle @@ -79,114 +99,121 @@ function createProxyServer() { // This handles HTTPS requests via the CONNECT method server.on("connect", handleConnect); - return server; -} + return { + startServer: () => startServer(server), + stopServer: () => stopServer(server), + verifyNoMaliciousPackages, + hasSuppressedVersions: getHasSuppressedVersions, + getServerPort: () => state.port, + getCombinedCaBundlePath, + }; -/** - * @param {import("http").Server} server - * - * @returns {Promise} - */ -function startServer(server) { - return new Promise((resolve, reject) => { - // Passing port 0 makes the OS assign an available port - server.listen(0, () => { - const address = server.address(); - if (address && typeof address === "object") { - state.port = address.port; - resolve(); - } else { - reject(new Error("Failed to start proxy server")); - } - }); - - server.on("error", (err) => { - reject(err); - }); - }); -} - -/** - * @param {import("http").Server} server - * - * @returns {Promise} - */ -function stopServer(server) { - return new Promise((resolve) => { - try { - server.close(() => { - resolve(); + /** + * @param {import("http").Server} server + * + * @returns {Promise} + */ + function startServer(server) { + return new Promise((resolve, reject) => { + // Passing port 0 makes the OS assign an available port + server.listen(0, () => { + const address = server.address(); + if (address && typeof address === "object") { + state.port = address.port; + resolve(); + } else { + reject(new Error("Failed to start proxy server")); + } }); - } 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 + server.on("error", (err) => { + reject(err); + }); + }); + } - 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); + /** + * @param {import("http").Server} server + * + * @returns {Promise} + */ + function stopServer(server) { + return new Promise((resolve) => { + try { + server.close(() => { + resolve(); + }); + } catch { + resolve(); } + setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS); + }); + } + + /** + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} clientSocket + * @param {Buffer} head + * + * @returns {void} + */ + function handleConnect(req, clientSocket, head) { + // CONNECT method is used for HTTPS requests + // It establishes a tunnel to the server identified by the request URL + + const interceptor = createInterceptorForUrl(req.url || ""); + + if (interceptor) { + // Subscribe to malware blocked events + interceptor.on( + "malwareBlocked", + ( + /** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event + ) => { + onMalwareBlocked(event.packageName, event.version, event.targetUrl); + } + ); + + mitmConnect(req, clientSocket, interceptor); + } else { + // For other hosts, just tunnel the request to the destination tcp socket + ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`); + tunnelRequest(req, clientSocket, head); + } + } + + /** + * + * @param {string} packageName + * @param {string} version + * @param {string} url + */ + function onMalwareBlocked(packageName, version, url) { + state.blockedRequests.push({ packageName, version, url }); + } + + function verifyNoMaliciousPackages() { + if (state.blockedRequests.length === 0) { + // No malicious packages were blocked, so nothing to block + return true; + } + + ui.emptyLine(); + + ui.writeInformation( + `Safe-chain: ${chalk.bold( + `blocked ${state.blockedRequests.length} malicious package downloads` + )}:` ); - 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); + for (const req of state.blockedRequests) { + ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`); + } + + ui.emptyLine(); + ui.writeExitWithoutInstallingMaliciousPackages(); + ui.emptyLine(); + + return false; } } - -/** - * - * @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; -}