From e2afcb16e34dbfe4912b112ed3a194ab39f59ede Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 30 Sep 2025 13:52:21 +0200 Subject: [PATCH] Implement a proxy blocking tarball requests for packages containing malware. --- package-lock.json | 10 ++ packages/safe-chain/package.json | 1 + packages/safe-chain/src/main.js | 30 ++++- .../npm/dependencyScanner/dryRunScanner.js | 7 +- .../dependencyScanner/dryRunScanner.spec.js | 14 +-- .../src/packagemanager/npm/runNpmCommand.js | 36 ++++-- .../src/packagemanager/pnpm/runPnpmCommand.js | 15 ++- .../src/packagemanager/yarn/runYarnCommand.js | 46 ++++++- .../safe-chain/src/registryProxy/certUtils.js | 114 +++++++++++++++++ .../src/registryProxy/mitmRequestHandler.js | 76 +++++++++++ .../src/registryProxy/parsePackageFromUrl.js | 48 +++++++ .../registryProxy/parsePackageFromUrl.spec.js | 114 +++++++++++++++++ .../src/registryProxy/registryProxy.js | 119 ++++++++++++++++++ .../src/registryProxy/tunnelRequestHandler.js | 20 +++ packages/safe-chain/src/utils/safeSpawn.js | 14 ++- test/e2e/package.json | 2 +- 16 files changed, 633 insertions(+), 33 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/certUtils.js create mode 100644 packages/safe-chain/src/registryProxy/mitmRequestHandler.js create mode 100644 packages/safe-chain/src/registryProxy/parsePackageFromUrl.js create mode 100644 packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js create mode 100644 packages/safe-chain/src/registryProxy/registryProxy.js create mode 100644 packages/safe-chain/src/registryProxy/tunnelRequestHandler.js diff --git a/package-lock.json b/package-lock.json index 4840448..f111431 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3323,6 +3323,15 @@ "node": ">= 0.6" } }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-pty": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", @@ -4878,6 +4887,7 @@ "abbrev": "3.0.1", "chalk": "5.4.1", "make-fetch-happen": "14.0.3", + "node-forge": "1.3.1", "npm-registry-fetch": "18.0.2", "ora": "8.2.0", "semver": "7.7.2" diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index 32228e7..c74d44e 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -31,6 +31,7 @@ "abbrev": "3.0.1", "chalk": "5.4.1", "make-fetch-happen": "14.0.3", + "node-forge": "1.3.1", "npm-registry-fetch": "18.0.2", "ora": "8.2.0", "semver": "7.7.2" diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 81b1b3a..9dd61d6 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -4,8 +4,13 @@ import { scanCommand, shouldScanCommand } from "./scanning/index.js"; import { ui } from "./environment/userInteraction.js"; import { getPackageManager } from "./packagemanager/currentPackageManager.js"; import { initializeCliArguments } from "./config/cliArguments.js"; +import { createSafeChainProxy } from "./registryProxy/registryProxy.js"; +import chalk from "chalk"; export async function main(args) { + const proxy = createSafeChainProxy(); + await proxy.startServer(); + try { // This parses all the --safe-chain arguments and removes them from the args array args = initializeCliArguments(args); @@ -18,6 +23,29 @@ export async function main(args) { process.exit(1); } - var result = getPackageManager().runCommand(args); + var result = await getPackageManager().runCommand(args); + + await proxy.stopServer(); + const blockedRequests = proxy.getBlockedRequests(); + if (blockedRequests.length > 0) { + ui.emptyLine(); + + ui.writeInformation( + `Safe-chain: ${chalk.bold( + `blocked ${blockedRequests.length} malicious package downloads` + )}:` + ); + + for (const req of blockedRequests) { + ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`); + } + + ui.emptyLine(); + ui.writeError("Exiting without installing malicious packages."); + ui.emptyLine(); + + process.exit(1); + } + process.exit(result.status); } diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js index 0db23cb..59cd236 100644 --- a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js +++ b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js @@ -8,7 +8,8 @@ export function dryRunScanner(scannerOptions) { shouldScan: (args) => shouldScanDependencies(scannerOptions, args), }; } -function scanDependencies(scannerOptions, args) { + +async function scanDependencies(scannerOptions, args) { let dryRunArgs = args; if (scannerOptions?.dryRunCommand) { @@ -31,8 +32,8 @@ function shouldScanDependencies(scannerOptions, args) { return true; } -function checkChangesWithDryRun(args) { - const dryRunOutput = dryRunNpmCommandAndOutput(args); +async function checkChangesWithDryRun(args) { + const dryRunOutput = await dryRunNpmCommandAndOutput(args); // Dry-run can return a non-zero status code in some cases // e.g., when running "npm audit fix --dry-run", it returns exit code 1 diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js index 4fb6272..88d7681 100644 --- a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js +++ b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js @@ -36,7 +36,7 @@ describe("dryRunScanner", async () => { })); const scanner = dryRunScanner(); - const result = scanner.scan(["audit", "fix"]); + const result = await scanner.scan(["audit", "fix"]); // Should not throw an error for audit fix commands assert.ok(Array.isArray(result)); @@ -53,8 +53,8 @@ describe("dryRunScanner", async () => { const scanner = dryRunScanner(); - assert.throws(() => { - scanner.scan(["install", "lodash"]); + await assert.rejects(async () => { + await scanner.scan(["install", "lodash"]); }, /Dry-run command failed with exit code 1/); }); @@ -67,7 +67,7 @@ describe("dryRunScanner", async () => { })); const scanner = dryRunScanner(); - const result = scanner.scan(["install", "lodash"]); + const result = await scanner.scan(["install", "lodash"]); assert.ok(Array.isArray(result)); assert.equal(mockWriteError.mock.callCount(), 0); @@ -83,8 +83,8 @@ describe("dryRunScanner", async () => { const scanner = dryRunScanner(); - assert.throws(() => { - scanner.scan(["audit", "fix"]); + await assert.rejects(async () => { + await scanner.scan(["audit", "fix"]); }, /Dry-run command failed with exit code 1/); }); }); @@ -99,7 +99,7 @@ describe("dryRunScanner", async () => { })); const scanner = dryRunScanner({ dryRunCommand: "install" }); - scanner.scan(["install-test", "lodash"]); + await scanner.scan(["install-test", "lodash"]); // Should call with "install" instead of "install-test" assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 1); diff --git a/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js b/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js index 70a0d17..26a4a9d 100644 --- a/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js +++ b/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js @@ -1,10 +1,14 @@ -import { execSync } from "child_process"; import { ui } from "../../environment/userInteraction.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -export function runNpm(args) { +export async function runNpm(args) { try { - const npmCommand = `npm ${args.join(" ")}`; - execSync(npmCommand, { stdio: "inherit" }); + const result = await safeSpawn("npm", args, { + stdio: "inherit", + env: mergeSafeChainProxyEnvironmentVariables(process.env), + }); + return { status: result.status }; } catch (error) { if (error.status) { return { status: error.status }; @@ -13,17 +17,29 @@ export function runNpm(args) { return { status: 1 }; } } - return { status: 0 }; } -export function dryRunNpmCommandAndOutput(args) { +export async function dryRunNpmCommandAndOutput(args) { try { - const npmCommand = `npm ${args.join(" ")} --ignore-scripts --dry-run`; - const output = execSync(npmCommand, { stdio: "pipe" }); - return { status: 0, output: output.toString() }; + const result = await safeSpawn( + "npm", + [...args, "--ignore-scripts", "--dry-run"], + { + stdio: "pipe", + env: mergeSafeChainProxyEnvironmentVariables(process.env), + } + ); + return { + status: result.status, + output: result.status === 0 ? result.stdout : result.stderr, + }; } catch (error) { if (error.status) { - const output = error.stdout ? error.stdout.toString() : ""; + const output = + error.stdout?.toString() ?? + error.stderr?.toString() ?? + error.message ?? + ""; return { status: error.status, output }; } else { ui.writeError("Error executing command:", error.message); diff --git a/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js b/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js index 0d5133f..794d6e3 100644 --- a/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js +++ b/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js @@ -1,13 +1,20 @@ import { ui } from "../../environment/userInteraction.js"; -import { safeSpawnSync } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; -export function runPnpmCommand(args, toolName = "pnpm") { +export async function runPnpmCommand(args, toolName = "pnpm") { try { let result; if (toolName === "pnpm") { - result = safeSpawnSync("pnpm", args, { stdio: "inherit" }); + result = await safeSpawn("pnpm", args, { + stdio: "inherit", + env: mergeSafeChainProxyEnvironmentVariables(process.env), + }); } else if (toolName === "pnpx") { - result = safeSpawnSync("pnpx", args, { stdio: "inherit" }); + result = await safeSpawn("pnpx", args, { + stdio: "inherit", + env: mergeSafeChainProxyEnvironmentVariables(process.env), + }); } else { throw new Error(`Unsupported tool name for aikido-pnpm: ${toolName}`); } diff --git a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js index c89c804..2c3795c 100644 --- a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js +++ b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js @@ -1,10 +1,17 @@ -import { execSync } from "child_process"; import { ui } from "../../environment/userInteraction.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -export function runYarnCommand(args) { +export async function runYarnCommand(args) { try { - const npxCommand = `yarn ${args.join(" ")}`; - execSync(npxCommand, { stdio: "inherit" }); + const env = mergeSafeChainProxyEnvironmentVariables(process.env); + await fixYarnProxyEnvironmentVariables(env); + + const result = await safeSpawn("yarn", args, { + stdio: "inherit", + env, + }); + return { status: result.status }; } catch (error) { if (error.status) { return { status: error.status }; @@ -13,5 +20,34 @@ export function runYarnCommand(args) { return { status: 1 }; } } - return { status: 0 }; +} + +async function fixYarnProxyEnvironmentVariables(env) { + // Yarn ignores standard proxy environment variables HTTPS_PROXY and NODE_EXTRA_CA_CERTS + + // Yarn v2/v3 and v4+ use different environment variables for proxy and CA certs + // When setting all variables, yarn returns an error about conflicting variables + // - v2/v3: "Usage Error: Unrecognized or legacy configuration settings found: httpsCaFilePath" + // - v4+: "Usage Error: Unrecognized or legacy configuration settings found: caFilePath" + + const version = await yarnVersion(); + const majorVersion = parseInt(version.split(".")[0]); + + if (majorVersion >= 4) { + env.YARN_HTTPS_PROXY = env.HTTPS_PROXY; + env.YARN_HTTPS_CA_FILE_PATH = env.NODE_EXTRA_CA_CERTS; + } else if (majorVersion === 2 || majorVersion === 3) { + env.YARN_HTTPS_PROXY = env.HTTPS_PROXY; + env.YARN_CA_FILE_PATH = env.NODE_EXTRA_CA_CERTS; + } +} + +async function yarnVersion() { + const result = await safeSpawn("yarn", ["--version"], { + stdio: "pipe", + }); + if (result.status !== 0) { + throw new Error("Failed to get yarn version"); + } + return result.stdout.trim(); } diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js new file mode 100644 index 0000000..99789f6 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -0,0 +1,114 @@ +import forge from "node-forge"; +import path from "path"; +import fs from "fs"; +import os from "os"; + +const certFolder = path.join(os.homedir(), ".safe-chain", "certs"); +const ca = loadCa(); + +const certCache = new Map(); + +export function getCaCertPath() { + return path.join(certFolder, "ca-cert.pem"); +} + +export function generateCertForHost(hostname) { + let existingCert = certCache.get(hostname); + if (existingCert) { + return existingCert; + } + + const keys = forge.pki.rsa.generateKeyPair(2048); + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = "01"; + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + 1); + + const attrs = [{ name: "commonName", value: hostname }]; + cert.setSubject(attrs); + cert.setIssuer(ca.certificate.subject.attributes); + cert.setExtensions([ + { + name: "subjectAltName", + altNames: [ + { + type: 2, // DNS + value: hostname, + }, + ], + }, + { + name: "keyUsage", + digitalSignature: true, + keyEncipherment: true, + }, + ]); + cert.sign(ca.privateKey, forge.md.sha256.create()); + + const result = { + privateKey: forge.pki.privateKeyToPem(keys.privateKey), + certificate: forge.pki.certificateToPem(cert), + }; + + certCache.set(hostname, result); + + return result; +} + +function loadCa() { + const keyPath = path.join(certFolder, "ca-key.pem"); + const certPath = path.join(certFolder, "ca-cert.pem"); + + if (fs.existsSync(keyPath) && fs.existsSync(certPath)) { + const privateKeyPem = fs.readFileSync(keyPath, "utf8"); + const certPem = fs.readFileSync(certPath, "utf8"); + const privateKey = forge.pki.privateKeyFromPem(privateKeyPem); + const certificate = forge.pki.certificateFromPem(certPem); + + // Don't return a cert that is valid for less than 1 hour + const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000); + if (certificate.validity.notAfter > oneHourFromNow) { + return { privateKey, certificate }; + } + } + + const { privateKey, certificate } = generateCa(); + fs.mkdirSync(certFolder, { recursive: true }); + fs.writeFileSync(keyPath, forge.pki.privateKeyToPem(privateKey)); + fs.writeFileSync(certPath, forge.pki.certificateToPem(certificate)); + return { privateKey, certificate }; +} + +function generateCa() { + const keys = forge.pki.rsa.generateKeyPair(2048); + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = "01"; + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + 1); + + const attrs = [{ name: "commonName", value: "safe-chain proxy" }]; + cert.setSubject(attrs); + cert.setIssuer(attrs); + cert.setExtensions([ + { + name: "basicConstraints", + cA: true, + }, + { + name: "keyUsage", + keyCertSign: true, + digitalSignature: true, + keyEncipherment: true, + }, + ]); + cert.sign(keys.privateKey, forge.md.sha256.create()); + + return { + privateKey: keys.privateKey, + certificate: cert, + }; +} diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js new file mode 100644 index 0000000..3958a70 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -0,0 +1,76 @@ +import https from "https"; +import { generateCertForHost } from "./certUtils.js"; +import chalk from "chalk"; + +export function mitmConnect(req, clientSocket, isAllowed) { + const { hostname } = new URL(`http://${req.url}`); + + const server = createHttpsServer(hostname, isAllowed); + + // Establish the connection + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + + // Hand off the socket to the HTTPS server + server.emit("connection", clientSocket); +} + +function createHttpsServer(hostname, isAllowed) { + const cert = generateCertForHost(hostname); + + async function handleRequest(req, res) { + const targetUrl = `https://${hostname}${req.url}`; + + if (!(await isAllowed(targetUrl))) { + res.writeHead(403, "Forbidden - blocked by safe-chain"); + res.end("Blocked by safe-chain"); + return; + } + + // Collect request body + forwardRequest(req, hostname, res); + } + + return https.createServer( + { + key: cert.privateKey, + cert: cert.certificate, + }, + handleRequest + ); +} + +function forwardRequest(req, hostname, res) { + const proxyReq = createProxyRequest(hostname, req, res); + + proxyReq.on("error", () => { + res.writeHead(502); + res.end("Bad Gateway"); + }); + + req.on("data", (chunk) => { + proxyReq.write(chunk); + }); + + req.on("end", () => { + proxyReq.end(); + }); +} + +function createProxyRequest(hostname, req, res) { + const options = { + hostname: hostname, + port: 443, + path: req.url, + method: req.method, + headers: { ...req.headers }, + }; + + delete options.headers.host; + + const proxyReq = https.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + }); + + return proxyReq; +} diff --git a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js new file mode 100644 index 0000000..7368b35 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js @@ -0,0 +1,48 @@ +export const knownRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; + +export function parsePackageFromUrl(url) { + let packageName, version, registry; + + for (const knownRegistry of knownRegistries) { + if (url.includes(knownRegistry)) { + registry = knownRegistry; + break; + } + } + + if (!registry || !url.endsWith(".tgz")) { + return { packageName, version }; + } + + const registryIndex = url.indexOf(registry); + const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash + + const separatorIndex = afterRegistry.indexOf("/-/"); + if (separatorIndex === -1) { + return { packageName, version }; + } + + packageName = afterRegistry.substring(0, separatorIndex); + const filename = afterRegistry.substring( + separatorIndex + 3, + afterRegistry.length - 4 + ); // Remove /-/ and .tgz + + // Extract version from filename + // For scoped packages like @babel/core, the filename is core-7.21.4.tgz + // For regular packages like lodash, the filename is lodash-4.17.21.tgz + if (packageName.startsWith("@")) { + const scopedPackageName = packageName.substring( + packageName.lastIndexOf("/") + 1 + ); + if (filename.startsWith(scopedPackageName + "-")) { + version = filename.substring(scopedPackageName.length + 1); + } + } else { + if (filename.startsWith(packageName + "-")) { + version = filename.substring(packageName.length + 1); + } + } + + return { packageName, version }; +} diff --git a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js new file mode 100644 index 0000000..0b8f700 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js @@ -0,0 +1,114 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { parsePackageFromUrl } from "./parsePackageFromUrl.js"; + +describe("parsePackageFromUrl", () => { + const testCases = [ + // Regular packages + { + url: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + expected: { packageName: "lodash", version: "4.17.21" }, + }, + { + url: "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + expected: { packageName: "express", version: "4.18.2" }, + }, + // Packages with hyphens in name + { + url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-1.0.0.tgz", + expected: { packageName: "safe-chain-test", version: "1.0.0" }, + }, + { + url: "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.0.tgz", + expected: { packageName: "web-vitals", version: "3.5.0" }, + }, + // Preview/prerelease versions + { + url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-0.0.1-security.tgz", + expected: { packageName: "safe-chain-test", version: "0.0.1-security" }, + }, + { + url: "https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz", + expected: { packageName: "lodash", version: "5.0.0-beta.1" }, + }, + { + url: "https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz", + expected: { packageName: "react", version: "18.3.0-canary-abc123" }, + }, + // Scoped packages + { + url: "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz", + expected: { packageName: "@babel/core", version: "7.21.4" }, + }, + { + url: "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", + expected: { packageName: "@types/node", version: "20.10.5" }, + }, + { + url: "https://registry.npmjs.org/@angular/common/-/common-17.0.8.tgz", + expected: { packageName: "@angular/common", version: "17.0.8" }, + }, + // Scoped packages with hyphens + { + url: "https://registry.npmjs.org/@safe-chain/test-package/-/test-package-2.1.0.tgz", + expected: { packageName: "@safe-chain/test-package", version: "2.1.0" }, + }, + { + url: "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.465.0.tgz", + expected: { packageName: "@aws-sdk/client-s3", version: "3.465.0" }, + }, + // Scoped packages with preview versions + { + url: "https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz", + expected: { packageName: "@babel/core", version: "8.0.0-alpha.1" }, + }, + { + url: "https://registry.npmjs.org/@safe-chain/security-test/-/security-test-1.0.0-security.tgz", + expected: { + packageName: "@safe-chain/security-test", + version: "1.0.0-security", + }, + }, + // Yarn registry + { + url: "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz", + expected: { packageName: "lodash", version: "4.17.21" }, + }, + { + url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz", + expected: { packageName: "@babel/core", version: "7.21.4" }, + }, + // Invalid URLs should return undefined values + { + url: "https://example.com/package.tgz", + expected: { packageName: undefined, version: undefined }, + }, + // URL to get package info, not tarball + { + url: "https://registry.npmjs.org/lodash", + expected: { packageName: undefined, version: undefined }, + }, + // Complex version patterns + { + url: "https://registry.npmjs.org/package-with-many-hyphens/-/package-with-many-hyphens-1.0.0-rc.1+build.123.tgz", + expected: { + packageName: "package-with-many-hyphens", + version: "1.0.0-rc.1+build.123", + }, + }, + { + url: "https://registry.npmjs.org/@scope/package-name-with-hyphens/-/package-name-with-hyphens-2.0.0-beta.2.tgz", + expected: { + packageName: "@scope/package-name-with-hyphens", + version: "2.0.0-beta.2", + }, + }, + ]; + + testCases.forEach(({ url, expected }, index) => { + it(`should parse URL ${index + 1}: ${url}`, () => { + const result = parsePackageFromUrl(url); + assert.deepEqual(result, expected); + }); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js new file mode 100644 index 0000000..fb83c0e --- /dev/null +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -0,0 +1,119 @@ +import * as http from "http"; +import { tunnelRequest } from "./tunnelRequestHandler.js"; +import { mitmConnect } from "./mitmRequestHandler.js"; +import { getCaCertPath } from "./certUtils.js"; +import { auditChanges } from "../scanning/audit/index.js"; +import { knownRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js"; + +const state = { + port: null, + blockedRequests: [], +}; + +export function createSafeChainProxy() { + const server = createProxyServer(); + server.on("connect", handleConnect); + + return { + startServer: () => startServer(server), + stopServer: () => stopServer(server), + getBlockedRequests: () => state.blockedRequests, + }; +} + +function getSafeChainProxyEnvironmentVariables() { + return { + HTTPS_PROXY: `http://localhost:${state.port}`, + GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`, + NODE_EXTRA_CA_CERTS: getCaCertPath(), + }; +} + +export function mergeSafeChainProxyEnvironmentVariables(env) { + const proxyEnv = getSafeChainProxyEnvironmentVariables(); + + for (const key of Object.keys(env)) { + // If we were to simply copy all env variables, we might overwrite + // the proxy settings set by safe-chain when casing varies (e.g. http_proxy vs HTTP_PROXY) + // So we only copy the variable if it's not already set in a different case + const upperKey = key.toUpperCase(); + + if (!proxyEnv[upperKey]) { + proxyEnv[upperKey] = env[key]; + } + } + + return proxyEnv; +} + +function createProxyServer() { + const server = http.createServer((_, res) => { + res.writeHead(400, "Bad Request"); + res.write( + "Safe-chain proxy: Direct http not supported. Only CONNECT requests are allowed." + ); + res.end(); + }); + + return server; +} + +function startServer(server) { + return new Promise((resolve, reject) => { + 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); + }); + }); +} + +function stopServer(server) { + return new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); +} + +function handleConnect(req, clientSocket, head) { + // CONNECT method is used for HTTPS requests + // It establishes a tunnel to the server identified by the request URL + + if (knownRegistries.some((reg) => req.url.includes(reg))) { + // For npm and yarn registries, we want to intercept and inspect the traffic + // so we can block packages with malware + mitmConnect(req, clientSocket, isAllowedUrl); + } else { + // For other hosts, just tunnel the request to the destination tcp socket + tunnelRequest(req, clientSocket, head); + } +} + +async function isAllowedUrl(url) { + const { packageName, version } = parsePackageFromUrl(url); + + // This happens when the URL is not a tarball download url. + if (!packageName || !version) { + return true; + } + + const auditResult = await auditChanges([ + { name: packageName, version, type: "add" }, + ]); + + if (!auditResult.isAllowed) { + state.blockedRequests.push({ packageName, version, url }); + return false; + } + + return true; +} diff --git a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js new file mode 100644 index 0000000..8579729 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js @@ -0,0 +1,20 @@ +import * as net from "net"; +import { ui } from "../environment/userInteraction.js"; + +export function tunnelRequest(req, clientSocket, head) { + const { port, hostname } = new URL(`http://${req.url}`); + + const serverSocket = net.connect(port || 443, hostname, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + serverSocket.write(head); + serverSocket.pipe(clientSocket); + clientSocket.pipe(serverSocket); + }); + + serverSocket.on("error", (err) => { + ui.writeError( + `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}` + ); + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + }); +} diff --git a/packages/safe-chain/src/utils/safeSpawn.js b/packages/safe-chain/src/utils/safeSpawn.js index 826ab7d..5af4740 100644 --- a/packages/safe-chain/src/utils/safeSpawn.js +++ b/packages/safe-chain/src/utils/safeSpawn.js @@ -22,12 +22,22 @@ export async function safeSpawn(command, args, options = {}) { const fullCommand = buildCommand(command, args); return new Promise((resolve, reject) => { const child = spawn(fullCommand, { ...options, shell: true }); + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); child.on("close", (code) => { resolve({ status: code, - stdout: Buffer.from(""), - stderr: Buffer.from(""), + stdout: stdout, + stderr: stderr, }); }); diff --git a/test/e2e/package.json b/test/e2e/package.json index 1dc6e8a..9217808 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "description": "End-to-end tests for the Aikido Safe Chain", "scripts": { - "test": "node --test **/*.spec.js" + "test": "node --test --test-concurrency=1 **/*.spec.js" }, "keywords": [], "author": "Aikido Security",