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
13
packages/safe-chain/bin/aikido-poetry.js
Executable file
13
packages/safe-chain/bin/aikido-poetry.js
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
|
||||
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
initializePackageManager("poetry");
|
||||
|
||||
(async () => {
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
process.exit(exitCode);
|
||||
})();
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
"aikido-pip3": "bin/aikido-pip3.js",
|
||||
"aikido-python": "bin/aikido-python.js",
|
||||
"aikido-python3": "bin/aikido-python3.js",
|
||||
"aikido-poetry": "bin/aikido-poetry.js",
|
||||
"safe-chain": "bin/safe-chain.js"
|
||||
},
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ function uv() {
|
|||
wrapSafeChainCommand "uv" "$@"
|
||||
}
|
||||
|
||||
function poetry() {
|
||||
wrapSafeChainCommand "poetry" "$@"
|
||||
}
|
||||
|
||||
# `python -m pip`, `python -m pip3`.
|
||||
function python() {
|
||||
wrapSafeChainCommand "python" "$@"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue