Compare commits

...

34 commits
1.5.3 ... main

Author SHA1 Message Date
bitterpanda
9453c8c0c9
Merge pull request #470 from AikidoSec/bump/endpoint-v1.5.4
Bump Endpoint to v1.5.4
2026-05-21 10:41:08 -07:00
github-actions[bot]
2621f6f974 Bump Endpoint to v1.5.4 2026-05-21 17:39:03 +00:00
bitterpanda
fd01d9f31b
Merge pull request #466 from AikidoSec/slack-url-secret
Store the slack url as a secret
2026-05-20 10:10:10 -07:00
Sander Declerck
0414a79982
Merge pull request #467 from AikidoSec/feat/bump-endpoint-1-5-3
Bump Endpoint to 1.5.3
2026-05-20 18:26:42 +02:00
Reinier Criel
70b5e4d012 Bump Endpoint 2026-05-20 08:39:03 -07:00
Sander Declerck
aed0aebdae
Store the slack url as a secret 2026-05-20 09:20:03 +02:00
bitterpanda
6aec1bc474
Merge pull request #464 from AikidoSec/create-bump-endpoint-workflow
Create a bump-endpoint.yml workflow
2026-05-19 15:03:09 -07:00
bitterpanda
f6145d5c20
Update bump-endpoint.yml 2026-05-19 14:58:55 -07:00
bitterpanda
ab058367f1 temp: re-add push trigger for testing 2026-05-19 14:56:46 -07:00
bitterpanda
f2cce7b7e9 temp: skip if branch already exists instead of checking for PR 2026-05-19 14:56:15 -07:00
bitterpanda
0b46c5408b
Update bump-endpoint.yml 2026-05-19 14:55:22 -07:00
bitterpanda
07b8571758 temp: post compare URL to Slack instead of creating PR 2026-05-19 14:52:37 -07:00
bitterpanda
3f0837c65a temp: use open-source-releaser runner 2026-05-19 14:48:23 -07:00
bitterpanda
47e9ed0f6c temp: trigger bump-endpoint on push to test 2026-05-19 14:47:33 -07:00
bitterpanda
cbbbe703d3 Add a slack webhook curl req for endpoint bumps 2026-05-19 14:45:26 -07:00
bitterpanda
9d44eca1d1
Apply suggestion from @bitterpanda63 2026-05-19 14:39:04 -07:00
bitterpanda
b38aba43dd Create a bump-endpoint.yml workflow 2026-05-19 14:37:02 -07:00
bitterpanda
93c264ef84
Merge pull request #463 from AikidoSec/remove-npm-token
Remove obsolete npm token from pipeline
2026-05-18 09:55:47 -07:00
Sander Declerck
34898980d7
Remove obsolete npm token from pipeline 2026-05-18 10:24:37 +02:00
Reinier Criel
a5c29d9e49
Merge pull request #399 from greenpixie/feat/pdm-support
Support for PDM package manager (Python)
2026-05-15 08:43:38 -07:00
Chris Ingram
bf2d37d114
Merge branch 'main' into feat/pdm-support 2026-05-15 08:46:06 +01:00
Sander Declerck
65a8075b0e
Merge pull request #459 from AikidoSec/bug/execpath
unset PKG_EXECPATH before invoking safe-chain binary
2026-05-15 09:11:32 +02:00
Chris Ingram
a1b89a55f8
Make block-count assertions count-agnostic in bun e2e
Bun retries blocked downloads, so the count in "blocked N malicious
package downloads" can be >1. Match on the surrounding text rather than
a fixed count to keep the assertion robust.

Also drops the brittle "pdm update updates dependencies" case.
2026-05-14 17:16:57 +01:00
Chris Ingram
8ab5cebd4f
Match actual block output in pdm e2e assertions
The user-facing message is "Safe-chain: blocked N malicious package
downloads", not "blocked by safe-chain" (which only appears in the
proxy's HTTP response, not the rendered CLI output).
2026-05-14 16:48:18 +01:00
Chris Ingram
ffe7f8de1f
Use numpy==2.4.4 as test malware in pdm e2e tests
The safe-chain-pi-test package no longer exists on PyPI. Aikido now
patches numpy==2.4.4 into the malware list for tests, matching the
pattern already used in the poetry e2e suite.
2026-05-14 16:28:50 +01:00
Chris Ingram
54db058ac7
Use getPackageManagerList in safe-chain setup help text
The install message in `safe-chain setup` help was hardcoding a stale
list of package managers (missing uv, uvx, poetry, pipx, pdm). Use the
existing getPackageManagerList() helper so the list stays in sync with
knownAikidoTools.
2026-05-14 10:04:18 +01:00
Chris Ingram
8453012f7b
Merge remote-tracking branch 'aikido/main' into feat/pdm-support 2026-05-14 09:51:31 +01:00
Reinier Criel
e0e06431d1 Fix tests 2026-05-13 20:28:58 -07:00
Reinier Criel
6cdad3df98 Fix tests 2026-05-13 20:27:27 -07:00
Reinier Criel
d9b7aefd34 unset PKG_EXECPATH before invoking safe-chain binary 2026-05-13 14:33:58 -07:00
Chris Ingram
abbe0480b6
Merge branch 'main' into feat/pdm-support 2026-04-22 14:25:32 +01:00
Chris Ingram
42102eb067 Merge branch 'main' into feat/pdm-support 2026-04-07 11:27:39 +01:00
Chris Ingram
ced5e26420 File mode on aikido-pdm.js 2026-04-07 11:19:04 +01:00
Chris Ingram
1eb4fe05fd Add pdm package manager support
PDM is a modern Python package manager using pyproject.toml (PEP 621).
Uses the same MITM-only proxy approach as poetry/uv/pipx — all malware
detection and minimum package age enforcement happens at the proxy layer
by intercepting PyPI requests.
2026-04-06 13:01:42 +01:00
29 changed files with 633 additions and 42 deletions

View file

@ -144,8 +144,6 @@ jobs:
with: with:
node-version: "lts/*" node-version: "lts/*"
registry-url: "https://registry.npmjs.org/" registry-url: "https://registry.npmjs.org/"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
- name: Setup safe-chain - name: Setup safe-chain
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci

82
.github/workflows/bump-endpoint.yml vendored Normal file
View file

@ -0,0 +1,82 @@
name: Bump Device Protection Automatically
on:
schedule:
- cron: '0 * * * *' # every hour
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
bump-endpoint:
runs-on: open-source-releaser
steps:
- uses: actions/checkout@v4
- name: Get latest safechain-internals release
id: latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(gh api repos/AikidoSec/safechain-internals/releases/latest --jq '.tag_name')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Get current version from install script
id: current
run: |
CURRENT=$(grep -oP '(?<=releases/download/)[^/]+(?=/EndpointProtection\.pkg)' install-scripts/install-endpoint-mac.sh)
echo "version=$CURRENT" >> $GITHUB_OUTPUT
- name: Download assets and compute checksums
if: steps.latest.outputs.version != steps.current.outputs.version
id: checksums
run: |
VERSION="${{ steps.latest.outputs.version }}"
BASE="https://github.com/AikidoSec/safechain-internals/releases/download/${VERSION}"
curl -fsSL "${BASE}/EndpointProtection.pkg" -o /tmp/EndpointProtection.pkg
curl -fsSL "${BASE}/EndpointProtection.msi" -o /tmp/EndpointProtection.msi
echo "mac=$(sha256sum /tmp/EndpointProtection.pkg | cut -d' ' -f1)" >> $GITHUB_OUTPUT
echo "win=$(sha256sum /tmp/EndpointProtection.msi | cut -d' ' -f1)" >> $GITHUB_OUTPUT
- name: Update install scripts
if: steps.latest.outputs.version != steps.current.outputs.version
run: |
NEW="${{ steps.latest.outputs.version }}"
OLD="${{ steps.current.outputs.version }}"
MAC_SHA="${{ steps.checksums.outputs.mac }}"
WIN_SHA="${{ steps.checksums.outputs.win }}"
sed -i "s|${OLD}/EndpointProtection.pkg|${NEW}/EndpointProtection.pkg|" install-scripts/install-endpoint-mac.sh
sed -i "s|^DOWNLOAD_SHA256=\"[^\"]*\"|DOWNLOAD_SHA256=\"${MAC_SHA}\"|" install-scripts/install-endpoint-mac.sh
sed -i "s|${OLD}/EndpointProtection.msi|${NEW}/EndpointProtection.msi|" install-scripts/install-endpoint-windows.ps1
sed -i 's|^\$DownloadSha256 = "[^"]*"|\$DownloadSha256 = "'"${WIN_SHA}"'"|' install-scripts/install-endpoint-windows.ps1
- name: Open PR
if: steps.latest.outputs.version != steps.current.outputs.version
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
NEW="${{ steps.latest.outputs.version }}"
OLD="${{ steps.current.outputs.version }}"
BRANCH="bump/endpoint-${NEW}"
if git ls-remote --exit-code --heads origin "$BRANCH" &>/dev/null; then
echo "Branch $BRANCH already exists, skipping."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add install-scripts/install-endpoint-mac.sh install-scripts/install-endpoint-windows.ps1
git commit -m "Bump Endpoint to ${NEW}"
git push origin "$BRANCH"
PR_URL="https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1"
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"text\": \"update to ${NEW} - ${PR_URL}\"}"

View file

@ -35,6 +35,7 @@ Aikido Safe Chain supports the following package managers:
- 📦 **poetry** - 📦 **poetry**
- 📦 **uvx** - 📦 **uvx**
- 📦 **pipx** - 📦 **pipx**
- 📦 **pdm**
# Usage # Usage
@ -77,7 +78,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
### Verify the installation ### Verify the installation
1. **❗Restart your terminal** to start using the Aikido Safe Chain. 1. **❗Restart your terminal** to start using the Aikido Safe Chain.
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, poetry, uv, uvx and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, poetry, uv, uvx, pipx and pdm are loaded correctly. If you do not restart your terminal, the aliases will not be available.
2. **Verify the installation** by running the verification command: 2. **Verify the installation** by running the verification command:
@ -108,7 +109,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
- 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`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` 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`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx` and `pdm` 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:
@ -120,7 +121,7 @@ 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, rush, rushx, bun, bunx, pip, pip3, uv, uvx, poetry or pipx 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, rush, rushx, bun, bunx, pip, pip3, uv, uvx, poetry, pipx or pdm 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 ### Minimum package age
@ -139,7 +140,7 @@ By default, the minimum package age is 48 hours. This provides an additional sec
### Shell Integration ### Shell Integration
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx, pdm). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
- ✅ **Bash** - ✅ **Bash**
- ✅ **Zsh** - ✅ **Zsh**

View file

@ -7,8 +7,8 @@
set -e # Exit on error set -e # Exit on error
# Configuration # Configuration
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.4/EndpointProtection.pkg" INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.pkg"
DOWNLOAD_SHA256="f2ea55588d42e4aa17545ad787f46dd36001009e2ddb9655c497b1a36edf3581" DOWNLOAD_SHA256="ad800f9e476b0a75bf32b1c079f060ecb98bc16972a4e8cca29cf165388ea9fe"
TOKEN_FILE="/tmp/aikido_endpoint_token.txt" TOKEN_FILE="/tmp/aikido_endpoint_token.txt"
# Colors for output # Colors for output

View file

@ -7,8 +7,8 @@ param(
) )
# Configuration # Configuration
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.4/EndpointProtection.msi" $InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.msi"
$DownloadSha256 = "0699379716a9a8b1531befa538befb237252af9f7fd780b33f4dce73588c6f83" $DownloadSha256 = "e2750c59124f53456a8f9cdb9e81fd9ce2f2491869f68f01602444ad519be5be"
# Ensure TLS 1.2 is enabled for downloads # Ensure TLS 1.2 is enabled for downloads
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

1
npm-shrinkwrap.json generated
View file

@ -3129,6 +3129,7 @@
"aikido-bunx": "bin/aikido-bunx.js", "aikido-bunx": "bin/aikido-bunx.js",
"aikido-npm": "bin/aikido-npm.js", "aikido-npm": "bin/aikido-npm.js",
"aikido-npx": "bin/aikido-npx.js", "aikido-npx": "bin/aikido-npx.js",
"aikido-pdm": "bin/aikido-pdm.js",
"aikido-pip": "bin/aikido-pip.js", "aikido-pip": "bin/aikido-pip.js",
"aikido-pip3": "bin/aikido-pip3.js", "aikido-pip3": "bin/aikido-pip3.js",
"aikido-pipx": "bin/aikido-pipx.js", "aikido-pipx": "bin/aikido-pipx.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("pdm");
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -1,5 +1,11 @@
#!/usr/bin/env node #!/usr/bin/env node
// Strip PKG_EXECPATH from the environment so any child process safe-chain
// spawns (npm, uv, pip, …) doesn't inherit it. If it leaks into a subsequent
// safe-chain invocation (e.g. via a shim) the yao-pkg bootstrap would treat
// argv[1] as a script path and fail with MODULE_NOT_FOUND.
delete process.env.PKG_EXECPATH;
import chalk from "chalk"; import chalk from "chalk";
import { ui } from "../src/environment/userInteraction.js"; import { ui } from "../src/environment/userInteraction.js";
import { setup } from "../src/shell-integration/setup.js"; import { setup } from "../src/shell-integration/setup.js";
@ -15,7 +21,7 @@ import { main } from "../src/main.js";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import fs from "fs"; import fs from "fs";
import { knownAikidoTools } from "../src/shell-integration/helpers.js"; import { knownAikidoTools, getPackageManagerList } from "../src/shell-integration/helpers.js";
import { getInstalledSafeChainDir } from "../src/installLocation.js"; import { getInstalledSafeChainDir } from "../src/installLocation.js";
/** @type {string} */ /** @type {string} */
@ -108,7 +114,7 @@ function writeHelp() {
ui.writeInformation( ui.writeInformation(
`- ${chalk.cyan( `- ${chalk.cyan(
"safe-chain setup", "safe-chain setup",
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip and pip3.`, )}: This will setup your shell to wrap safe-chain around ${getPackageManagerList()}.`,
); );
ui.writeInformation( ui.writeInformation(
`- ${chalk.cyan( `- ${chalk.cyan(

View file

@ -25,6 +25,7 @@
"aikido-python3": "bin/aikido-python3.js", "aikido-python3": "bin/aikido-python3.js",
"aikido-poetry": "bin/aikido-poetry.js", "aikido-poetry": "bin/aikido-poetry.js",
"aikido-pipx": "bin/aikido-pipx.js", "aikido-pipx": "bin/aikido-pipx.js",
"aikido-pdm": "bin/aikido-pdm.js",
"safe-chain": "bin/safe-chain.js" "safe-chain": "bin/safe-chain.js"
}, },
"type": "module", "type": "module",
@ -39,7 +40,7 @@
"keywords": [], "keywords": [],
"author": "Aikido Security", "author": "Aikido Security",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [rush](https://rushjs.io/), [rushx](https://rushjs.io/pages/commands/rushx/), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, uv, uvx, or pip/pip3 from downloading or running the malware.", "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [rush](https://rushjs.io/), [rushx](https://rushjs.io/pages/commands/rushx/), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), [pip](https://pip.pypa.io/), and [pdm](https://pdm-project.org/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, uv, uvx, pip/pip3, or pdm from downloading or running the malware.",
"dependencies": { "dependencies": {
"certifi": "14.5.15", "certifi": "14.5.15",
"chalk": "5.4.1", "chalk": "5.4.1",

View file

@ -13,6 +13,7 @@ import { createPipPackageManager } from "./pip/createPackageManager.js";
import { createUvPackageManager } from "./uv/createUvPackageManager.js"; import { createUvPackageManager } from "./uv/createUvPackageManager.js";
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js"; import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js"; import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js";
import { createPdmPackageManager } from "./pdm/createPdmPackageManager.js";
import { createRushPackageManager } from "./rush/createRushPackageManager.js"; import { createRushPackageManager } from "./rush/createRushPackageManager.js";
import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js"; import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js";
import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js"; import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js";
@ -69,6 +70,8 @@ export function initializePackageManager(packageManagerName, context) {
state.packageManagerName = createPoetryPackageManager(); state.packageManagerName = createPoetryPackageManager();
} else if (packageManagerName === "pipx") { } else if (packageManagerName === "pipx") {
state.packageManagerName = createPipXPackageManager(); state.packageManagerName = createPipXPackageManager();
} else if (packageManagerName === "pdm") {
state.packageManagerName = createPdmPackageManager();
} else if (packageManagerName === "rush") { } else if (packageManagerName === "rush") {
state.packageManagerName = createRushPackageManager(); state.packageManagerName = createRushPackageManager();
} else if (packageManagerName === "rushx") { } else if (packageManagerName === "rushx") {

View file

@ -0,0 +1,72 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createPdmPackageManager() {
return {
runCommand: (args) => runPdmCommand(args),
// MITM only approach for PDM
isSupportedCommand: () => false,
getDependencyUpdatesForCommand: () => [],
};
}
/**
* Sets CA bundle environment variables used by PDM and Python libraries.
* PDM uses httpx (via unearth) which respects SSL_CERT_FILE through Python's ssl module.
*
* @param {NodeJS.ProcessEnv} env - Environment object to modify
* @param {string} combinedCaPath - Path to the combined CA bundle
*/
function setPdmCaBundleEnvironmentVariables(env, combinedCaPath) {
// SSL_CERT_FILE: Used by Python SSL libraries and httpx (which PDM uses)
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 (PDM plugins may use it)
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: PDM 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 pdm command with safe-chain's certificate bundle and proxy configuration.
*
* PDM respects standard HTTP_PROXY/HTTPS_PROXY environment variables through
* httpx which it uses for package downloads.
*
* @param {string[]} args - Command line arguments to pass to pdm
* @returns {Promise<{status: number}>} Exit status of the pdm command
*/
async function runPdmCommand(args) {
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
const combinedCaPath = getCombinedCaBundlePath();
setPdmCaBundleEnvironmentVariables(env, combinedCaPath);
const result = await safeSpawn("pdm", args, {
stdio: "inherit",
env,
});
return { status: result.status };
} catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, "pdm");
}
}

View file

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

View file

@ -120,6 +120,12 @@ export const knownAikidoTools = [
ecoSystem: ECOSYSTEM_PY, ecoSystem: ECOSYSTEM_PY,
internalPackageManagerName: "pipx", internalPackageManagerName: "pipx",
}, },
{
tool: "pdm",
aikidoCommand: "aikido-pdm",
ecoSystem: ECOSYSTEM_PY,
internalPackageManagerName: "pdm",
},
// When adding a new tool here, also update the documentation for the new tool in the README.md // When adding a new tool here, also update the documentation for the new tool in the README.md
]; ];

View file

@ -20,7 +20,10 @@ remove_shim_from_path() {
} }
if command -v safe-chain >/dev/null 2>&1; then if command -v safe-chain >/dev/null 2>&1; then
# Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops.
# Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't
# mistake argv[1] for a script path and try to resolve "{{PACKAGE_MANAGER}}" against cwd.
unset PKG_EXECPATH
PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@" PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@"
else else
# safe-chain is not reachable — warn the user so they know protection is inactive # safe-chain is not reachable — warn the user so they know protection is inactive

View file

@ -0,0 +1,60 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..", "..");
describe("PKG_EXECPATH cleanup", () => {
it("unix shim template unsets PKG_EXECPATH before invoking safe-chain", () => {
const file = path.join(
repoRoot,
"src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh",
);
const content = fs.readFileSync(file, "utf-8");
assert.match(
content,
/unset PKG_EXECPATH[\s\S]*exec safe-chain/,
"unix-wrapper.template.sh must `unset PKG_EXECPATH` before `exec safe-chain`",
);
});
it("posix shell function unsets PKG_EXECPATH before invoking safe-chain", () => {
const file = path.join(
repoRoot,
"src/shell-integration/startup-scripts/init-posix.sh",
);
const content = fs.readFileSync(file, "utf-8");
// Scoped subshell so we don't mutate the user's interactive env.
assert.match(
content,
/\(unset PKG_EXECPATH;\s*safe-chain "\$@"\)/,
"init-posix.sh must invoke safe-chain in a subshell that unsets PKG_EXECPATH",
);
});
it("fish shell function unsets PKG_EXECPATH before invoking safe-chain", () => {
const file = path.join(
repoRoot,
"src/shell-integration/startup-scripts/init-fish.fish",
);
const content = fs.readFileSync(file, "utf-8");
assert.match(
content,
/env -u PKG_EXECPATH safe-chain/,
"init-fish.fish must invoke safe-chain via `env -u PKG_EXECPATH`",
);
});
it("safe-chain entry point deletes PKG_EXECPATH from process.env", () => {
const file = path.join(repoRoot, "bin/safe-chain.js");
const content = fs.readFileSync(file, "utf-8");
assert.match(
content,
/delete process\.env\.PKG_EXECPATH/,
"bin/safe-chain.js must delete process.env.PKG_EXECPATH so spawned children don't inherit it",
);
});
});

View file

@ -84,6 +84,10 @@ function pipx
wrapSafeChainCommand "pipx" $argv wrapSafeChainCommand "pipx" $argv
end end
function pdm
wrapSafeChainCommand "pdm" $argv
end
function printSafeChainWarning function printSafeChainWarning
set original_cmd $argv[1] set original_cmd $argv[1]
@ -120,8 +124,10 @@ function wrapSafeChainCommand
end end
if type -q safe-chain if type -q safe-chain
# If the safe-chain command is available, just run it with the provided arguments # If the safe-chain command is available, just run it with the provided arguments.
safe-chain $original_cmd $cmd_args # Unset PKG_EXECPATH for this invocation so the yao-pkg bootstrap inside the
# safe-chain binary doesn't mistake argv[1] for a script path to resolve against cwd.
env -u PKG_EXECPATH safe-chain $original_cmd $cmd_args
else else
# If the safe-chain command is not available, print a warning and run the original command # If the safe-chain command is not available, print a warning and run the original command
printSafeChainWarning $original_cmd printSafeChainWarning $original_cmd

View file

@ -89,6 +89,10 @@ function pipx() {
wrapSafeChainCommand "pipx" "$@" wrapSafeChainCommand "pipx" "$@"
} }
function pdm() {
wrapSafeChainCommand "pdm" "$@"
}
function printSafeChainWarning() { function printSafeChainWarning() {
# \033[43;30m is used to set the background color to yellow and text color to black # \033[43;30m is used to set the background color to yellow and text color to black
# \033[0m is used to reset the text formatting # \033[0m is used to reset the text formatting
@ -109,8 +113,10 @@ function wrapSafeChainCommand() {
fi fi
if command -v safe-chain > /dev/null 2>&1; then if command -v safe-chain > /dev/null 2>&1; then
# If the aikido command is available, just run it with the provided arguments # If the aikido command is available, just run it with the provided arguments.
safe-chain "$@" # Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't
# mistake argv[1] for a script path and try to resolve it against cwd.
(unset PKG_EXECPATH; safe-chain "$@")
else else
# If the aikido command is not available, print a warning and run the original command # If the aikido command is not available, print a warning and run the original command
printSafeChainWarning "$original_cmd" printSafeChainWarning "$original_cmd"

View file

@ -83,6 +83,10 @@ function pipx {
Invoke-WrappedCommand "pipx" $args $MyInvocation.Line $MyInvocation.OffsetInLine Invoke-WrappedCommand "pipx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
} }
function pdm {
Invoke-WrappedCommand "pdm" $args $MyInvocation.Line $MyInvocation.OffsetInLine
}
function Write-SafeChainWarning { function Write-SafeChainWarning {
param([string]$Command) param([string]$Command)

View file

@ -79,6 +79,10 @@ RUN apt-get update && apt-get install -y pipx && \
pipx install poetry && \ pipx install poetry && \
ln -sf /root/.local/bin/poetry /usr/local/bin/poetry ln -sf /root/.local/bin/poetry /usr/local/bin/poetry
# Install PDM
RUN pipx install pdm && \
ln -sf /root/.local/bin/pdm /usr/local/bin/pdm
# 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

@ -46,8 +46,9 @@ describe("E2E: bun coverage", () => {
var result = await shell.runCommand("bun install"); var result = await shell.runCommand("bun install");
assert.ok( assert.match(
result.output.includes("blocked 1 malicious package downloads"), result.output,
/blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(
@ -65,8 +66,9 @@ describe("E2E: bun coverage", () => {
const result = await shell.runCommand("bunx safe-chain-test"); const result = await shell.runCommand("bunx safe-chain-test");
assert.ok( assert.match(
result.output.includes("blocked 1 malicious package downloads"), result.output,
/blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(

View file

@ -70,8 +70,9 @@ describe("E2E: npm coverage", () => {
var result = await shell.runCommand("npm install"); var result = await shell.runCommand("npm install");
assert.ok( assert.match(
result.output.includes("blocked 1 malicious package downloads"), result.output,
/blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(

300
test/e2e/pdm.e2e.spec.js Normal file
View file

@ -0,0 +1,300 @@
import { describe, it, before, beforeEach, afterEach } from "node:test";
import { DockerTestContainer } from "./DockerTestContainer.js";
import assert from "node:assert";
describe("E2E: pdm 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");
// Clear pdm cache
await installationShell.runCommand("command pdm cache clear");
});
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 pdm add`, async () => {
const shell = await container.openShell("zsh");
// Initialize a new pdm project
await shell.runCommand("mkdir /tmp/test-pdm-project && cd /tmp/test-pdm-project");
await shell.runCommand("cd /tmp/test-pdm-project && pdm init --non-interactive");
// Add a safe package
const result = await shell.runCommand(
"cd /tmp/test-pdm-project && pdm 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(`pdm add with specific version`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-version && cd /tmp/test-pdm-version");
await shell.runCommand("cd /tmp/test-pdm-version && pdm init --non-interactive");
const result = await shell.runCommand(
"cd /tmp/test-pdm-version && pdm 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 pdm`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-malware && cd /tmp/test-pdm-malware");
await shell.runCommand("cd /tmp/test-pdm-malware && pdm init --non-interactive");
const result = await shell.runCommand(
"cd /tmp/test-pdm-malware && pdm add numpy==2.4.4"
);
assert.ok(
result.output.includes("blocked") && result.output.includes("malicious package downloads"),
`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(`pdm install installs dependencies from pyproject.toml`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-install && cd /tmp/test-pdm-install");
await shell.runCommand("cd /tmp/test-pdm-install && pdm init --non-interactive");
await shell.runCommand("cd /tmp/test-pdm-install && pdm add requests");
// Now remove the virtualenv and run install
await shell.runCommand("cd /tmp/test-pdm-install && rm -rf .venv");
const result = await shell.runCommand(
"cd /tmp/test-pdm-install && pdm 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(`pdm update with specific packages`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-update-specific && cd /tmp/test-pdm-update-specific");
await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm init --non-interactive");
await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm add requests certifi");
const result = await shell.runCommand(
"cd /tmp/test-pdm-update-specific && pdm 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(`pdm add with multiple packages`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-multi && cd /tmp/test-pdm-multi");
await shell.runCommand("cd /tmp/test-pdm-multi && pdm init --non-interactive");
const result = await shell.runCommand(
"cd /tmp/test-pdm-multi && pdm 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(`pdm add with extras`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-extras && cd /tmp/test-pdm-extras");
await shell.runCommand("cd /tmp/test-pdm-extras && pdm init --non-interactive");
// Use quotes to prevent shell expansion of square brackets
const result = await shell.runCommand(
'cd /tmp/test-pdm-extras && pdm 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(`pdm add with development group`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-dev && cd /tmp/test-pdm-dev");
await shell.runCommand("cd /tmp/test-pdm-dev && pdm init --non-interactive");
const result = await shell.runCommand(
"cd /tmp/test-pdm-dev && pdm add -dG 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(`pdm lock creates/updates lock file`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-lock && cd /tmp/test-pdm-lock");
await shell.runCommand("cd /tmp/test-pdm-lock && pdm init --non-interactive");
await shell.runCommand("cd /tmp/test-pdm-lock && pdm add requests");
await shell.runCommand("cd /tmp/test-pdm-lock && rm pdm.lock");
const result = await shell.runCommand(
"cd /tmp/test-pdm-lock && pdm 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(`pdm remove does not download packages`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-remove && cd /tmp/test-pdm-remove");
await shell.runCommand("cd /tmp/test-pdm-remove && pdm init --non-interactive");
await shell.runCommand("cd /tmp/test-pdm-remove && pdm add requests");
const result = await shell.runCommand(
"cd /tmp/test-pdm-remove && pdm 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 pdm install`, async () => {
const shell = await container.openShell("zsh");
// Create a project with malware in dependencies
await shell.runCommand("mkdir /tmp/test-pdm-install-malware && cd /tmp/test-pdm-install-malware");
await shell.runCommand("cd /tmp/test-pdm-install-malware && pdm init --non-interactive");
// Add malware package - this will create lock file and attempt download
const result = await shell.runCommand(
"cd /tmp/test-pdm-install-malware && pdm add numpy==2.4.4 2>&1"
);
assert.ok(
result.output.includes("blocked") && result.output.includes("malicious package downloads"),
`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 adding malicious dependency alongside safe one`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-batch && cd /tmp/test-pdm-batch");
await shell.runCommand("cd /tmp/test-pdm-batch && pdm init --non-interactive");
// Try to add malware alongside safe package
const result = await shell.runCommand(
"cd /tmp/test-pdm-batch && pdm add numpy==2.4.4 requests 2>&1"
);
assert.ok(
result.output.includes("blocked") && result.output.includes("malicious package downloads"),
`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-pdm-batch && pdm list");
assert.ok(
!listResult.output.includes("requests"),
`Safe package should not be installed when batch includes malware. Output was:\n${listResult.output}`
);
});
it(`pdm non-network commands work correctly`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-nonnetwork && cd /tmp/test-pdm-nonnetwork");
await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm init --non-interactive");
await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm add requests");
// Test pdm --version
const versionResult = await shell.runCommand("pdm --version");
assert.ok(
versionResult.output.includes("PDM") || versionResult.output.includes("pdm"),
`Expected version output. Output was:\n${versionResult.output}`
);
// Test pdm list (list installed packages)
const listResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm list");
assert.ok(
listResult.output.includes("requests"),
`Expected to see installed package. Output was:\n${listResult.output}`
);
// Test pdm info (show project info)
const infoResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm info");
assert.ok(
infoResult.output.includes("PDM") || infoResult.output.includes("Python") || infoResult.output.includes("Project"),
`Expected project info. Output was:\n${infoResult.output}`
);
// Test pdm config (show configuration)
const configResult = await shell.runCommand("pdm config");
assert.ok(
configResult.output.length > 0,
`Expected configuration output. Output was:\n${configResult.output}`
);
// Test pdm run (execute command in virtualenv) - non-network command
const runResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm run python --version");
assert.ok(
runResult.output.includes("Python"),
`Expected Python version output. Output was:\n${runResult.output}`
);
});
});

View file

@ -131,8 +131,9 @@ describe("E2E: pip coverage", () => {
"pip3 install --break-system-packages numpy==2.4.4 --safe-chain-logging=verbose" "pip3 install --break-system-packages numpy==2.4.4 --safe-chain-logging=verbose"
); );
assert.ok( assert.match(
result.output.includes("blocked 1 malicious package downloads:"), result.output,
/blocked [1-9]\d* malicious package downloads:/,
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(

View file

@ -70,8 +70,9 @@ describe("E2E: pnpm coverage", () => {
var result = await shell.runCommand("pnpm install"); var result = await shell.runCommand("pnpm install");
assert.ok( assert.match(
result.output.includes("blocked 1 malicious package downloads"), result.output,
/blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(

View file

@ -109,7 +109,7 @@ describe("E2E: rush coverage", () => {
assert.match( assert.match(
result.output, result.output,
/blocked \d+ malicious package downloads/, /blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(

View file

@ -57,7 +57,7 @@ describe("E2E: rushx coverage", () => {
assert.match( assert.match(
result.output, result.output,
/blocked \d+ malicious package downloads/, /blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(

View file

@ -100,8 +100,9 @@ describe("E2E: safe-chain CLI python/pip support", () => {
"safe-chain pip3 install --break-system-packages numpy==2.4.4" "safe-chain pip3 install --break-system-packages numpy==2.4.4"
); );
assert.ok( assert.match(
result.output.includes("blocked 1 malicious package downloads"), result.output,
/blocked [1-9]\d* malicious package downloads/,
`Should have blocked malware. Output was:\n${result.output}` `Should have blocked malware. Output was:\n${result.output}`
); );
}); });

View file

@ -129,8 +129,9 @@ describe("E2E: uv coverage", () => {
"uv pip install --system --break-system-packages numpy==2.4.4" "uv pip install --system --break-system-packages numpy==2.4.4"
); );
assert.ok( assert.match(
result.output.includes("blocked 1 malicious package downloads:"), result.output,
/blocked [1-9]\d* malicious package downloads:/,
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(
@ -416,8 +417,9 @@ describe("E2E: uv coverage", () => {
"cd test-project-malware && uv add numpy==2.4.4" "cd test-project-malware && uv add numpy==2.4.4"
); );
assert.ok( assert.match(
result.output.includes("blocked 1 malicious package downloads:"), result.output,
/blocked [1-9]\d* malicious package downloads:/,
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(
@ -447,8 +449,9 @@ describe("E2E: uv coverage", () => {
const shell = await container.openShell("zsh"); const shell = await container.openShell("zsh");
const result = await shell.runCommand("uv tool install numpy==2.4.4"); const result = await shell.runCommand("uv tool install numpy==2.4.4");
assert.ok( assert.match(
result.output.includes("blocked 1 malicious package downloads:"), result.output,
/blocked [1-9]\d* malicious package downloads:/,
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(
@ -485,8 +488,9 @@ describe("E2E: uv coverage", () => {
"uv run --with numpy==2.4.4 test_script2.py" "uv run --with numpy==2.4.4 test_script2.py"
); );
assert.ok( assert.match(
result.output.includes("blocked 1 malicious package downloads:"), result.output,
/blocked [1-9]\d* malicious package downloads:/,
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
}); });

View file

@ -70,8 +70,9 @@ describe("E2E: yarn coverage", () => {
var result = await shell.runCommand("yarn"); var result = await shell.runCommand("yarn");
assert.ok( assert.match(
result.output.includes("blocked 1 malicious package downloads"), result.output,
/blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(