mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Move npm and pip mitm interception to separate files
This commit is contained in:
parent
e251908cb3
commit
f4694ba119
8 changed files with 350 additions and 224 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { isMalwarePackage } from "../../scanning/audit/index.js";
|
||||
import { createInterceptorBuilder } 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) {
|
||||
const builder = createInterceptorBuilder();
|
||||
|
||||
builder.onRequest(async (req) => {
|
||||
const { packageName, version } = parseNpmPackageUrl(
|
||||
req.targetUrl,
|
||||
registry
|
||||
);
|
||||
if (await isMalwarePackage(packageName, version)) {
|
||||
req.blockRequest(403, "Forbidden - blocked by safe-chain");
|
||||
}
|
||||
});
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {string} registry
|
||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||
*/
|
||||
export 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 };
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("npmInterceptor", async () => {
|
||||
let lastPackage;
|
||||
let malwareResponse = false;
|
||||
|
||||
mock.module("../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async (packageName, version) => {
|
||||
lastPackage = { packageName, version };
|
||||
return malwareResponse;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
|
||||
|
||||
const parserCases = [
|
||||
// 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" },
|
||||
},
|
||||
// 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",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
parserCases.forEach(({ url, expected }, index) => {
|
||||
it(`should parse URL ${index + 1}: ${url}`, async () => {
|
||||
const interceptor = npmInterceptorForUrl(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/some-package/-/some-package-1.0.0.tgz";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
|
||||
assert.equal(
|
||||
interceptor,
|
||||
undefined,
|
||||
"Interceptor should be undefined for unknown registry"
|
||||
);
|
||||
});
|
||||
|
||||
it("should block malicious package", async () => {
|
||||
const url =
|
||||
"https://registry.npmjs.org/malicious-package/-/malicious-package-1.0.0.tgz";
|
||||
malwareResponse = true;
|
||||
|
||||
const interceptor = npmInterceptorForUrl(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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { isMalwarePackage } from "../../scanning/audit/index.js";
|
||||
import { createInterceptorBuilder } from "./interceptorBuilder.js";
|
||||
|
||||
const knownPipRegistries = [
|
||||
"files.pythonhosted.org",
|
||||
"pypi.org",
|
||||
"pypi.python.org",
|
||||
"pythonhosted.org",
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
export function pipInterceptorForUrl(url) {
|
||||
const registry = knownPipRegistries.find((reg) => url.includes(reg));
|
||||
|
||||
if (registry) {
|
||||
return buildPipInterceptor(registry);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} registry
|
||||
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
function buildPipInterceptor(registry) {
|
||||
const builder = createInterceptorBuilder();
|
||||
|
||||
builder.onRequest(async (req) => {
|
||||
const { packageName, version } = parsePipPackageFromUrl(
|
||||
req.targetUrl,
|
||||
registry
|
||||
);
|
||||
if (await isMalwarePackage(packageName, version)) {
|
||||
req.blockRequest(403, "Forbidden - blocked by safe-chain");
|
||||
}
|
||||
});
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {string} registry
|
||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||
*/
|
||||
function parsePipPackageFromUrl(url, registry) {
|
||||
let packageName, version;
|
||||
|
||||
// Basic validation
|
||||
if (!registry || typeof url !== "string") {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
// Quick sanity check on the URL + parse
|
||||
let urlObj;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
// Get the last path segment (filename) and decode it (strip query & fragment automatically)
|
||||
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
|
||||
if (!lastSegment) {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
const filename = decodeURIComponent(lastSegment);
|
||||
|
||||
// Parse Python package downloads from PyPI/pythonhosted.org
|
||||
// Example wheel: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1-py3-none-any.whl
|
||||
// Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz
|
||||
|
||||
// Wheel (.whl)
|
||||
if (filename.endsWith(".whl")) {
|
||||
const base = filename.slice(0, -4); // remove ".whl"
|
||||
const firstDash = base.indexOf("-");
|
||||
if (firstDash > 0) {
|
||||
const dist = base.slice(0, firstDash); // may contain underscores
|
||||
const rest = base.slice(firstDash + 1); // version + the rest of tags
|
||||
const secondDash = rest.indexOf("-");
|
||||
const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
|
||||
packageName = dist; // preserve underscores
|
||||
version = rawVersion;
|
||||
// Reject "latest" as it's a placeholder, not a real version
|
||||
// When version is "latest", this signals the URL doesn't contain actual version info
|
||||
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
|
||||
if (version === "latest" || !packageName || !version) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
return { packageName, version };
|
||||
}
|
||||
}
|
||||
|
||||
// Source dist (sdist)
|
||||
const sdistExtMatch = filename.match(/\.(tar\.gz|zip|tar\.bz2|tar\.xz)$/i);
|
||||
if (sdistExtMatch) {
|
||||
const base = filename.slice(0, -sdistExtMatch[0].length);
|
||||
const lastDash = base.lastIndexOf("-");
|
||||
if (lastDash > 0 && lastDash < base.length - 1) {
|
||||
packageName = base.slice(0, lastDash);
|
||||
version = base.slice(lastDash + 1);
|
||||
// Reject "latest" as it's a placeholder, not a real version
|
||||
// When version is "latest", this signals the URL doesn't contain actual version info
|
||||
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
|
||||
if (version === "latest" || !packageName || !version) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
return { packageName, version };
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown file type or invalid
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue