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

@ -22,6 +22,7 @@ Aikido Safe Chain supports the following package managers:
- 📦 **pip** (beta) - 📦 **pip** (beta)
- 📦 **pip3** (beta) - 📦 **pip3** (beta)
- 📦 **uv** (beta) - 📦 **uv** (beta)
- 📦 **poetry** (beta)
# Usage # Usage
@ -81,7 +82,7 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
- The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware.
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `uv`, `pip`, or `pip3` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `uv`, `pip`, `pip3` or `poetry` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
You can check the installed version by running: You can check the installed version by running:
@ -93,13 +94,13 @@ safe-chain --version
### Malware Blocking ### Malware Blocking
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, `pip`, or `pip3` commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, pip, pip3 or poetry commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
### Minimum package age (npm only) ### Minimum package age (npm only)
For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below.
⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3). ⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3, poetry).
### Shell Integration ### Shell Integration
@ -235,7 +236,7 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
run: npm ci run: npm ci
``` ```
> **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv) support. > **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv/poetry) support.
## Azure DevOps Example ## Azure DevOps Example
@ -252,6 +253,6 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
displayName: "Install dependencies" displayName: "Install dependencies"
``` ```
> **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv) support. > **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv/poetry) support.
After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection.

1
package-lock.json generated
View file

@ -3133,6 +3133,7 @@
"aikido-pip3": "bin/aikido-pip3.js", "aikido-pip3": "bin/aikido-pip3.js",
"aikido-pnpm": "bin/aikido-pnpm.js", "aikido-pnpm": "bin/aikido-pnpm.js",
"aikido-pnpx": "bin/aikido-pnpx.js", "aikido-pnpx": "bin/aikido-pnpx.js",
"aikido-poetry": "bin/aikido-poetry.js",
"aikido-python": "bin/aikido-python.js", "aikido-python": "bin/aikido-python.js",
"aikido-python3": "bin/aikido-python3.js", "aikido-python3": "bin/aikido-python3.js",
"aikido-uv": "bin/aikido-uv.js", "aikido-uv": "bin/aikido-uv.js",

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

View file

@ -20,6 +20,7 @@
"aikido-pip3": "bin/aikido-pip3.js", "aikido-pip3": "bin/aikido-pip3.js",
"aikido-python": "bin/aikido-python.js", "aikido-python": "bin/aikido-python.js",
"aikido-python3": "bin/aikido-python3.js", "aikido-python3": "bin/aikido-python3.js",
"aikido-poetry": "bin/aikido-poetry.js",
"safe-chain": "bin/safe-chain.js" "safe-chain": "bin/safe-chain.js"
}, },
"type": "module", "type": "module",

View file

@ -11,6 +11,7 @@ import {
import { createYarnPackageManager } from "./yarn/createPackageManager.js"; import { createYarnPackageManager } from "./yarn/createPackageManager.js";
import { createPipPackageManager } from "./pip/createPackageManager.js"; import { createPipPackageManager } from "./pip/createPackageManager.js";
import { createUvPackageManager } from "./uv/createUvPackageManager.js"; import { createUvPackageManager } from "./uv/createUvPackageManager.js";
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
/** /**
* @type {{packageManagerName: PackageManager | null}} * @type {{packageManagerName: PackageManager | null}}
@ -58,6 +59,8 @@ export function initializePackageManager(packageManagerName, context) {
state.packageManagerName = createPipPackageManager(context); state.packageManagerName = createPipPackageManager(context);
} else if (packageManagerName === "uv") { } else if (packageManagerName === "uv") {
state.packageManagerName = createUvPackageManager(); state.packageManagerName = createUvPackageManager();
} else if (packageManagerName === "poetry") {
state.packageManagerName = createPoetryPackageManager();
} else { } else {
throw new Error("Unsupported package manager: " + packageManagerName); 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(); 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() { export function getCaCertPath() {
return path.join(certFolder, "ca-cert.pem"); return path.join(certFolder, "ca-cert.pem");
} }
@ -33,6 +44,7 @@ export function generateCertForHost(hostname) {
const attrs = [{ name: "commonName", value: hostname }]; const attrs = [{ name: "commonName", value: hostname }];
cert.setSubject(attrs); cert.setSubject(attrs);
cert.setIssuer(ca.certificate.subject.attributes); cert.setIssuer(ca.certificate.subject.attributes);
const authorityKeyIdentifier = createKeyIdentifier(ca.certificate.publicKey);
cert.setExtensions([ cert.setExtensions([
{ {
name: "subjectAltName", name: "subjectAltName",
@ -50,14 +62,42 @@ export function generateCertForHost(hostname) {
}, },
{ {
/* /*
extKeyUsage serverAuth is required for TLS server authentication. Extended Key Usage (EKU) serverAuth extension
This is especially important for Python venv environments, which use their own
certificate validation logic and will reject certificates lacking the serverAuth EKU. Needed for TLS server authentication. This extension indicates the certificate's
Adding serverAuth does not impact other usages 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", name: "extKeyUsage",
serverAuth: true, 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()); cert.sign(ca.privateKey, forge.md.sha256.create());
@ -106,11 +146,13 @@ function generateCa() {
const attrs = [{ name: "commonName", value: "safe-chain proxy" }]; const attrs = [{ name: "commonName", value: "safe-chain proxy" }];
cert.setSubject(attrs); cert.setSubject(attrs);
cert.setIssuer(attrs); cert.setIssuer(attrs); // Self-signed: issuer === subject
const keyIdentifier = createKeyIdentifier(cert.publicKey);
cert.setExtensions([ cert.setExtensions([
{ {
name: "basicConstraints", name: "basicConstraints",
cA: true, 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", name: "keyUsage",
@ -118,6 +160,14 @@ function generateCa() {
digitalSignature: true, digitalSignature: true,
keyEncipherment: true, keyEncipherment: true,
}, },
{
name: "subjectKeyIdentifier",
subjectKeyIdentifier: keyIdentifier,
},
{
name: "authorityKeyIdentifier",
keyIdentifier,
},
]); ]);
cert.sign(keys.privateKey, forge.md.sha256.create()); cert.sign(keys.privateKey, forge.md.sha256.create());

View file

@ -32,7 +32,16 @@ function buildPipInterceptor(registry) {
reqContext.targetUrl, reqContext.targetUrl,
registry 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); 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 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 // Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz
// Wheel (.whl) // Wheel (.whl) and Poetry's preflight metadata (.whl.metadata)
if (filename.endsWith(".whl")) { // Examples:
const base = filename.slice(0, -4); // remove ".whl" // 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("-"); const firstDash = base.indexOf("-");
if (firstDash > 0) { if (firstDash > 0) {
const dist = base.slice(0, firstDash); // may contain underscores const dist = base.slice(0, firstDash); // may contain underscores
const rest = base.slice(firstDash + 1); // version + the rest of tags const rest = base.slice(firstDash + 1); // version + the rest of tags
const secondDash = rest.indexOf("-"); const secondDash = rest.indexOf("-");
const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest; const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
packageName = dist; // preserve underscores packageName = dist;
version = rawVersion; version = rawVersion;
// Reject "latest" as it's a placeholder, not a real version // 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 // 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) // Source dist (sdist) and potential metadata sidecars (e.g., .tar.gz.metadata)
const sdistExtMatch = filename.match(/\.(tar\.gz|zip|tar\.bz2|tar\.xz)$/i); const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
const sdistExtMatch = filename.match(sdistExtWithMetadataRe);
if (sdistExtMatch) { if (sdistExtMatch) {
const base = filename.slice(0, -sdistExtMatch[0].length); const base = filename.replace(sdistExtWithMetadataRe, "");
const lastDash = base.lastIndexOf("-"); const lastDash = base.lastIndexOf("-");
if (lastDash > 0 && lastDash < base.length - 1) { if (lastDash > 0 && lastDash < base.length - 1) {
packageName = base.slice(0, lastDash); packageName = base.slice(0, lastDash);
@ -109,7 +124,6 @@ function parsePipPackageFromUrl(url, registry) {
return { packageName, version }; return { packageName, version };
} }
} }
// Unknown file type or invalid // Unknown file type or invalid
return { packageName: undefined, version: undefined }; 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", 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", 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", 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", 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", 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", 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", 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", 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", 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 // Invalid pip URLs
{ {

View file

@ -36,9 +36,10 @@ function getSafeChainProxyEnvironmentVariables() {
return {}; return {};
} }
const proxyUrl = `http://localhost:${state.port}`;
return { return {
HTTPS_PROXY: `http://localhost:${state.port}`, HTTPS_PROXY: proxyUrl,
GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`, GLOBAL_AGENT_HTTP_PROXY: proxyUrl,
NODE_EXTRA_CA_CERTS: getCaCertPath(), NODE_EXTRA_CA_CERTS: getCaCertPath(),
}; };
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -71,6 +71,12 @@ EOF
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
echo 'source $HOME/.local/bin/env' >> ~/.bashrc echo 'source $HOME/.local/bin/env' >> ~/.bashrc
# Install pipx (recommended installer for Poetry) and Poetry itself
RUN apt-get update && apt-get install -y pipx && \
pipx ensurepath && \
pipx install poetry && \
ln -sf /root/.local/bin/poetry /usr/local/bin/poetry
# Copy and install Safe chain # Copy and install Safe chain
COPY --from=builder /app/*.tgz /pkgs/ COPY --from=builder /app/*.tgz /pkgs/
RUN npm install -g /pkgs/*.tgz RUN npm install -g /pkgs/*.tgz

View file

@ -12,6 +12,10 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => {
beforeEach(async () => { beforeEach(async () => {
container = new DockerTestContainer(); container = new DockerTestContainer();
await container.start(); await container.start();
// Clear pip cache before each test to ensure fresh downloads through proxy
const shell = await container.openShell("zsh");
await shell.runCommand("pip3 cache purge");
}); });
afterEach(async () => { afterEach(async () => {

View file

@ -16,6 +16,9 @@ describe("E2E: pip coverage", () => {
const installationShell = await container.openShell("zsh"); const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup --include-python"); await installationShell.runCommand("safe-chain setup --include-python");
// Clear pip cache before each test to ensure fresh downloads through proxy
await installationShell.runCommand("pip3 cache purge");
}); });
afterEach(async () => { afterEach(async () => {
@ -118,9 +121,6 @@ describe("E2E: pip coverage", () => {
it(`safe-chain blocks installation of malicious Python packages`, async () => { it(`safe-chain blocks installation of malicious Python packages`, async () => {
const shell = await container.openShell("zsh"); const shell = await container.openShell("zsh");
// Clear pip cache to ensure network download through proxy
await shell.runCommand("pip3 cache purge");
const result = await shell.runCommand( const result = await shell.runCommand(
"pip3 install --break-system-packages safe-chain-pi-test" "pip3 install --break-system-packages safe-chain-pi-test"
); );
@ -247,9 +247,6 @@ describe("E2E: pip coverage", () => {
it(`pip3 successfully validates certificates for HTTPS downloads`, async () => { it(`pip3 successfully validates certificates for HTTPS downloads`, async () => {
const shell = await container.openShell("zsh"); const shell = await container.openShell("zsh");
// Clear cache to force network download through proxy
await shell.runCommand("pip3 cache purge");
const result = await shell.runCommand( const result = await shell.runCommand(
"pip3 install --break-system-packages certifi" "pip3 install --break-system-packages certifi"
); );

425
test/e2e/poetry.e2e.spec.js Normal file
View file

@ -0,0 +1,425 @@
import { describe, it, before, beforeEach, afterEach } from "node:test";
import { DockerTestContainer } from "./DockerTestContainer.js";
import assert from "node:assert";
describe("E2E: poetry coverage", () => {
let container;
before(async () => {
DockerTestContainer.buildImage();
});
beforeEach(async () => {
// Run a new Docker container for each test
container = new DockerTestContainer();
await container.start();
const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup --include-python");
});
afterEach(async () => {
// Stop and clean up the container after each test
if (container) {
await container.stop();
container = null;
}
});
it(`successfully installs known safe packages with poetry add`, async () => {
const shell = await container.openShell("zsh");
// Clear poetry cache using command to bypass safe-chain wrapper
await shell.runCommand("command poetry cache clear pypi --all -n");
// Initialize a new poetry project
await shell.runCommand("mkdir /tmp/test-poetry-project && cd /tmp/test-poetry-project");
await shell.runCommand("cd /tmp/test-poetry-project && poetry init --no-interaction");
// Add a safe package
const result = await shell.runCommand(
"cd /tmp/test-poetry-project && poetry add requests"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`poetry add with specific version`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-version && cd /tmp/test-poetry-version");
await shell.runCommand("cd /tmp/test-poetry-version && poetry init --no-interaction");
const result = await shell.runCommand(
"cd /tmp/test-poetry-version && poetry add requests==2.32.3"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`safe-chain blocks installation of malicious Python packages via poetry`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-malware && cd /tmp/test-poetry-malware");
await shell.runCommand("cd /tmp/test-poetry-malware && poetry init --no-interaction");
const result = await shell.runCommand(
"cd /tmp/test-poetry-malware && poetry add safe-chain-pi-test"
);
assert.ok(
result.output.includes("blocked by safe-chain"),
`Expected malware to be blocked. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("Exiting without installing malicious packages."),
`Expected exit message. Output was:\n${result.output}`
);
});
it(`poetry install installs dependencies from pyproject.toml`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-install && cd /tmp/test-poetry-install");
await shell.runCommand("cd /tmp/test-poetry-install && poetry init --no-interaction");
await shell.runCommand("cd /tmp/test-poetry-install && poetry add requests");
// Now remove the virtualenv and run install
await shell.runCommand("cd /tmp/test-poetry-install && rm -rf .venv");
const result = await shell.runCommand(
"cd /tmp/test-poetry-install && poetry install"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`poetry update updates dependencies`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-update && cd /tmp/test-poetry-update");
await shell.runCommand("cd /tmp/test-poetry-update && poetry init --no-interaction");
await shell.runCommand("cd /tmp/test-poetry-update && poetry add requests");
const result = await shell.runCommand(
"cd /tmp/test-poetry-update && poetry update"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Updating"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`poetry update with specific packages`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-update-specific && cd /tmp/test-poetry-update-specific");
await shell.runCommand("cd /tmp/test-poetry-update-specific && poetry init --no-interaction");
await shell.runCommand("cd /tmp/test-poetry-update-specific && poetry add requests certifi");
const result = await shell.runCommand(
"cd /tmp/test-poetry-update-specific && poetry update requests"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Updating"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`poetry sync synchronizes environment`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-sync && cd /tmp/test-poetry-sync");
await shell.runCommand("cd /tmp/test-poetry-sync && poetry init --no-interaction");
await shell.runCommand("cd /tmp/test-poetry-sync && poetry add requests");
const result = await shell.runCommand(
"cd /tmp/test-poetry-sync && poetry sync"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`poetry add with multiple packages`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-multi && cd /tmp/test-poetry-multi");
await shell.runCommand("cd /tmp/test-poetry-multi && poetry init --no-interaction");
const result = await shell.runCommand(
"cd /tmp/test-poetry-multi && poetry add requests certifi"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`poetry add with extras`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-extras && cd /tmp/test-poetry-extras");
await shell.runCommand("cd /tmp/test-poetry-extras && poetry init --no-interaction");
// Use quotes to prevent shell expansion of square brackets
const result = await shell.runCommand(
'cd /tmp/test-poetry-extras && poetry add "requests[security]"'
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`poetry add with development group`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-dev && cd /tmp/test-poetry-dev");
await shell.runCommand("cd /tmp/test-poetry-dev && poetry init --no-interaction");
const result = await shell.runCommand(
"cd /tmp/test-poetry-dev && poetry add --group dev pytest"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`poetry install with extras`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-install-extras && cd /tmp/test-poetry-install-extras");
await shell.runCommand("cd /tmp/test-poetry-install-extras && poetry init --no-interaction");
await shell.runCommand('cd /tmp/test-poetry-install-extras && poetry add requests');
await shell.runCommand("cd /tmp/test-poetry-install-extras && rm -rf .venv");
const result = await shell.runCommand(
'cd /tmp/test-poetry-install-extras && poetry install'
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`poetry install with dependency groups`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-install-groups && cd /tmp/test-poetry-install-groups");
await shell.runCommand("cd /tmp/test-poetry-install-groups && poetry init --no-interaction");
await shell.runCommand("cd /tmp/test-poetry-install-groups && poetry add requests");
await shell.runCommand("cd /tmp/test-poetry-install-groups && rm -rf .venv");
const result = await shell.runCommand(
"cd /tmp/test-poetry-install-groups && poetry install"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`poetry lock creates/updates lock file`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-lock && cd /tmp/test-poetry-lock");
await shell.runCommand("cd /tmp/test-poetry-lock && poetry init --no-interaction");
await shell.runCommand("cd /tmp/test-poetry-lock && poetry add requests");
await shell.runCommand("cd /tmp/test-poetry-lock && rm poetry.lock");
const result = await shell.runCommand(
"cd /tmp/test-poetry-lock && poetry lock"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Resolving") || result.output.includes("lock file"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`poetry add with version constraint using @`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-constraint && cd /tmp/test-poetry-constraint");
await shell.runCommand("cd /tmp/test-poetry-constraint && poetry init --no-interaction");
const result = await shell.runCommand(
"cd /tmp/test-poetry-constraint && poetry add requests@^2.32.0"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`poetry remove does not download packages`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-remove && cd /tmp/test-poetry-remove");
await shell.runCommand("cd /tmp/test-poetry-remove && poetry init --no-interaction");
await shell.runCommand("cd /tmp/test-poetry-remove && poetry add requests");
const result = await shell.runCommand(
"cd /tmp/test-poetry-remove && poetry remove requests"
);
// Remove should succeed - it doesn't download packages, just modifies pyproject.toml
assert.ok(
!result.output.includes("blocked"),
`Remove command should not trigger downloads. Output was:\n${result.output}`
);
});
it(`blocks malware during poetry install`, async () => {
const shell = await container.openShell("zsh");
// Create a project with malware in dependencies
await shell.runCommand("mkdir /tmp/test-poetry-install-malware && cd /tmp/test-poetry-install-malware");
await shell.runCommand("cd /tmp/test-poetry-install-malware && poetry init --no-interaction");
// Add malware package - this will create lock file and attempt download
const result = await shell.runCommand(
"cd /tmp/test-poetry-install-malware && poetry add safe-chain-pi-test 2>&1"
);
assert.ok(
result.output.includes("blocked by safe-chain"),
`Expected malware to be blocked during add (which triggers install). Output was:\n${result.output}`
);
assert.ok(
result.output.includes("Exiting without installing malicious packages."),
`Expected exit message. Output was:\n${result.output}`
);
});
it(`blocks malware when updating to add malicious dependency`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-update-add && cd /tmp/test-poetry-update-add");
await shell.runCommand("cd /tmp/test-poetry-update-add && poetry init --no-interaction");
// Start with a safe dependency
await shell.runCommand("cd /tmp/test-poetry-update-add && poetry add requests");
// Now try to add malware via add command
const result = await shell.runCommand(
"cd /tmp/test-poetry-update-add && poetry add safe-chain-pi-test 2>&1"
);
assert.ok(
result.output.includes("blocked by safe-chain"),
`Expected malware to be blocked. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("Exiting without installing malicious packages."),
`Expected exit message. Output was:\n${result.output}`
);
});
it(`blocks malware when installing from requirements with malicious package`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-req-malware && cd /tmp/test-poetry-req-malware");
await shell.runCommand("cd /tmp/test-poetry-req-malware && poetry init --no-interaction");
// Try to add malware directly - this is the primary vector
const result = await shell.runCommand(
"cd /tmp/test-poetry-req-malware && poetry add safe-chain-pi-test requests 2>&1"
);
assert.ok(
result.output.includes("blocked by safe-chain"),
`Expected malware to be blocked. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("Exiting without installing malicious packages."),
`Expected exit message. Output was:\n${result.output}`
);
// Verify safe package was also not installed due to malware in batch
const listResult = await shell.runCommand("cd /tmp/test-poetry-req-malware && poetry show");
assert.ok(
!listResult.output.includes("requests"),
`Safe package should not be installed when batch includes malware. Output was:\n${listResult.output}`
);
});
it(`poetry non-network commands work correctly`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-poetry-nonnetwork && cd /tmp/test-poetry-nonnetwork");
await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry init --no-interaction");
await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry add requests");
// Test poetry --version
const versionResult = await shell.runCommand("poetry --version");
assert.ok(
versionResult.output.includes("Poetry") && versionResult.output.includes("version"),
`Expected version output. Output was:\n${versionResult.output}`
);
// Test poetry show (list installed packages)
const showResult = await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry show");
assert.ok(
showResult.output.includes("requests"),
`Expected to see installed package. Output was:\n${showResult.output}`
);
// Test poetry env info (show virtual environment info)
const envInfoResult = await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry env info");
assert.ok(
envInfoResult.output.includes("Virtualenv") || envInfoResult.output.includes("Path"),
`Expected environment info. Output was:\n${envInfoResult.output}`
);
// Test poetry check (validate pyproject.toml)
const checkResult = await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry check");
assert.ok(
checkResult.output.includes("valid") || checkResult.output.includes("All"),
`Expected validation success. Output was:\n${checkResult.output}`
);
// Test poetry config --list (show configuration)
const configResult = await shell.runCommand("poetry config --list");
assert.ok(
configResult.output.length > 0,
`Expected configuration output. Output was:\n${configResult.output}`
);
// Test poetry run (execute command in virtualenv) - non-network command
const runResult = await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry run python --version");
assert.ok(
runResult.output.includes("Python"),
`Expected Python version output. Output was:\n${runResult.output}`
);
// Test poetry shell would start an interactive shell, so we skip that
// Test poetry env list (list virtual environments)
const envListResult = await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry env list");
assert.ok(
envListResult.output.includes("py3") || envListResult.output.includes("Activated"),
`Expected env list output. Output was:\n${envListResult.output}`
);
});
});