mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge pull request #178 from AikidoSec/feature/poetry-2
Add Poetry support
This commit is contained in:
commit
15cc6ff7fe
19 changed files with 670 additions and 35 deletions
|
|
@ -8,6 +8,17 @@ const ca = loadCa();
|
|||
|
||||
const certCache = new Map();
|
||||
|
||||
/**
|
||||
* @param {forge.pki.PublicKey} publicKey
|
||||
* @returns {string}
|
||||
*/
|
||||
function createKeyIdentifier(publicKey) {
|
||||
return forge.pki.getPublicKeyFingerprint(publicKey, {
|
||||
encoding: "binary",
|
||||
md: forge.md.sha1.create(),
|
||||
});
|
||||
}
|
||||
|
||||
export function getCaCertPath() {
|
||||
return path.join(certFolder, "ca-cert.pem");
|
||||
}
|
||||
|
|
@ -33,6 +44,7 @@ export function generateCertForHost(hostname) {
|
|||
const attrs = [{ name: "commonName", value: hostname }];
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(ca.certificate.subject.attributes);
|
||||
const authorityKeyIdentifier = createKeyIdentifier(ca.certificate.publicKey);
|
||||
cert.setExtensions([
|
||||
{
|
||||
name: "subjectAltName",
|
||||
|
|
@ -50,14 +62,42 @@ export function generateCertForHost(hostname) {
|
|||
},
|
||||
{
|
||||
/*
|
||||
extKeyUsage serverAuth is required for TLS server authentication.
|
||||
This is especially important for Python venv environments, which use their own
|
||||
certificate validation logic and will reject certificates lacking the serverAuth EKU.
|
||||
Adding serverAuth does not impact other usages
|
||||
Extended Key Usage (EKU) serverAuth extension
|
||||
|
||||
Needed for TLS server authentication. This extension indicates the certificate's
|
||||
public key may be used for TLS WWW server authentication.
|
||||
Python virtualenv environments (like pipx-installed Poetry) enforce this strictly
|
||||
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.12
|
||||
*/
|
||||
name: "extKeyUsage",
|
||||
serverAuth: true,
|
||||
},
|
||||
{
|
||||
/*
|
||||
Subject Key Identifier (SKI)
|
||||
|
||||
Needed for Python virtualenv SSL validation and certificate chain building.
|
||||
This extension provides a means of identifying certificates containing a particular public key.
|
||||
Python virtualenv environments require this for proper certificate chain validation.
|
||||
System Python installations may be more lenient.
|
||||
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.2
|
||||
*/
|
||||
name: "subjectKeyIdentifier",
|
||||
subjectKeyIdentifier: createKeyIdentifier(cert.publicKey),
|
||||
},
|
||||
{
|
||||
/*
|
||||
Authority Key Identifier (AKI)
|
||||
|
||||
Needed for Python virtualenv SSL validation and certificate path validation.
|
||||
This extension identifies the public key corresponding to the private key used to sign
|
||||
this certificate. It links this certificate to its issuing CA certificate.
|
||||
Without this, Python virtualenv certificate validation might fail (for instance for Poetry)
|
||||
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.1
|
||||
*/
|
||||
name: "authorityKeyIdentifier",
|
||||
keyIdentifier: authorityKeyIdentifier,
|
||||
},
|
||||
]);
|
||||
cert.sign(ca.privateKey, forge.md.sha256.create());
|
||||
|
||||
|
|
@ -106,11 +146,13 @@ function generateCa() {
|
|||
|
||||
const attrs = [{ name: "commonName", value: "safe-chain proxy" }];
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(attrs);
|
||||
cert.setIssuer(attrs); // Self-signed: issuer === subject
|
||||
const keyIdentifier = createKeyIdentifier(cert.publicKey);
|
||||
cert.setExtensions([
|
||||
{
|
||||
name: "basicConstraints",
|
||||
cA: true,
|
||||
critical: true, // Marking basicConstraints as critical is required for CA certificates so clients must process it to trust the cert as a CA
|
||||
},
|
||||
{
|
||||
name: "keyUsage",
|
||||
|
|
@ -118,6 +160,14 @@ function generateCa() {
|
|||
digitalSignature: true,
|
||||
keyEncipherment: true,
|
||||
},
|
||||
{
|
||||
name: "subjectKeyIdentifier",
|
||||
subjectKeyIdentifier: keyIdentifier,
|
||||
},
|
||||
{
|
||||
name: "authorityKeyIdentifier",
|
||||
keyIdentifier,
|
||||
},
|
||||
]);
|
||||
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,16 @@ function buildPipInterceptor(registry) {
|
|||
reqContext.targetUrl,
|
||||
registry
|
||||
);
|
||||
if (await isMalwarePackage(packageName, version)) {
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
|
@ -71,16 +80,21 @@ function parsePipPackageFromUrl(url, registry) {
|
|||
// 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"
|
||||
// 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; // preserve underscores
|
||||
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
|
||||
|
|
@ -92,10 +106,11 @@ function parsePipPackageFromUrl(url, registry) {
|
|||
}
|
||||
}
|
||||
|
||||
// Source dist (sdist)
|
||||
const sdistExtMatch = filename.match(/\.(tar\.gz|zip|tar\.bz2|tar\.xz)$/i);
|
||||
// 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.slice(0, -sdistExtMatch[0].length);
|
||||
const base = filename.replace(sdistExtWithMetadataRe, "");
|
||||
const lastDash = base.lastIndexOf("-");
|
||||
if (lastDash > 0 && lastDash < base.length - 1) {
|
||||
packageName = base.slice(0, lastDash);
|
||||
|
|
@ -109,7 +124,6 @@ function parsePipPackageFromUrl(url, registry) {
|
|||
return { packageName, version };
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown file type or invalid
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,11 +32,16 @@ describe("pipInterceptor", async () => {
|
|||
},
|
||||
{
|
||||
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" },
|
||||
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" },
|
||||
},
|
||||
{
|
||||
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" },
|
||||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo.bar/foo.bar-1.0.0.tar.gz",
|
||||
|
|
@ -44,27 +49,32 @@ describe("pipInterceptor", async () => {
|
|||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0b1.tar.gz",
|
||||
expected: { packageName: "foo_bar", version: "2.0.0b1" },
|
||||
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" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0rc1.tar.gz",
|
||||
expected: { packageName: "foo_bar", version: "2.0.0rc1" },
|
||||
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" },
|
||||
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" },
|
||||
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" },
|
||||
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" },
|
||||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
// Invalid pip URLs
|
||||
{
|
||||
|
|
|
|||
|
|
@ -36,9 +36,10 @@ function getSafeChainProxyEnvironmentVariables() {
|
|||
return {};
|
||||
}
|
||||
|
||||
const proxyUrl = `http://localhost:${state.port}`;
|
||||
return {
|
||||
HTTPS_PROXY: `http://localhost:${state.port}`,
|
||||
GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`,
|
||||
HTTPS_PROXY: proxyUrl,
|
||||
GLOBAL_AGENT_HTTP_PROXY: proxyUrl,
|
||||
NODE_EXTRA_CA_CERTS: getCaCertPath(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue