Implement a proxy blocking tarball requests for packages containing malware.

This commit is contained in:
Sander Declerck 2025-09-30 13:52:21 +02:00
parent 04cb001006
commit e2afcb16e3
No known key found for this signature in database
16 changed files with 633 additions and 33 deletions

View file

@ -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"

View file

@ -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);
}

View file

@ -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

View file

@ -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);

View file

@ -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);

View file

@ -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}`);
}

View file

@ -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();
}

View file

@ -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,
};
}

View file

@ -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;
}

View file

@ -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 };
}

View file

@ -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);
});
});
});

View file

@ -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;
}

View file

@ -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");
});
}

View file

@ -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,
});
});