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 };
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
});
|
);
|
||||||
|
|
||||||
describe("parsePackageFromUrl - pip URLs", () => {
|
await interceptor.handleRequest(url);
|
||||||
beforeEach(() => {
|
|
||||||
setEcoSystem(ECOSYSTEM_PY);
|
assert.deepEqual(lastPackage, expected);
|
||||||
});
|
|
||||||
|
|
||||||
const pipTestCases = [
|
|
||||||
// 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 },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
pipTestCases.forEach(({ url, expected }, index) => {
|
|
||||||
it(`should parse pip URL ${index + 1}: ${url}`, () => {
|
|
||||||
const result = parsePackageFromUrl(url);
|
|
||||||
assert.deepEqual(result, 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -1,79 +1,45 @@
|
||||||
import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
import { isMalwarePackage } from "../../scanning/audit/index.js";
|
||||||
|
import { createInterceptorBuilder } 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;
|
const builder = createInterceptorBuilder();
|
||||||
if (!registry || !url.endsWith(".tgz")) {
|
|
||||||
return { packageName, version };
|
|
||||||
}
|
|
||||||
|
|
||||||
const registryIndex = url.indexOf(registry);
|
builder.onRequest(async (req) => {
|
||||||
const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
|
const { packageName, version } = parsePipPackageFromUrl(
|
||||||
|
req.targetUrl,
|
||||||
const separatorIndex = afterRegistry.indexOf("/-/");
|
registry
|
||||||
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);
|
req.blockRequest(403, "Forbidden - blocked by safe-chain");
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (filename.startsWith(packageName + "-")) {
|
|
||||||
version = filename.substring(packageName.length + 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return { packageName, version };
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -82,11 +48,11 @@ 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") {
|
||||||
return { packageName, version};
|
return { packageName, version };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick sanity check on the URL + parse
|
// Quick sanity check on the URL + parse
|
||||||
|
|
@ -94,13 +60,13 @@ function parsePipPackageFromUrl(url, registry) {
|
||||||
try {
|
try {
|
||||||
urlObj = new URL(url);
|
urlObj = new URL(url);
|
||||||
} catch {
|
} catch {
|
||||||
return { packageName, version};
|
return { packageName, version };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the last path segment (filename) and decode it (strip query & fragment automatically)
|
// Get the last path segment (filename) and decode it (strip query & fragment automatically)
|
||||||
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
|
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
|
||||||
if (!lastSegment){
|
if (!lastSegment) {
|
||||||
return { packageName, version};
|
return { packageName, version };
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = decodeURIComponent(lastSegment);
|
const filename = decodeURIComponent(lastSegment);
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -69,7 +69,7 @@ function createHttpsServer(hostname, interceptor) {
|
||||||
const targetUrl = `https://${hostname}${pathAndQuery}`;
|
const targetUrl = `https://${hostname}${pathAndQuery}`;
|
||||||
|
|
||||||
const interceptorResult = await interceptor.handleRequest(targetUrl);
|
const interceptorResult = await interceptor.handleRequest(targetUrl);
|
||||||
const blockResponse = interceptorResult?.blockResponse;
|
const blockResponse = interceptorResult.blockResponse;
|
||||||
|
|
||||||
if (blockResponse) {
|
if (blockResponse) {
|
||||||
ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`);
|
ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`);
|
||||||
|
|
|
||||||
|
|
@ -3,20 +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 { createInterceptorBuilder } from "./interceptors/interceptorBuilder.js";
|
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
|
||||||
|
|
||||||
const SERVER_STOP_TIMEOUT_MS = 1000;
|
const SERVER_STOP_TIMEOUT_MS = 1000;
|
||||||
/**
|
/**
|
||||||
|
|
@ -141,18 +130,10 @@ 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) {
|
mitmConnect(req, clientSocket, interceptor);
|
||||||
isKnownRegistry = knownJsRegistries.some((reg) => url.includes(reg));
|
|
||||||
} else if (ecosystem === ECOSYSTEM_PY) {
|
|
||||||
isKnownRegistry = knownPipRegistries.some((reg) => url.includes(reg));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isKnownRegistry) {
|
|
||||||
mitmConnect(req, clientSocket, createMitmInterceptor());
|
|
||||||
} 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}`);
|
||||||
|
|
@ -160,47 +141,6 @@ function handleConnect(req, clientSocket, head) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @returns {import("./interceptors/interceptorBuilder.js").Interceptor}
|
|
||||||
*/
|
|
||||||
function createMitmInterceptor() {
|
|
||||||
const builder = createInterceptorBuilder();
|
|
||||||
|
|
||||||
builder.onRequest(async (req) => {
|
|
||||||
if (!(await isAllowedUrl(req.targetUrl))) {
|
|
||||||
req.blockRequest(403, "Forbidden - blocked by safe-chain");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return builder.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} url
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
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() {
|
function verifyNoMaliciousPackages() {
|
||||||
if (state.blockedRequests.length === 0) {
|
if (state.blockedRequests.length === 0) {
|
||||||
// No malicious packages were blocked, so nothing to block
|
// No malicious packages were blocked, so nothing to block
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*
|
*
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue