Merge pull request #178 from AikidoSec/feature/poetry-2

Add Poetry support
This commit is contained in:
bitterpanda 2025-12-05 15:56:20 +01:00 committed by GitHub
commit 15cc6ff7fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 670 additions and 35 deletions

View file

@ -11,6 +11,7 @@ import {
import { createYarnPackageManager } from "./yarn/createPackageManager.js";
import { createPipPackageManager } from "./pip/createPackageManager.js";
import { createUvPackageManager } from "./uv/createUvPackageManager.js";
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
/**
* @type {{packageManagerName: PackageManager | null}}
@ -58,6 +59,8 @@ export function initializePackageManager(packageManagerName, context) {
state.packageManagerName = createPipPackageManager(context);
} else if (packageManagerName === "uv") {
state.packageManagerName = createUvPackageManager();
} else if (packageManagerName === "poetry") {
state.packageManagerName = createPoetryPackageManager();
} else {
throw new Error("Unsupported package manager: " + packageManagerName);
}

View file

@ -0,0 +1,77 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createPoetryPackageManager() {
return {
runCommand: (args) => runPoetryCommand(args),
// MITM only approach for Poetry
isSupportedCommand: () => false,
getDependencyUpdatesForCommand: () => [],
};
}
/**
* Sets CA bundle environment variables used by Poetry and Python libraries.
* Poetry uses the Python requests library which respects these environment variables.
*
* @param {NodeJS.ProcessEnv} env - Environment object to modify
* @param {string} combinedCaPath - Path to the combined CA bundle
*/
function setPoetryCaBundleEnvironmentVariables(env, combinedCaPath) {
// SSL_CERT_FILE: Used by Python SSL libraries and requests
if (env.SSL_CERT_FILE) {
ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.");
}
env.SSL_CERT_FILE = combinedCaPath;
// REQUESTS_CA_BUNDLE: Used by the requests library (which Poetry uses)
if (env.REQUESTS_CA_BUNDLE) {
ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.");
}
env.REQUESTS_CA_BUNDLE = combinedCaPath;
// PIP_CERT: Poetry may use pip internally
if (env.PIP_CERT) {
ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.");
}
env.PIP_CERT = combinedCaPath;
}
/**
* Runs a poetry command with safe-chain's certificate bundle and proxy configuration.
*
* Poetry respects standard HTTP_PROXY/HTTPS_PROXY environment variables through
* the Python requests library.
*
* @param {string[]} args - Command line arguments to pass to poetry
* @returns {Promise<{status: number}>} Exit status of the poetry command
*/
async function runPoetryCommand(args) {
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
const combinedCaPath = getCombinedCaBundlePath();
setPoetryCaBundleEnvironmentVariables(env, combinedCaPath);
const result = await safeSpawn("poetry", args, {
stdio: "inherit",
env,
});
return { status: result.status };
} catch (/** @type any */ error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
ui.writeError("Is 'poetry' installed and available on your system?");
return { status: 1 };
}
}
}

View file

@ -0,0 +1,14 @@
import { test } from "node:test";
import assert from "node:assert";
import { createPoetryPackageManager } from "./createPoetryPackageManager.js";
test("createPoetryPackageManager", async (t) => {
await t.test("should create package manager with required interface", () => {
const pm = createPoetryPackageManager();
assert.ok(pm);
assert.strictEqual(typeof pm.runCommand, "function");
assert.strictEqual(typeof pm.isSupportedCommand, "function");
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
});
});

View file

@ -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());

View file

@ -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 };
}

View file

@ -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
{

View file

@ -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(),
};
}

View file

@ -76,6 +76,12 @@ export const knownAikidoTools = [
ecoSystem: ECOSYSTEM_PY,
internalPackageManagerName: "pip",
},
{
tool: "poetry",
aikidoCommand: "aikido-poetry",
ecoSystem: ECOSYSTEM_PY,
internalPackageManagerName: "poetry",
},
{
tool: "python",
aikidoCommand: "aikido-python",

View file

@ -52,6 +52,10 @@ function uv
wrapSafeChainCommand "uv" $argv
end
function poetry
wrapSafeChainCommand "poetry" $argv
end
# `python -m pip`, `python -m pip3`.
function python
wrapSafeChainCommand "python" $argv

View file

@ -48,6 +48,10 @@ function uv() {
wrapSafeChainCommand "uv" "$@"
}
function poetry() {
wrapSafeChainCommand "poetry" "$@"
}
# `python -m pip`, `python -m pip3`.
function python() {
wrapSafeChainCommand "python" "$@"

View file

@ -50,6 +50,10 @@ function uv {
Invoke-WrappedCommand "uv" $args
}
function poetry {
Invoke-WrappedCommand "poetry" $args
}
# `python -m pip`, `python -m pip3`.
function python {
Invoke-WrappedCommand 'python' $args