Merge pull request #144 from AikidoSec/only-write-stdout-when-safe-chain-audited

Add interceptors for MITM
This commit is contained in:
bitterpanda 2025-11-12 14:27:27 +01:00 committed by GitHub
commit bb0d06cdfc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 459 additions and 202 deletions

View file

@ -0,0 +1,25 @@
import {
ECOSYSTEM_JS,
ECOSYSTEM_PY,
getEcoSystem,
} from "../../config/settings.js";
import { npmInterceptorForUrl } from "./npmInterceptor.js";
import { pipInterceptorForUrl } from "./pipInterceptor.js";
/**
* @param {string} url
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
*/
export function createInterceptorForUrl(url) {
const ecosystem = getEcoSystem();
if (ecosystem === ECOSYSTEM_JS) {
return npmInterceptorForUrl(url);
}
if (ecosystem === ECOSYSTEM_PY) {
return pipInterceptorForUrl(url);
}
return undefined;
}

View file

@ -0,0 +1,92 @@
import { EventEmitter } from "events";
/**
* @typedef {Object} Interceptor
* @property {(targetUrl: string) => Promise<RequestInterceptionHandler>} handleRequest
* @property {(event: string, listener: (...args: any[]) => void) => Interceptor} on
* @property {(event: string, ...args: any[]) => boolean} emit
*
*
* @typedef {Object} RequestInterceptionContext
* @property {string} targetUrl
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
* @property {() => RequestInterceptionHandler} build
*
*
* @typedef {Object} RequestInterceptionHandler
* @property {{statusCode: number, message: string} | undefined} blockResponse
*/
/**
* @param {(requestHandlerBuilder: RequestInterceptionContext) => Promise<void>} requestInterceptionFunc
* @returns {Interceptor}
*/
export function interceptRequests(requestInterceptionFunc) {
return buildInterceptor([requestInterceptionFunc]);
}
/**
* @param {Array<(requestHandlerBuilder: RequestInterceptionContext) => Promise<void>>} requestHandlers
* @returns {Interceptor}
*/
function buildInterceptor(requestHandlers) {
const eventEmitter = new EventEmitter();
return {
async handleRequest(targetUrl) {
const requestContext = createRequestContext(targetUrl, eventEmitter);
for (const handler of requestHandlers) {
await handler(requestContext);
}
return requestContext.build();
},
on(event, listener) {
eventEmitter.on(event, listener);
return this;
},
emit(event, ...args) {
return eventEmitter.emit(event, ...args);
},
};
}
/**
* @param {string} targetUrl
* @param {import('events').EventEmitter} eventEmitter
* @returns {RequestInterceptionContext}
*/
function createRequestContext(targetUrl, eventEmitter) {
/** @type {{statusCode: number, message: string} | undefined} */
let blockResponse = undefined;
/**
* @param {string | undefined} packageName
* @param {string | undefined} version
*/
function blockMalware(packageName, version) {
blockResponse = {
statusCode: 403,
message: "Forbidden - blocked by safe-chain",
};
// Emit the malwareBlocked event
eventEmitter.emit("malwareBlocked", {
packageName,
version,
targetUrl,
timestamp: Date.now(),
});
}
return {
targetUrl,
blockMalware,
build() {
return {
blockResponse,
};
},
};
}

View file

@ -0,0 +1,78 @@
import { isMalwarePackage } from "../../scanning/audit/index.js";
import { interceptRequests } from "./interceptorBuilder.js";
const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
/**
* @param {string} url
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
*/
export function npmInterceptorForUrl(url) {
const registry = knownJsRegistries.find((reg) => url.includes(reg));
if (registry) {
return buildNpmInterceptor(registry);
}
return undefined;
}
/**
* @param {string} registry
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
*/
function buildNpmInterceptor(registry) {
return interceptRequests(async (reqContext) => {
const { packageName, version } = parseNpmPackageUrl(
reqContext.targetUrl,
registry
);
if (await isMalwarePackage(packageName, version)) {
reqContext.blockMalware(packageName, version);
}
});
}
/**
* @param {string} url
* @param {string} registry
* @returns {{packageName: string | undefined, version: string | undefined}}
*/
function parseNpmPackageUrl(url, registry) {
let packageName, version;
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

@ -1,14 +1,22 @@
import { describe, it, beforeEach } from "node:test"; import { describe, it, mock } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
import { parsePackageFromUrl } from "./parsePackageFromUrl.js";
import { setEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
describe("parsePackageFromUrl", () => { describe("npmInterceptor", async () => {
beforeEach(() => { let lastPackage;
setEcoSystem(ECOSYSTEM_JS); let malwareResponse = false;
mock.module("../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };
return malwareResponse;
},
},
}); });
const testCases = [ const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
const parserCases = [
// Regular packages // Regular packages
{ {
url: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", url: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@ -83,11 +91,6 @@ describe("parsePackageFromUrl", () => {
url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz", url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz",
expected: { packageName: "@babel/core", version: "7.21.4" }, 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 to get package info, not tarball
{ {
url: "https://registry.npmjs.org/lodash", url: "https://registry.npmjs.org/lodash",
@ -110,92 +113,51 @@ describe("parsePackageFromUrl", () => {
}, },
]; ];
testCases.forEach(({ url, expected }, index) => { parserCases.forEach(({ url, expected }, index) => {
it(`should parse URL ${index + 1}: ${url}`, () => { it(`should parse URL ${index + 1}: ${url}`, async () => {
const result = parsePackageFromUrl(url); const interceptor = npmInterceptorForUrl(url);
assert.deepEqual(result, expected); assert.ok(
}); interceptor,
"Interceptor should be created for known npm registry"
);
await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, expected);
}); });
}); });
describe("parsePackageFromUrl - pip URLs", () => { it("should not create interceptor for unknown registry", () => {
beforeEach(() => { const url = "https://example.com/some-package/-/some-package-1.0.0.tgz";
setEcoSystem(ECOSYSTEM_PY);
const interceptor = npmInterceptorForUrl(url);
assert.equal(
interceptor,
undefined,
"Interceptor should be undefined for unknown registry"
);
}); });
const pipTestCases = [ it("should block malicious package", async () => {
// Valid pip URLs const url =
{ "https://registry.npmjs.org/malicious-package/-/malicious-package-1.0.0.tgz";
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz", malwareResponse = true;
expected: { packageName: "foobar", version: "1.2.3" },
},
{
url: "https://pypi.org/packages/source/f/foobar/foobar-1.2.3.tar.gz",
expected: { packageName: "foobar", version: "1.2.3" },
},
{
url: "https://pypi.org/packages/source/f/foo-bar/foo-bar-0.9.0.tar.gz",
expected: { packageName: "foo-bar", version: "0.9.0" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-py3-none-any.whl",
expected: { packageName: "foo_bar", version: "2.0.0" },
},
{
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl",
expected: { packageName: "foo_bar", version: "2.0.0" },
},
{
url: "https://pypi.org/packages/source/f/foo.bar/foo.bar-1.0.0.tar.gz",
expected: { packageName: "foo.bar", version: "1.0.0" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0b1.tar.gz",
expected: { packageName: "foo_bar", version: "2.0.0b1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0rc1.tar.gz",
expected: { packageName: "foo_bar", version: "2.0.0rc1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.post1.tar.gz",
expected: { packageName: "foo_bar", version: "2.0.0.post1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.dev1.tar.gz",
expected: { packageName: "foo_bar", version: "2.0.0.dev1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz",
expected: { packageName: "foo_bar", version: "2.0.0a1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl",
expected: { packageName: "foo_bar", version: "2.0.0" },
},
// Invalid pip URLs
{
url: "https://pypi.org/simple/",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://pypi.org/project/foobar/",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-latest.tar.gz",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-latest.tar.gz",
expected: { packageName: undefined, version: undefined },
},
];
pipTestCases.forEach(({ url, expected }, index) => { const interceptor = npmInterceptorForUrl(url);
it(`should parse pip URL ${index + 1}: ${url}`, () => {
const result = parsePackageFromUrl(url); const result = await interceptor.handleRequest(url);
assert.deepEqual(result, expected);
}); assert.ok(result.blockResponse, "Should contain a blockResponse");
assert.equal(
result.blockResponse.statusCode,
403,
"Block response should have status code 403"
);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain",
"Block response should have correct status message"
);
}); });
}); });

View file

@ -1,79 +1,41 @@
import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; import { isMalwarePackage } from "../../scanning/audit/index.js";
import { interceptRequests } from "./interceptorBuilder.js";
export const knownJsRegistries = ["registry.npmjs.org","registry.yarnpkg.com"]; const knownPipRegistries = [
export const knownPipRegistries = ["files.pythonhosted.org", "pypi.org", "pypi.python.org", "pythonhosted.org"]; "files.pythonhosted.org",
"pypi.org",
"pypi.python.org",
"pythonhosted.org",
];
/** /**
* @param {string} url * @param {string} url
* @returns {{packageName: string | undefined, version: string | undefined}} * @returns {import("./interceptorBuilder.js").Interceptor | undefined}
*/ */
export function parsePackageFromUrl(url) { export function pipInterceptorForUrl(url) {
const ecosystem = getEcoSystem(); const registry = knownPipRegistries.find((reg) => url.includes(reg));
let registry;
// Only check registries that match the current ecosystem if (registry) {
if (ecosystem === ECOSYSTEM_JS) { return buildPipInterceptor(registry);
for (const knownRegistry of knownJsRegistries) {
if (url.includes(knownRegistry)) {
registry = knownRegistry;
return parseJsPackageFromUrl(url, registry);
}
}
} else if (ecosystem === ECOSYSTEM_PY) {
for (const knownRegistry of knownPipRegistries) {
if (url.includes(knownRegistry)) {
registry = knownRegistry;
return parsePipPackageFromUrl(url, registry);
}
}
} }
// If no known registry matched, return { packageName: undefined, version: undefined } return undefined;
return { packageName: undefined, version: undefined };
} }
/** /**
* @param {string} url
* @param {string} registry * @param {string} registry
* @returns {{packageName: string | undefined, version: string | undefined}} * @returns {import("./interceptorBuilder.js").Interceptor | undefined}
*/ */
function parseJsPackageFromUrl(url, registry) { function buildPipInterceptor(registry) {
let packageName, version; return interceptRequests(async (reqContext) => {
if (!registry || !url.endsWith(".tgz")) { const { packageName, version } = parsePipPackageFromUrl(
return { packageName, version }; reqContext.targetUrl,
} registry
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 + "-")) { if (await isMalwarePackage(packageName, version)) {
version = filename.substring(scopedPackageName.length + 1); reqContext.blockMalware(packageName, version);
} }
} else { });
if (filename.startsWith(packageName + "-")) {
version = filename.substring(packageName.length + 1);
}
}
return { packageName, version };
} }
/** /**
@ -82,7 +44,7 @@ function parseJsPackageFromUrl(url, registry) {
* @returns {{packageName: string | undefined, version: string | undefined}} * @returns {{packageName: string | undefined, version: string | undefined}}
*/ */
function parsePipPackageFromUrl(url, registry) { function parsePipPackageFromUrl(url, registry) {
let packageName, version let packageName, version;
// Basic validation // Basic validation
if (!registry || typeof url !== "string") { if (!registry || typeof url !== "string") {

View file

@ -0,0 +1,135 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
describe("pipInterceptor", async () => {
let lastPackage;
let malwareResponse = false;
mock.module("../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };
return malwareResponse;
},
},
});
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
const parserCases = [
// Valid pip URLs
{
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz",
expected: { packageName: "foobar", version: "1.2.3" },
},
{
url: "https://pypi.org/packages/source/f/foobar/foobar-1.2.3.tar.gz",
expected: { packageName: "foobar", version: "1.2.3" },
},
{
url: "https://pypi.org/packages/source/f/foo-bar/foo-bar-0.9.0.tar.gz",
expected: { packageName: "foo-bar", version: "0.9.0" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-py3-none-any.whl",
expected: { packageName: "foo_bar", version: "2.0.0" },
},
{
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl",
expected: { packageName: "foo_bar", version: "2.0.0" },
},
{
url: "https://pypi.org/packages/source/f/foo.bar/foo.bar-1.0.0.tar.gz",
expected: { packageName: "foo.bar", version: "1.0.0" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0b1.tar.gz",
expected: { packageName: "foo_bar", version: "2.0.0b1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0rc1.tar.gz",
expected: { packageName: "foo_bar", version: "2.0.0rc1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.post1.tar.gz",
expected: { packageName: "foo_bar", version: "2.0.0.post1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.dev1.tar.gz",
expected: { packageName: "foo_bar", version: "2.0.0.dev1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz",
expected: { packageName: "foo_bar", version: "2.0.0a1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl",
expected: { packageName: "foo_bar", version: "2.0.0" },
},
// Invalid pip URLs
{
url: "https://pypi.org/simple/",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://pypi.org/project/foobar/",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-latest.tar.gz",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-latest.tar.gz",
expected: { packageName: undefined, version: undefined },
},
];
parserCases.forEach(({ url, expected }, index) => {
it(`should parse URL ${index + 1}: ${url}`, async () => {
const interceptor = pipInterceptorForUrl(url);
assert.ok(
interceptor,
"Interceptor should be created for known npm registry"
);
await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, expected);
});
});
it("should not create interceptor for unknown registry", () => {
const url = "https://example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
const interceptor = pipInterceptorForUrl(url);
assert.equal(
interceptor,
undefined,
"Interceptor should be undefined for unknown registry"
);
});
it("should block malicious package", async () => {
const url =
"https://files.pythonhosted.org/packages/xx/yy/malicious_package-1.0.0.tar.gz";
malwareResponse = true;
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse, "Should contain a blockResponse");
assert.equal(
result.blockResponse.statusCode,
403,
"Block response should have status code 403"
);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain",
"Block response should have correct status message"
);
});
});

View file

@ -3,12 +3,16 @@ import { generateCertForHost } from "./certUtils.js";
import { HttpsProxyAgent } from "https-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
/**
* @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor
*/
/** /**
* @param {import("http").IncomingMessage} req * @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} clientSocket * @param {import("http").ServerResponse} clientSocket
* @param {(target: string) => Promise<boolean>} isAllowed * @param {Interceptor} interceptor
*/ */
export function mitmConnect(req, clientSocket, isAllowed) { export function mitmConnect(req, clientSocket, interceptor) {
ui.writeVerbose(`Safe-chain: Set up MITM tunnel for ${req.url}`); ui.writeVerbose(`Safe-chain: Set up MITM tunnel for ${req.url}`);
const { hostname } = new URL(`http://${req.url}`); const { hostname } = new URL(`http://${req.url}`);
@ -21,7 +25,7 @@ export function mitmConnect(req, clientSocket, isAllowed) {
// Not subscribing to 'close' event will cause node to throw and crash. // Not subscribing to 'close' event will cause node to throw and crash.
}); });
const server = createHttpsServer(hostname, isAllowed); const server = createHttpsServer(hostname, interceptor);
server.on("error", (err) => { server.on("error", (err) => {
ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`); ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`);
@ -41,10 +45,10 @@ export function mitmConnect(req, clientSocket, isAllowed) {
/** /**
* @param {string} hostname * @param {string} hostname
* @param {(target: string) => Promise<boolean>} isAllowed * @param {Interceptor} interceptor
* @returns {import("https").Server} * @returns {import("https").Server}
*/ */
function createHttpsServer(hostname, isAllowed) { function createHttpsServer(hostname, interceptor) {
const cert = generateCertForHost(hostname); const cert = generateCertForHost(hostname);
/** /**
@ -64,10 +68,13 @@ function createHttpsServer(hostname, isAllowed) {
const pathAndQuery = getRequestPathAndQuery(req.url); const pathAndQuery = getRequestPathAndQuery(req.url);
const targetUrl = `https://${hostname}${pathAndQuery}`; const targetUrl = `https://${hostname}${pathAndQuery}`;
if (!(await isAllowed(targetUrl))) { const interceptorResult = await interceptor.handleRequest(targetUrl);
const blockResponse = interceptorResult.blockResponse;
if (blockResponse) {
ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`); ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`);
res.writeHead(403, "Forbidden - blocked by safe-chain"); res.writeHead(blockResponse.statusCode, blockResponse.message);
res.end("Blocked by safe-chain"); res.end(blockResponse.message);
return; return;
} }

View file

@ -3,11 +3,9 @@ import { tunnelRequest } from "./tunnelRequestHandler.js";
import { mitmConnect } from "./mitmRequestHandler.js"; import { mitmConnect } from "./mitmRequestHandler.js";
import { handleHttpProxyRequest } from "./plainHttpProxy.js"; import { handleHttpProxyRequest } from "./plainHttpProxy.js";
import { getCaCertPath } from "./certUtils.js"; import { getCaCertPath } from "./certUtils.js";
import { auditChanges } from "../scanning/audit/index.js";
import { knownJsRegistries, knownPipRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js";
import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import chalk from "chalk"; import chalk from "chalk";
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
const SERVER_STOP_TIMEOUT_MS = 1000; const SERVER_STOP_TIMEOUT_MS = 1000;
/** /**
@ -132,18 +130,15 @@ function handleConnect(req, clientSocket, head) {
// CONNECT method is used for HTTPS requests // CONNECT method is used for HTTPS requests
// It establishes a tunnel to the server identified by the request URL // It establishes a tunnel to the server identified by the request URL
const ecosystem = getEcoSystem(); const interceptor = createInterceptorForUrl(req.url || "");
const url = req.url || "";
let isKnownRegistry = false; if (interceptor) {
if (ecosystem === ECOSYSTEM_JS) { // Subscribe to malware blocked events
isKnownRegistry = knownJsRegistries.some((reg) => url.includes(reg)); interceptor.on("malwareBlocked", (event) => {
} else if (ecosystem === ECOSYSTEM_PY) { onMalwareBlocked(event.packageName, event.version, event.url);
isKnownRegistry = knownPipRegistries.some((reg) => url.includes(reg)); });
}
if (isKnownRegistry) { mitmConnect(req, clientSocket, interceptor);
mitmConnect(req, clientSocket, isAllowedUrl);
} else { } else {
// For other hosts, just tunnel the request to the destination tcp socket // For other hosts, just tunnel the request to the destination tcp socket
ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`); ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`);
@ -152,28 +147,13 @@ function handleConnect(req, clientSocket, head) {
} }
/** /**
*
* @param {string} packageName
* @param {string} version
* @param {string} url * @param {string} url
* @returns {Promise<boolean>}
*/ */
async function isAllowedUrl(url) { function onMalwareBlocked(packageName, version, 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 }); state.blockedRequests.push({ packageName, version, url });
return false;
}
return true;
} }
function verifyNoMaliciousPackages() { function verifyNoMaliciousPackages() {

View file

@ -41,6 +41,22 @@ export function getAuditStats() {
return auditStats; return auditStats;
} }
/**
*
* @param {string | undefined} name
* @param {string | undefined} version
* @returns {Promise<boolean>}
*/
export async function isMalwarePackage(name, version) {
if (!name || !version) {
return false;
}
const auditResult = await auditChanges([{ name, version, type: "add" }]);
return !auditResult.isAllowed;
}
/** /**
* @param {PackageChange[]} changes * @param {PackageChange[]} changes
* *