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
11
README.md
11
README.md
|
|
@ -22,6 +22,7 @@ Aikido Safe Chain supports the following package managers:
|
|||
- 📦 **pip** (beta)
|
||||
- 📦 **pip3** (beta)
|
||||
- 📦 **uv** (beta)
|
||||
- 📦 **poetry** (beta)
|
||||
|
||||
# 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.
|
||||
|
||||
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:
|
||||
|
||||
|
|
@ -93,13 +94,13 @@ safe-chain --version
|
|||
|
||||
### 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)
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -235,7 +236,7 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
|
|||
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
|
||||
|
||||
|
|
@ -252,6 +253,6 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
|
|||
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.
|
||||
|
|
|
|||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -3133,6 +3133,7 @@
|
|||
"aikido-pip3": "bin/aikido-pip3.js",
|
||||
"aikido-pnpm": "bin/aikido-pnpm.js",
|
||||
"aikido-pnpx": "bin/aikido-pnpx.js",
|
||||
"aikido-poetry": "bin/aikido-poetry.js",
|
||||
"aikido-python": "bin/aikido-python.js",
|
||||
"aikido-python3": "bin/aikido-python3.js",
|
||||
"aikido-uv": "bin/aikido-uv.js",
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
|
|
@ -71,6 +71,12 @@ EOF
|
|||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||
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 --from=builder /app/*.tgz /pkgs/
|
||||
RUN npm install -g /pkgs/*.tgz
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => {
|
|||
beforeEach(async () => {
|
||||
container = new DockerTestContainer();
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ describe("E2E: pip coverage", () => {
|
|||
|
||||
const installationShell = await container.openShell("zsh");
|
||||
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 () => {
|
||||
|
|
@ -118,9 +121,6 @@ describe("E2E: pip coverage", () => {
|
|||
|
||||
it(`safe-chain blocks installation of malicious Python packages`, async () => {
|
||||
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(
|
||||
"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 () => {
|
||||
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(
|
||||
"pip3 install --break-system-packages certifi"
|
||||
);
|
||||
|
|
|
|||
425
test/e2e/poetry.e2e.spec.js
Normal file
425
test/e2e/poetry.e2e.spec.js
Normal 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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue