mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Add minimum package age check for pypi
This commit is contained in:
parent
2c8a1b4972
commit
fd6fb456b4
22 changed files with 516 additions and 273 deletions
|
|
@ -4,7 +4,7 @@ import {
|
|||
getEcoSystem,
|
||||
} from "../../config/settings.js";
|
||||
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
|
||||
import { pipInterceptorForUrl } from "./pipInterceptor.js";
|
||||
import { pipInterceptorForUrl } from "./pip/pipInterceptor.js";
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { getMinimumPackageAgeExclusions, getEcoSystem } from "../../config/settings.js";
|
||||
import { getEquivalentPackageNames } from "../../scanning/packageNameVariants.js";
|
||||
|
||||
/**
|
||||
* Checks if a package name matches an exclusion pattern.
|
||||
* Supports trailing wildcard (*) for prefix matching.
|
||||
* @param {string} packageName
|
||||
* @param {string} pattern
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function matchesExclusionPattern(packageName, pattern) {
|
||||
if (pattern.endsWith("/*")) {
|
||||
return packageName.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
return packageName === pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} packageName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isExcludedFromMinimumPackageAge(packageName) {
|
||||
if (!packageName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const exclusions = getMinimumPackageAgeExclusions();
|
||||
const candidateNames = getEquivalentPackageNames(packageName, getEcoSystem());
|
||||
|
||||
return exclusions.some((pattern) =>
|
||||
candidateNames.some((name) => matchesExclusionPattern(name, pattern))
|
||||
);
|
||||
}
|
||||
|
|
@ -196,17 +196,3 @@ export function getPackageNameFromMetadataResponse(body, headers) {
|
|||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a package name matches an exclusion pattern.
|
||||
* Supports trailing wildcard (*) for prefix matching.
|
||||
* @param {string} packageName
|
||||
* @param {string} pattern
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function matchesExclusionPattern(packageName, pattern) {
|
||||
if (pattern.endsWith("/*")) {
|
||||
return packageName.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
return packageName === pattern;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import {
|
||||
getNpmCustomRegistries,
|
||||
getNpmMinimumPackageAgeExclusions,
|
||||
skipMinimumPackageAge,
|
||||
} from "../../../config/settings.js";
|
||||
import { isMalwarePackage } from "../../../scanning/audit/index.js";
|
||||
|
|
@ -8,12 +7,14 @@ import { interceptRequests } from "../interceptorBuilder.js";
|
|||
import {
|
||||
getPackageNameFromMetadataResponse,
|
||||
isPackageInfoUrl,
|
||||
matchesExclusionPattern,
|
||||
modifyNpmInfoRequestHeaders,
|
||||
modifyNpmInfoResponse,
|
||||
} from "./modifyNpmInfo.js";
|
||||
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
|
||||
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
|
||||
import {
|
||||
isExcludedFromMinimumPackageAge,
|
||||
} from "../minimumPackageAgeExclusions.js";
|
||||
|
||||
const knownJsRegistries = [
|
||||
"registry.npmjs.org",
|
||||
|
|
@ -81,17 +82,6 @@ function buildNpmInterceptor(registry) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} packageName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isExcludedFromMinimumPackageAge(packageName) {
|
||||
const exclusions = getNpmMinimumPackageAgeExclusions();
|
||||
return exclusions.some((pattern) =>
|
||||
matchesExclusionPattern(packageName, pattern)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} body
|
||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ describe("npmInterceptor minimum package age", async () => {
|
|||
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
|
||||
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
||||
getNpmCustomRegistries: () => [],
|
||||
getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
|
||||
getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
|
||||
getEcoSystem: () => "js",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ mock.module("../../../config/settings.js", {
|
|||
setEcoSystem: () => {},
|
||||
getMinimumPackageAgeHours: () => 24,
|
||||
getNpmCustomRegistries: () => customRegistries,
|
||||
getNpmMinimumPackageAgeExclusions: () => [],
|
||||
getMinimumPackageAgeExclusions: () => [],
|
||||
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* @param {string} url
|
||||
* @param {string} registry
|
||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||
*/
|
||||
export function parsePipPackageFromUrl(url, registry) {
|
||||
let packageName, version;
|
||||
|
||||
if (!registry || typeof url !== "string") {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
let urlObj;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
|
||||
if (!lastSegment) {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
const filename = decodeURIComponent(lastSegment);
|
||||
|
||||
const wheelExtRe = /\.whl(?:\.metadata)?$/;
|
||||
if (wheelExtRe.test(filename)) {
|
||||
const base = filename.replace(wheelExtRe, "");
|
||||
const firstDash = base.indexOf("-");
|
||||
if (firstDash > 0) {
|
||||
const dist = base.slice(0, firstDash);
|
||||
const rest = base.slice(firstDash + 1);
|
||||
const secondDash = rest.indexOf("-");
|
||||
const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
|
||||
packageName = dist;
|
||||
version = rawVersion;
|
||||
|
||||
if (version === "latest" || !packageName || !version) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
||||
return { packageName, version };
|
||||
}
|
||||
}
|
||||
|
||||
const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
|
||||
if (sdistExtWithMetadataRe.test(filename)) {
|
||||
const base = filename.replace(sdistExtWithMetadataRe, "");
|
||||
const lastDash = base.lastIndexOf("-");
|
||||
if (lastDash > 0 && lastDash < base.length - 1) {
|
||||
packageName = base.slice(0, lastDash);
|
||||
version = base.slice(lastDash + 1);
|
||||
|
||||
if (version === "latest" || !packageName || !version) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
||||
return { packageName, version };
|
||||
}
|
||||
}
|
||||
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
|
@ -6,13 +6,25 @@ describe("pipInterceptor custom registries", async () => {
|
|||
let malwareResponse = false;
|
||||
let customRegistries = [];
|
||||
|
||||
mock.module("../../config/settings.js", {
|
||||
mock.module("../../../config/settings.js", {
|
||||
namedExports: {
|
||||
ECOSYSTEM_PY: "py",
|
||||
getEcoSystem: () => "py",
|
||||
getMinimumPackageAgeExclusions: () => [],
|
||||
getPipCustomRegistries: () => customRegistries,
|
||||
skipMinimumPackageAge: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../scanning/audit/index.js", {
|
||||
mock.module("../../../scanning/newPackagesListCache.js", {
|
||||
namedExports: {
|
||||
openNewPackagesDatabase: async () => ({
|
||||
isNewlyReleasedPackage: () => false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async (packageName, version) => {
|
||||
lastPackage = { packageName, version };
|
||||
|
|
@ -30,10 +42,7 @@ describe("pipInterceptor custom registries", async () => {
|
|||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
assert.ok(
|
||||
interceptor,
|
||||
"Interceptor should be created for custom registry"
|
||||
);
|
||||
assert.ok(interceptor);
|
||||
});
|
||||
|
||||
it("should parse package from custom registry URL", async () => {
|
||||
|
|
@ -42,7 +51,7 @@ describe("pipInterceptor custom registries", async () => {
|
|||
"https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor, "Interceptor should be created");
|
||||
assert.ok(interceptor);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
|
|
@ -58,7 +67,7 @@ describe("pipInterceptor custom registries", async () => {
|
|||
"https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor, "Interceptor should be created");
|
||||
assert.ok(interceptor);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
|
|
@ -82,11 +91,8 @@ describe("pipInterceptor custom registries", async () => {
|
|||
const interceptor1 = pipInterceptorForUrl(url1);
|
||||
const interceptor2 = pipInterceptorForUrl(url2);
|
||||
|
||||
assert.ok(interceptor1, "Interceptor should be created for first registry");
|
||||
assert.ok(
|
||||
interceptor2,
|
||||
"Interceptor should be created for second registry"
|
||||
);
|
||||
assert.ok(interceptor1);
|
||||
assert.ok(interceptor2);
|
||||
});
|
||||
|
||||
it("should block malicious package from custom registry", async () => {
|
||||
|
|
@ -97,21 +103,13 @@ describe("pipInterceptor custom registries", async () => {
|
|||
"https://my-custom-registry.example.com/packages/malicious_package-1.0.0.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor, "Interceptor should be created");
|
||||
assert.ok(interceptor);
|
||||
|
||||
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"
|
||||
);
|
||||
assert.ok(result.blockResponse);
|
||||
assert.equal(result.blockResponse.statusCode, 403);
|
||||
assert.equal(result.blockResponse.message, "Forbidden - blocked by safe-chain");
|
||||
|
||||
malwareResponse = false;
|
||||
});
|
||||
|
|
@ -124,10 +122,7 @@ describe("pipInterceptor custom registries", async () => {
|
|||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
assert.ok(
|
||||
interceptor,
|
||||
"Interceptor should be created for known registry even with custom registries set"
|
||||
);
|
||||
assert.ok(interceptor);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
|
|
@ -143,11 +138,7 @@ describe("pipInterceptor custom registries", async () => {
|
|||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
assert.equal(
|
||||
interceptor,
|
||||
undefined,
|
||||
"Interceptor should be undefined for unknown registry"
|
||||
);
|
||||
assert.equal(interceptor, undefined);
|
||||
});
|
||||
|
||||
it("should handle empty custom registries array", () => {
|
||||
|
|
@ -157,11 +148,7 @@ describe("pipInterceptor custom registries", async () => {
|
|||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
assert.equal(
|
||||
interceptor,
|
||||
undefined,
|
||||
"Interceptor should be undefined when no custom registries are configured"
|
||||
);
|
||||
assert.equal(interceptor, undefined);
|
||||
});
|
||||
|
||||
it("should parse .whl.metadata from custom registry", async () => {
|
||||
|
|
@ -170,7 +157,7 @@ describe("pipInterceptor custom registries", async () => {
|
|||
"https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl.metadata";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor, "Interceptor should be created");
|
||||
assert.ok(interceptor);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
|
|
@ -186,7 +173,7 @@ describe("pipInterceptor custom registries", async () => {
|
|||
"https://private-pypi.internal.com/packages/foo_bar-2.0.0.tar.gz.metadata";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor, "Interceptor should be created");
|
||||
assert.ok(interceptor);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
|
|
@ -196,4 +183,3 @@ describe("pipInterceptor custom registries", async () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import {
|
||||
getPipCustomRegistries,
|
||||
skipMinimumPackageAge,
|
||||
} from "../../../config/settings.js";
|
||||
import { isMalwarePackage } from "../../../scanning/audit/index.js";
|
||||
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
|
||||
import { interceptRequests } from "../interceptorBuilder.js";
|
||||
import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js";
|
||||
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.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 customRegistries = getPipCustomRegistries();
|
||||
const registries = [...knownPipRegistries, ...customRegistries];
|
||||
const registry = registries.find((reg) => url.includes(reg));
|
||||
|
||||
if (registry) {
|
||||
return buildPipInterceptor(registry);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} registry
|
||||
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
function buildPipInterceptor(registry) {
|
||||
return interceptRequests(async (reqContext) => {
|
||||
const { packageName, version } = parsePipPackageFromUrl(
|
||||
reqContext.targetUrl,
|
||||
registry
|
||||
);
|
||||
|
||||
// PyPI treats hyphens and underscores as equivalent distribution names.
|
||||
const hyphenName = packageName?.includes("_")
|
||||
? packageName.replace(/_/g, "-")
|
||||
: packageName;
|
||||
|
||||
const isMalicious =
|
||||
await isMalwarePackage(packageName, version) ||
|
||||
await isMalwarePackage(hyphenName, version);
|
||||
|
||||
if (isMalicious) {
|
||||
reqContext.blockMalware(packageName, version);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
packageName &&
|
||||
version &&
|
||||
!skipMinimumPackageAge() &&
|
||||
!isExcludedFromMinimumPackageAge(packageName)
|
||||
) {
|
||||
const newPackagesDatabase = await openNewPackagesDatabase();
|
||||
const isNewlyReleased = newPackagesDatabase.isNewlyReleasedPackage(
|
||||
packageName,
|
||||
version
|
||||
);
|
||||
|
||||
if (isNewlyReleased) {
|
||||
reqContext.blockMinimumAgeRequest(
|
||||
packageName,
|
||||
version,
|
||||
`Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("pipInterceptor minimum package age", async () => {
|
||||
let skipMinimumPackageAgeSetting = false;
|
||||
let newlyReleasedPackageResponse = false;
|
||||
let minimumPackageAgeExclusionsSetting = [];
|
||||
|
||||
mock.module("../../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async () => false,
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../scanning/newPackagesListCache.js", {
|
||||
namedExports: {
|
||||
openNewPackagesDatabase: async () => ({
|
||||
isNewlyReleasedPackage: (packageName, version) => {
|
||||
return newlyReleasedPackageResponse &&
|
||||
(packageName === "foo-bar" ||
|
||||
packageName === "foo_bar" ||
|
||||
packageName === "foo.bar") &&
|
||||
version === "2.0.0";
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../config/settings.js", {
|
||||
namedExports: {
|
||||
ECOSYSTEM_PY: "py",
|
||||
getEcoSystem: () => "py",
|
||||
getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
|
||||
getPipCustomRegistries: () => [],
|
||||
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
||||
},
|
||||
});
|
||||
|
||||
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
|
||||
|
||||
it("should block newly released package downloads", async () => {
|
||||
const url =
|
||||
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
|
||||
newlyReleasedPackageResponse = true;
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.ok(result.blockResponse);
|
||||
assert.equal(result.blockResponse.statusCode, 403);
|
||||
assert.equal(
|
||||
result.blockResponse.message,
|
||||
"Forbidden - blocked by safe-chain direct download minimum package age (foo_bar@2.0.0)"
|
||||
);
|
||||
|
||||
newlyReleasedPackageResponse = false;
|
||||
});
|
||||
|
||||
it("should not block newly released package downloads when skipMinimumPackageAge is enabled", async () => {
|
||||
const url =
|
||||
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
|
||||
newlyReleasedPackageResponse = true;
|
||||
skipMinimumPackageAgeSetting = true;
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.equal(result.blockResponse, undefined);
|
||||
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
newlyReleasedPackageResponse = false;
|
||||
});
|
||||
|
||||
it("should not block newly released package downloads when the package is excluded", async () => {
|
||||
const url =
|
||||
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
|
||||
newlyReleasedPackageResponse = true;
|
||||
minimumPackageAgeExclusionsSetting = ["foo-bar"];
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.equal(result.blockResponse, undefined);
|
||||
|
||||
minimumPackageAgeExclusionsSetting = [];
|
||||
newlyReleasedPackageResponse = false;
|
||||
});
|
||||
|
||||
it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => {
|
||||
const url =
|
||||
"https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz";
|
||||
newlyReleasedPackageResponse = true;
|
||||
minimumPackageAgeExclusionsSetting = ["foo-bar"];
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.equal(result.blockResponse, undefined);
|
||||
|
||||
minimumPackageAgeExclusionsSetting = [];
|
||||
newlyReleasedPackageResponse = false;
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,7 @@ describe("pipInterceptor", async () => {
|
|||
let lastPackage;
|
||||
let malwareResponse = false;
|
||||
|
||||
mock.module("../../scanning/audit/index.js", {
|
||||
mock.module("../../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async (packageName, version) => {
|
||||
lastPackage = { packageName, version };
|
||||
|
|
@ -14,10 +14,27 @@ describe("pipInterceptor", async () => {
|
|||
},
|
||||
});
|
||||
|
||||
mock.module("../../../scanning/newPackagesListCache.js", {
|
||||
namedExports: {
|
||||
openNewPackagesDatabase: async () => ({
|
||||
isNewlyReleasedPackage: () => false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../config/settings.js", {
|
||||
namedExports: {
|
||||
ECOSYSTEM_PY: "py",
|
||||
getEcoSystem: () => "py",
|
||||
getMinimumPackageAgeExclusions: () => [],
|
||||
getPipCustomRegistries: () => [],
|
||||
skipMinimumPackageAge: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
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" },
|
||||
|
|
@ -35,7 +52,6 @@ describe("pipInterceptor", async () => {
|
|||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
{
|
||||
// Poetry preflight metadata alongside wheel (.whl.metadata)
|
||||
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl.metadata",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
|
|
@ -52,7 +68,6 @@ describe("pipInterceptor", async () => {
|
|||
expected: { packageName: "foo-bar", version: "2.0.0b1" },
|
||||
},
|
||||
{
|
||||
// sdist with metadata sidecar (.tar.gz.metadata)
|
||||
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz.metadata",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
|
|
@ -76,7 +91,6 @@ describe("pipInterceptor", async () => {
|
|||
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 },
|
||||
|
|
@ -98,10 +112,7 @@ describe("pipInterceptor", async () => {
|
|||
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"
|
||||
);
|
||||
assert.ok(interceptor, "Interceptor should be created for known pip registry");
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
|
|
@ -111,14 +122,8 @@ describe("pipInterceptor", async () => {
|
|||
|
||||
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"
|
||||
);
|
||||
assert.equal(interceptor, undefined);
|
||||
});
|
||||
|
||||
it("should block malicious package", async () => {
|
||||
|
|
@ -127,19 +132,15 @@ describe("pipInterceptor", async () => {
|
|||
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.ok(result.blockResponse);
|
||||
assert.equal(result.blockResponse.statusCode, 403);
|
||||
assert.equal(
|
||||
result.blockResponse.message,
|
||||
"Forbidden - blocked by safe-chain",
|
||||
"Block response should have correct status message"
|
||||
"Forbidden - blocked by safe-chain"
|
||||
);
|
||||
|
||||
malwareResponse = false;
|
||||
});
|
||||
});
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import { getPipCustomRegistries } from "../../config/settings.js";
|
||||
import { isMalwarePackage } from "../../scanning/audit/index.js";
|
||||
import { interceptRequests } 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 customRegistries = getPipCustomRegistries();
|
||||
const registries = [...knownPipRegistries, ...customRegistries];
|
||||
const registry = registries.find((reg) => url.includes(reg));
|
||||
|
||||
if (registry) {
|
||||
return buildPipInterceptor(registry);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} registry
|
||||
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
function buildPipInterceptor(registry) {
|
||||
return interceptRequests(async (reqContext) => {
|
||||
const { packageName, version } = parsePipPackageFromUrl(
|
||||
reqContext.targetUrl,
|
||||
registry
|
||||
);
|
||||
|
||||
// Normalize underscores to hyphens for DB matching, as PyPI allows underscores in distribution names.
|
||||
// Per python, packages that differ only by hyphen vs underscore are considered the same.
|
||||
const hyphenName = packageName?.includes("_") ? packageName.replace(/_/g, "-") : packageName;
|
||||
|
||||
const isMalicious =
|
||||
await isMalwarePackage(packageName, version)
|
||||
|| await isMalwarePackage(hyphenName, version);
|
||||
|
||||
if (isMalicious) {
|
||||
reqContext.blockMalware(packageName, version);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @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) and Poetry's preflight metadata (.whl.metadata)
|
||||
// Examples:
|
||||
// foo_bar-2.0.0-py3-none-any.whl
|
||||
// foo_bar-2.0.0-py3-none-any.whl.metadata
|
||||
const wheelExtRe = /\.whl(?:\.metadata)?$/;
|
||||
const wheelExtMatch = filename.match(wheelExtRe);
|
||||
if (wheelExtMatch) {
|
||||
const base = filename.replace(wheelExtRe, "");
|
||||
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;
|
||||
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) and potential metadata sidecars (e.g., .tar.gz.metadata)
|
||||
const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
|
||||
const sdistExtMatch = filename.match(sdistExtWithMetadataRe);
|
||||
if (sdistExtMatch) {
|
||||
const base = filename.replace(sdistExtWithMetadataRe, "");
|
||||
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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue