mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Implement a proxy blocking tarball requests for packages containing malware.
This commit is contained in:
parent
04cb001006
commit
e2afcb16e3
16 changed files with 633 additions and 33 deletions
114
packages/safe-chain/src/registryProxy/certUtils.js
Normal file
114
packages/safe-chain/src/registryProxy/certUtils.js
Normal 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,
|
||||
};
|
||||
}
|
||||
76
packages/safe-chain/src/registryProxy/mitmRequestHandler.js
Normal file
76
packages/safe-chain/src/registryProxy/mitmRequestHandler.js
Normal 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;
|
||||
}
|
||||
48
packages/safe-chain/src/registryProxy/parsePackageFromUrl.js
Normal file
48
packages/safe-chain/src/registryProxy/parsePackageFromUrl.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
119
packages/safe-chain/src/registryProxy/registryProxy.js
Normal file
119
packages/safe-chain/src/registryProxy/registryProxy.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue