diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index 740d741..7b93768 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -46,7 +46,7 @@ jobs: include: # Common production setup - node_version: "20" - npm_version: "10.0.0" + npm_version: "10.2.0" yarn_version: "4.0.0" pnpm_version: "9.0.0" # Current Active LTS with latest tools @@ -66,7 +66,7 @@ jobs: pnpm_version: "latest" # Version pinning scenario - node_version: "22" - npm_version: "10.0.0" + npm_version: "10.2.0" yarn_version: "4.0.0" pnpm_version: "9.0.0" # Backward compatibility testing @@ -82,7 +82,7 @@ jobs: # EOL compatibility testing - Node 16 (EOL Sept 2023) - node_version: "16" npm_version: "8.0.0" - yarn_version: "3.6.0" + yarn_version: "1.22.0" pnpm_version: "8.0.0" steps: diff --git a/package-lock.json b/package-lock.json index 4840448..6b74d53 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", @@ -4877,7 +4886,9 @@ "dependencies": { "abbrev": "3.0.1", "chalk": "5.4.1", + "https-proxy-agent": "7.0.6", "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/bin/aikido-npm.js b/packages/safe-chain/bin/aikido-npm.js index 4176db1..d8b8c3e 100755 --- a/packages/safe-chain/bin/aikido-npm.js +++ b/packages/safe-chain/bin/aikido-npm.js @@ -6,7 +6,9 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa const packageManagerName = "npm"; initializePackageManager(packageManagerName, getNpmVersion()); -await main(process.argv.slice(2)); +var exitCode = await main(process.argv.slice(2)); + +process.exit(exitCode); function getNpmVersion() { try { diff --git a/packages/safe-chain/bin/aikido-npx.js b/packages/safe-chain/bin/aikido-npx.js index 067608c..7f06c7c 100755 --- a/packages/safe-chain/bin/aikido-npx.js +++ b/packages/safe-chain/bin/aikido-npx.js @@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa const packageManagerName = "npx"; initializePackageManager(packageManagerName, process.versions.node); -await main(process.argv.slice(2)); +var exitCode = await main(process.argv.slice(2)); + +process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-pnpm.js b/packages/safe-chain/bin/aikido-pnpm.js index e7bac47..7177159 100755 --- a/packages/safe-chain/bin/aikido-pnpm.js +++ b/packages/safe-chain/bin/aikido-pnpm.js @@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa const packageManagerName = "pnpm"; initializePackageManager(packageManagerName, process.versions.node); -await main(process.argv.slice(2)); +var exitCode = await main(process.argv.slice(2)); + +process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-pnpx.js b/packages/safe-chain/bin/aikido-pnpx.js index 25884ce..4bb6840 100755 --- a/packages/safe-chain/bin/aikido-pnpx.js +++ b/packages/safe-chain/bin/aikido-pnpx.js @@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa const packageManagerName = "pnpx"; initializePackageManager(packageManagerName, process.versions.node); -await main(process.argv.slice(2)); +var exitCode = await main(process.argv.slice(2)); + +process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-yarn.js b/packages/safe-chain/bin/aikido-yarn.js index a0eaaf6..002a956 100755 --- a/packages/safe-chain/bin/aikido-yarn.js +++ b/packages/safe-chain/bin/aikido-yarn.js @@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa const packageManagerName = "yarn"; initializePackageManager(packageManagerName, process.versions.node); -await main(process.argv.slice(2)); +var exitCode = await main(process.argv.slice(2)); + +process.exit(exitCode); diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index 32228e7..d28fe73 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -30,7 +30,9 @@ "dependencies": { "abbrev": "3.0.1", "chalk": "5.4.1", + "https-proxy-agent": "7.0.6", "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..e106e83 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -4,20 +4,50 @@ 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); if (shouldScanCommand(args)) { - await scanCommand(args); + const commandScanResult = await scanCommand(args); + + // Returning the exit code back to the caller allows the promise + // to be awaited in the bin files and return the correct exit code + if (commandScanResult !== 0) { + return commandScanResult; + } } + + const packageManagerResult = await getPackageManager().runCommand(args); + + if (!proxy.verifyNoMaliciousPackages()) { + return 1; + } + + ui.emptyLine(); + ui.writeInformation( + `${chalk.green( + "✔" + )} Safe-chain: Command completed, no malicious packages found.` + ); + + // Returning the exit code back to the caller allows the promise + // to be awaited in the bin files and return the correct exit code + return packageManagerResult.status; } catch (error) { ui.writeError("Failed to check for malicious packages:", error.message); - process.exit(1); - } - var result = getPackageManager().runCommand(args); - process.exit(result.status); + // Returning the exit code back to the caller allows the promise + // to be awaited in the bin files and return the correct exit code + return 1; + } finally { + await proxy.stopServer(); + } } diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js index 0db23cb..6189b2f 100644 --- a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js +++ b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js @@ -8,6 +8,7 @@ export function dryRunScanner(scannerOptions) { shouldScan: (args) => shouldScanDependencies(scannerOptions, args), }; } + function scanDependencies(scannerOptions, args) { let dryRunArgs = args; @@ -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/npx/runNpxCommand.js b/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js index cc78abb..b8896b7 100644 --- a/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js +++ b/packages/safe-chain/src/packagemanager/npx/runNpxCommand.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 runNpx(args) { +export async function runNpx(args) { try { - const npxCommand = `npx ${args.join(" ")}`; - execSync(npxCommand, { stdio: "inherit" }); + const result = await safeSpawn("npx", args, { + stdio: "inherit", + env: mergeSafeChainProxyEnvironmentVariables(process.env), + }); + return { status: result.status }; } catch (error) { if (error.status) { return { status: error.status }; @@ -13,5 +17,4 @@ export function runNpx(args) { return { status: 1 }; } } - return { status: 0 }; } 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..d5d414c --- /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.setHours(cert.validity.notBefore.getHours() + 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..4be9987 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -0,0 +1,90 @@ +import https from "https"; +import { generateCertForHost } from "./certUtils.js"; +import { HttpsProxyAgent } from "https-proxy-agent"; + +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 pathAndQuery = getRequestPathAndQuery(req.url); + const targetUrl = `https://${hostname}${pathAndQuery}`; + + 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 getRequestPathAndQuery(url) { + if (url.startsWith("http://") || url.startsWith("https://")) { + const parsedUrl = new URL(url); + return parsedUrl.pathname + parsedUrl.search + parsedUrl.hash; + } + return url; +} + +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 httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; + if (httpsProxy) { + options.agent = new HttpsProxyAgent(httpsProxy); + } + + 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..3558673 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -0,0 +1,158 @@ +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"; +import { ui } from "../environment/userInteraction.js"; +import chalk from "chalk"; + +const SERVER_STOP_TIMEOUT_MS = 1000; +const state = { + port: null, + blockedRequests: [], +}; + +export function createSafeChainProxy() { + const server = createProxyServer(); + server.on("connect", handleConnect); + + return { + startServer: () => startServer(server), + stopServer: () => stopServer(server), + verifyNoMaliciousPackages, + }; +} + +function getSafeChainProxyEnvironmentVariables() { + if (!state.port) { + return {}; + } + + 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[key] = 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) => { + // 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); + }); + }); +} + +function stopServer(server) { + return new Promise((resolve) => { + try { + server.close(() => { + resolve(); + }); + } catch { + resolve(); + } + setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS); + }); +} + +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); + + // packageName and version are undefined when the URL is not a package download + // In that case, we can allow the request to proceed + 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; +} + +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.writeError("Exiting without installing malicious packages."); + ui.emptyLine(); + + return false; +} diff --git a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js new file mode 100644 index 0000000..95e2beb --- /dev/null +++ b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js @@ -0,0 +1,98 @@ +import * as net from "net"; +import { ui } from "../environment/userInteraction.js"; + +export function tunnelRequest(req, clientSocket, head) { + const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; + + if (httpsProxy) { + // If an HTTPS proxy is set, tunnel the request via the proxy + // This is the system proxy, not the safe-chain proxy + // The package manager will run via the safe-chain proxy + // The safe-chain proxy will then send the request to the system proxy + // Typical flow: package manager -> safe-chain proxy -> system proxy -> destination + + // There are 2 processes involved in this: + // 1. Safe-chain process: has HTTPS_PROXY set to system proxy + // 2. Package manager process: has HTTPS_PROXY set to safe-chain proxy + + tunnelRequestViaProxy(req, clientSocket, head, httpsProxy); + } else { + tunnelRequestToDestination(req, clientSocket, head); + } +} + +function tunnelRequestToDestination(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"); + }); +} + +function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { + const { port, hostname } = new URL(`http://${req.url}`); + const proxy = new URL(proxyUrl); + + // Connect to proxy server + const proxySocket = net.connect({ + host: proxy.hostname, + port: proxy.port, + }); + + proxySocket.on("connect", () => { + // Send CONNECT request to proxy + const connectRequest = [ + `CONNECT ${hostname}:${port || 443} HTTP/1.1`, + `Host: ${hostname}:${port || 443}`, + "", + "", + ].join("\r\n"); + + proxySocket.write(connectRequest); + }); + + let isConnected = false; + proxySocket.once("data", (data) => { + const response = data.toString(); + + // Check if CONNECT succeeded (HTTP/1.1 200) + if (response.startsWith("HTTP/1.1 200")) { + isConnected = true; + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + proxySocket.write(head); + proxySocket.pipe(clientSocket); + clientSocket.pipe(proxySocket); + } else { + ui.writeError( + `Safe-chain: proxy CONNECT failed: ${response.split("\r\n")[0]}` + ); + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + proxySocket.end(); + } + }); + + proxySocket.on("error", (err) => { + if (!isConnected) { + ui.writeError( + `Safe-chain: error connecting to proxy ${proxy.hostname}:${ + proxy.port || 8080 + } - ${err.message}` + ); + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + } + }); + + clientSocket.on("error", () => { + proxySocket.end(); + }); +} diff --git a/packages/safe-chain/src/scanning/index.js b/packages/safe-chain/src/scanning/index.js index 48a3e3a..36f62ca 100644 --- a/packages/safe-chain/src/scanning/index.js +++ b/packages/safe-chain/src/scanning/index.js @@ -61,10 +61,11 @@ export async function scanCommand(args) { } if (!audit || audit.isAllowed) { - spinner.succeed("Safe-chain: No malicious packages detected."); + spinner.stop(); + return 0; } else { printMaliciousChanges(audit.disallowedChanges, spinner); - await onMalwareFound(); + return await onMalwareFound(); } } @@ -88,11 +89,11 @@ async function onMalwareFound() { if (continueInstall) { ui.writeWarning("Continuing with the installation despite the risks..."); - return; + return 0; } } ui.writeError("Exiting without installing malicious packages."); ui.emptyLine(); - process.exit(1); + return 1; } diff --git a/packages/safe-chain/src/scanning/index.scanCommand.spec.js b/packages/safe-chain/src/scanning/index.scanCommand.spec.js index 715ecfb..1858d10 100644 --- a/packages/safe-chain/src/scanning/index.scanCommand.spec.js +++ b/packages/safe-chain/src/scanning/index.scanCommand.spec.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { describe, it, mock } from "node:test"; +import { beforeEach, describe, it, mock } from "node:test"; import { setTimeout } from "node:timers/promises"; import { MALWARE_ACTION_PROMPT, @@ -13,6 +13,7 @@ describe("scanCommand", async () => { setText: () => {}, succeed: () => {}, fail: () => {}, + stop: () => {}, })); const mockConfirm = mock.fn(() => true); let malwareAction = MALWARE_ACTION_PROMPT; @@ -87,30 +88,37 @@ describe("scanCommand", async () => { const { scanCommand } = await import("./index.js"); + beforeEach(() => { + // Reset malware action back to prompt mode for other tests + malwareAction = MALWARE_ACTION_PROMPT; + }); + it("should succeed when there are no changes", async () => { - let successMessageWasSet = false; + let progressWasStopped = false; mockStartProcess.mock.mockImplementationOnce(() => ({ setText: () => {}, - succeed: () => { - successMessageWasSet = true; - }, + succeed: () => {}, fail: () => {}, + stop: () => { + progressWasStopped = true; + }, })); mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => []); await scanCommand(["install", "lodash"]); - assert.equal(successMessageWasSet, true); + assert.equal(progressWasStopped, true); }); it("should succeed when changes are not malicious", async () => { - let successMessageWasSet = false; + let progressWasStopped = false; mockStartProcess.mock.mockImplementationOnce(() => ({ setText: () => {}, - succeed: () => { - successMessageWasSet = true; - }, + succeed: () => {}, fail: () => {}, + stop: () => { + progressWasStopped = true; + }, })); mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ { name: "lodash", version: "4.17.21" }, @@ -118,7 +126,7 @@ describe("scanCommand", async () => { await scanCommand(["install", "lodash"]); - assert.equal(successMessageWasSet, true); + assert.equal(progressWasStopped, true); }); it("should throw an error when timing out", async () => { @@ -129,6 +137,7 @@ describe("scanCommand", async () => { fail: () => { failureMessageWasSet = true; }, + stop: () => {}, })); getScanTimeoutMock.mock.mockImplementationOnce(() => 100); mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => { @@ -149,6 +158,7 @@ describe("scanCommand", async () => { fail: () => { failureMessageWasSet = true; }, + stop: () => {}, })); mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ { name: "malicious", version: "1.0.0" }, @@ -173,6 +183,7 @@ describe("scanCommand", async () => { fail: (message) => { failureMessages.push(message); }, + stop: () => {}, })); getScanTimeoutMock.mock.mockImplementationOnce(() => 100); mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => { @@ -194,46 +205,29 @@ describe("scanCommand", async () => { it("should exit immediately when malicious changes are detected in block mode", async () => { // Set malware action to block mode for this test malwareAction = MALWARE_ACTION_BLOCK; - + // Reset mock call count mockConfirm.mock.resetCalls(); - + let failureMessageWasSet = false; - let exitCode = null; - + mockStartProcess.mock.mockImplementationOnce(() => ({ setText: () => {}, succeed: () => {}, fail: () => { failureMessageWasSet = true; }, + stop: () => {}, })); - + mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ { name: "malicious", version: "1.0.0" }, ]); - // Mock process.exit - const originalExit = process.exit; - process.exit = mock.fn((code) => { - exitCode = code; - throw new Error("Process exit called"); // Prevent actual exit - }); - - try { - await assert.rejects( - scanCommand(["install", "malicious"]), - /Process exit called/ - ); - } finally { - // Restore original process.exit - process.exit = originalExit; - // Reset malware action back to prompt mode for other tests - malwareAction = MALWARE_ACTION_PROMPT; - } + const result = await scanCommand(["install", "malicious"]); assert.equal(failureMessageWasSet, true); - assert.equal(exitCode, 1); + assert.equal(result, 1); // Confirm should not have been called in block mode assert.equal(mockConfirm.mock.callCount(), 0); }); @@ -241,19 +235,19 @@ describe("scanCommand", async () => { it("should exit immediately when malicious changes are detected in block mode without prompting", async () => { // Set malware action to block mode for this test malwareAction = MALWARE_ACTION_BLOCK; - + // Reset mock call count mockConfirm.mock.resetCalls(); - - let processExited = false; + let userWasPrompted = false; - + mockStartProcess.mock.mockImplementationOnce(() => ({ setText: () => {}, succeed: () => {}, fail: () => {}, + stop: () => {}, })); - + mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ { name: "malicious", version: "1.0.0" }, ]); @@ -263,26 +257,9 @@ describe("scanCommand", async () => { return false; }); - // Mock process.exit - const originalExit = process.exit; - process.exit = mock.fn(() => { - processExited = true; - throw new Error("Process exit called"); // Prevent actual exit - }); + const result = await scanCommand(["install", "malicious"]); - try { - await assert.rejects( - scanCommand(["install", "malicious"]), - /Process exit called/ - ); - } finally { - // Restore original process.exit - process.exit = originalExit; - // Reset malware action back to prompt mode for other tests - malwareAction = MALWARE_ACTION_PROMPT; - } - - assert.equal(processExited, true); + assert.equal(result, 1); assert.equal(userWasPrompted, false); }); }); diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index 26f1999..1cb781b 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -8,7 +8,13 @@ import { } from "../config/configFile.js"; import { ui } from "../environment/userInteraction.js"; +let cachedMalwareDatabase = null; + export async function openMalwareDatabase() { + if (cachedMalwareDatabase) { + return cachedMalwareDatabase; + } + const malwareDatabase = await getMalwareDatabase(); function getPackageStatus(name, version) { @@ -25,13 +31,16 @@ export async function openMalwareDatabase() { return packageData.reason; } - return { + // This implicitely caches the malware database + // that's closed over by the getPackageStatus function + cachedMalwareDatabase = { getPackageStatus, isMalware: (name, version) => { const status = getPackageStatus(name, version); return isMalwareStatus(status); }, }; + return cachedMalwareDatabase; } async function getMalwareDatabase() { diff --git a/packages/safe-chain/src/utils/safeSpawn.js b/packages/safe-chain/src/utils/safeSpawn.js index 826ab7d..c5cd913 100644 --- a/packages/safe-chain/src/utils/safeSpawn.js +++ b/packages/safe-chain/src/utils/safeSpawn.js @@ -23,11 +23,23 @@ export async function safeSpawn(command, args, options = {}) { return new Promise((resolve, reject) => { const child = spawn(fullCommand, { ...options, shell: true }); + // When stdio is piped, we need to collect the output + 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/Dockerfile b/test/e2e/Dockerfile index a84db30..3c8ce73 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -29,6 +29,9 @@ ARG PNPM_VERSION=latest SHELL ["/bin/bash", "-c"] ENV BASH_ENV=~/.bashrc +# Install a proxy +RUN apt-get update && apt-get install tinyproxy -y + # Install zsh RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.1/zsh-in-docker.sh)" # Install fish diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js index 3e08c3d..dc1c23f 100644 --- a/test/e2e/npm-ci.e2e.spec.js +++ b/test/e2e/npm-ci.e2e.spec.js @@ -36,7 +36,7 @@ describe("E2E: npm coverage using PATH", () => { const result = await shell.runCommand("npm i axios"); assert.ok( - result.output.includes("No malicious packages detected."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index 0e64971..c744835 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -31,7 +31,7 @@ describe("E2E: npm coverage", () => { const result = await shell.runCommand("npm i axios"); assert.ok( - result.output.includes("No malicious packages detected."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -60,6 +60,52 @@ describe("E2E: npm coverage", () => { ); }); + it(`safe-chain blocks download of malicious packages already in package.json`, async () => { + const shell = await container.openShell("zsh"); + const npmVersion = (await shell.runCommand("npm --version")).output.trim(); + const majorVersion = parseInt(npmVersion.split(".")[0]); + const minorVersion = parseInt(npmVersion.split(".")[1]); + const isBelow10_4 = + majorVersion < 10 || (majorVersion === 10 && minorVersion < 4); + await shell.runCommand( + 'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json' + ); + + var result = await shell.runCommand("npm install"); + + if (isBelow10_4) { + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes( + "Exiting without installing malicious packages." + ), + `Output did not include expected text. Output was:\n${result.output}` + ); + } else { + assert.ok( + result.output.includes("Malicious changes detected:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes( + "Exiting without installing malicious packages." + ), + `Output did not include expected text. Output was:\n${result.output}` + ); + } + }); + it("safe-chain blocks npx from executing malicious packages", async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand("npx safe-chain-test"); 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", diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js index 9a8c6a2..339a5e0 100644 --- a/test/e2e/pnpm-ci.e2e.spec.js +++ b/test/e2e/pnpm-ci.e2e.spec.js @@ -36,7 +36,7 @@ describe("E2E: pnpm coverage", () => { const result = await shell.runCommand("pnpm add axios"); assert.ok( - result.output.includes("No malicious packages detected."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index db7eb58..c0187d7 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -31,7 +31,7 @@ describe("E2E: pnpm coverage", () => { const result = await shell.runCommand("pnpm add axios"); assert.ok( - result.output.includes("No malicious packages detected."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -60,6 +60,28 @@ describe("E2E: pnpm coverage", () => { ); }); + it(`safe-chain blocks download of malicious packages already in package.json`, async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand( + 'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json' + ); + + var result = await shell.runCommand("pnpm install"); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + it("safe-chain blocks pnpx from executing malicious packages", async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand("pnpx safe-chain-test"); diff --git a/test/e2e/safe-chain-proxy.e2e.spec.js b/test/e2e/safe-chain-proxy.e2e.spec.js new file mode 100644 index 0000000..6abbb0f --- /dev/null +++ b/test/e2e/safe-chain-proxy.e2e.spec.js @@ -0,0 +1,60 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: Safe chain proxy", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + // Run a new Docker container for each test + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup"); + }); + + afterEach(async () => { + // Stop and clean up the container after each test + if (container) { + await container.stop(); + container = null; + } + }); + + it(`safe-chain proxy respects upstream proxy settings`, async () => { + // Configure and start a proxy inside the container + const proxy = await container.openShell("zsh"); + await proxy.runCommand( + `echo 'BasicAuth user password' >> /etc/tinyproxy/tinyproxy.conf` + ); + await proxy.runCommand("tinyproxy"); + + const shell = await container.openShell("zsh"); + await shell.runCommand( + 'export HTTPS_PROXY="http://user:password@localhost:8888"' + ); + const { output } = await shell.runCommand("npm install axios"); + + // Check if the installation was successful + assert( + output.includes("added") || output.includes("up to date"), + "npm install did not complete successfully" + ); + + const proxyLog = await container.openShell("zsh"); + const { output: logOutput } = await proxyLog.runCommand( + "cat /var/log/tinyproxy/tinyproxy.log" + ); + + // Check if the proxy log contains entries for the npm install + assert( + logOutput.includes("CONNECT registry.npmjs.org:443"), + "Proxy log does not contain expected entries" + ); + }); +}); diff --git a/test/e2e/yarn-ci.e2e.spec.js b/test/e2e/yarn-ci.e2e.spec.js index 5466851..33ef4f2 100644 --- a/test/e2e/yarn-ci.e2e.spec.js +++ b/test/e2e/yarn-ci.e2e.spec.js @@ -36,7 +36,7 @@ describe("E2E: yarn coverage", () => { const result = await shell.runCommand("yarn add axios"); assert.ok( - result.output.includes("No malicious packages detected."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index fb22b76..3909318 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -31,7 +31,53 @@ describe("E2E: yarn coverage", () => { const result = await shell.runCommand("yarn add axios"); assert.ok( - result.output.includes("No malicious packages detected."), + result.output.includes("no malicious packages found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks installation of malicious packages`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("yarn add safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + const listResult = await shell.runCommand("yarn list"); + assert.ok( + !listResult.output.includes("safe-chain-test"), + `Malicious package was installed despite safe-chain protection. Output of 'yarn list' was:\n${listResult.output}` + ); + }); + + it(`safe-chain blocks download of malicious packages already in package.json`, async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand( + 'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json' + ); + + var result = await shell.runCommand("yarn"); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), `Output did not include expected text. Output was:\n${result.output}` ); });