Merge remote-tracking branch 'origin/main' into pip-custom-registries

This commit is contained in:
galargh 2025-12-22 13:27:04 +01:00
commit 39e2001d97
58 changed files with 2760 additions and 702 deletions

View file

@ -44,9 +44,7 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
- name: Setup safe-chain
run: |
npm i -g @aikidosec/safe-chain
safe-chain setup-ci
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- name: Set the version in safe-chain package
run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain
@ -77,21 +75,35 @@ jobs:
- name: Rename binaries to include platform and architecture
run: |
mv binaries/safe-chain-macos-x64/safe-chain binaries/safe-chain-macos-x64/safe-chain-macos-x64
mv binaries/safe-chain-macos-arm64/safe-chain binaries/safe-chain-macos-arm64/safe-chain-macos-arm64
mv binaries/safe-chain-linux-x64/safe-chain binaries/safe-chain-linux-x64/safe-chain-linux-x64
mv binaries/safe-chain-linux-arm64/safe-chain binaries/safe-chain-linux-arm64/safe-chain-linux-arm64
mv binaries/safe-chain-win-x64/safe-chain.exe binaries/safe-chain-win-x64/safe-chain-win-x64.exe
mv binaries/safe-chain-win-arm64/safe-chain.exe binaries/safe-chain-win-arm64/safe-chain-win-arm64.exe
mkdir release-artifacts
mv binaries/safe-chain-macos-x64/safe-chain release-artifacts/safe-chain-macos-x64
mv binaries/safe-chain-macos-arm64/safe-chain release-artifacts/safe-chain-macos-arm64
mv binaries/safe-chain-linux-x64/safe-chain release-artifacts/safe-chain-linux-x64
mv binaries/safe-chain-linux-arm64/safe-chain release-artifacts/safe-chain-linux-arm64
mv binaries/safe-chain-win-x64/safe-chain.exe release-artifacts/safe-chain-win-x64.exe
mv binaries/safe-chain-win-arm64/safe-chain.exe release-artifacts/safe-chain-win-arm64.exe
- name: Move install scripts and hard-code version
env:
VERSION: ${{ needs.set-version.outputs.version }}
run: |
sed "s/\$(fetch_latest_version)/${VERSION}/" install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh
sed "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1
cp install-scripts/uninstall-safe-chain.sh release-artifacts/uninstall-safe-chain.sh
cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1
- name: Upload binaries to existing GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload ${{ needs.set-version.outputs.version }} \
binaries/safe-chain-macos-x64/* \
binaries/safe-chain-macos-arm64/* \
binaries/safe-chain-linux-x64/* \
binaries/safe-chain-linux-arm64/* \
binaries/safe-chain-win-x64/* \
binaries/safe-chain-win-arm64/*
release-artifacts/safe-chain-macos-x64 \
release-artifacts/safe-chain-macos-arm64 \
release-artifacts/safe-chain-linux-x64 \
release-artifacts/safe-chain-linux-arm64 \
release-artifacts/safe-chain-win-x64.exe \
release-artifacts/safe-chain-win-arm64.exe \
release-artifacts/install-safe-chain.sh \
release-artifacts/install-safe-chain.ps1 \
release-artifacts/uninstall-safe-chain.sh \
release-artifacts/uninstall-safe-chain.ps1

View file

@ -5,7 +5,7 @@ on:
workflow_call:
inputs:
version:
description: 'Version to set in package.json'
description: "Version to set in package.json"
required: false
type: string
@ -59,18 +59,22 @@ jobs:
with:
node-version: "20.x"
- name: Setup safe-chain
run: |
npm i -g @aikidosec/safe-chain
safe-chain setup-ci
- name: Setup safe-chain (Mac/Linux)
if: runner.os != 'Windows'
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- name: Set the version in safe-chain package
if: inputs.version != ''
run: npm --no-git-tag-version version ${{ inputs.version }} --workspace=packages/safe-chain
- name: Setup safe-chain (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci"
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Set the version in safe-chain package
if: inputs.version != ''
run: npm --no-git-tag-version version ${{ inputs.version }} --workspace=packages/safe-chain --ignore-scripts
- name: Create binary
run: |
node build.js ${{ matrix.target }}

View file

@ -22,10 +22,14 @@ jobs:
with:
node-version: "lts/*"
- name: Setup safe-chain
run: |
npm i -g @aikidosec/safe-chain
safe-chain setup-ci
- name: Setup safe-chain (Mac/Linux)
if: runner.os != 'Windows'
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- name: Setup safe-chain (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci"
- name: Install dependencies
run: npm ci --ignore-scripts
@ -110,9 +114,7 @@ jobs:
node-version: "lts/*"
- name: Setup safe-chain
run: |
npm i -g @aikidosec/safe-chain@1.0.24
safe-chain setup-ci
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- name: Install dependencies (root)
run: npm ci

146
README.md
View file

@ -19,13 +19,16 @@ Aikido Safe Chain supports the following package managers:
- 📦 **pnpx**
- 📦 **bun**
- 📦 **bunx**
- 📦 **pip** (beta)
- 📦 **pip3** (beta)
- 📦 **uv** (beta)
- 📦 **poetry** (beta)
- 📦 **pip**
- 📦 **pip3**
- 📦 **uv**
- 📦 **poetry**
- 📦 **pipx**
# Usage
![Aikido Safe Chain demo](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/safe-package-manager-demo.gif)
## Installation
Installing the Aikido Safe Chain is easy with our one-line installer.
@ -34,37 +37,39 @@ Installing the Aikido Safe Chain is easy with our one-line installer.
### Unix/Linux/macOS
**Default installation (JavaScript packages only):**
```shell
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh
```
**Include Python support (pip/pip3/uv):**
```shell
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --include-python
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh
```
### Windows (PowerShell)
**Default installation (JavaScript packages only):**
```powershell
iex (iwr "https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1" -UseBasicParsing)
iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1" -UseBasicParsing)
```
**Include Python support (pip/pip3/uv):**
### Pinning to a specific version
To install a specific version instead of the latest, replace `latest` with the version number in the URL (available from version 1.3.2 onwards):
**Unix/Linux/macOS:**
```shell
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.sh | sh
```
**Windows (PowerShell):**
```powershell
iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -includepython"
iex (iwr "https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.ps1" -UseBasicParsing)
```
You can find all available versions on the [releases page](https://github.com/AikidoSec/safe-chain/releases).
### Verify the installation
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, bun, bunx, and pip/pip3 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, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available.
2. **Verify the installation** by running one of the following commands:
@ -74,7 +79,7 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
npm install safe-chain-test
```
For Python (if you enabled Python support):
For Python:
```shell
pip3 install safe-chain-pi-test
@ -82,7 +87,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`, `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.
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `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.
You can check the installed version by running:
@ -94,17 +99,17 @@ 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, 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.
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, pip, pip3, uv, 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.
### 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, poetry).
⚠️ 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, pipx).
### Shell Integration
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (uv, pip). 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, bun, bunx, and Python package managers (pip, uv, 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:
- ✅ **Bash**
- ✅ **Zsh**
@ -116,17 +121,21 @@ More information about the shell integration can be found in the [shell integrat
## Uninstallation
To uninstall the Aikido Safe Chain, you can run the following command:
To uninstall the Aikido Safe Chain, use our one-line uninstaller:
1. **Remove all aliases from your shell** by running:
```shell
safe-chain teardown
```
2. **Uninstall the Aikido Safe Chain package** using npm:
```shell
npm uninstall -g @aikidosec/safe-chain
```
3. **❗Restart your terminal** to remove the aliases.
### Unix/Linux/macOS
```shell
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/uninstall-safe-chain.sh | sh
```
### Windows (PowerShell)
```powershell
iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/uninstall-safe-chain.ps1" -UseBasicParsing)
```
**❗Restart your terminal** after uninstalling to ensure all aliases are removed.
# Configuration
@ -179,24 +188,29 @@ You can set the minimum package age through multiple sources (in order of priori
}
```
## Custom Registries
## Custom NPM Registries
By default, Safe Chain monitors downloads from the official package registries (npm registry, PyPI, etc.). If you use a private or custom package registry, you can configure Safe Chain to also monitor downloads from those registries.
⚠️ This feature **currently only applies to Python package managers** (pip, pip3, uv, poetry) and does not apply to npm-based package managers.
Configure Safe Chain to scan packages from custom or private npm registries.
### Configuration Options
You can set custom registries through the following source:
You can set custom registries through environment variable or config file. Both sources are merged together.
1. **Environment Variable**:
1. **Environment Variable** (comma-separated):
```shell
export SAFE_CHAIN_PIP_CUSTOM_REGISTRIES=my-custom-registry.example.com,private-pypi.internal.com
pip install mypackage
export SAFE_CHAIN_NPM_CUSTOM_REGISTRIES="npm.company.com,registry.internal.net"
```
Use a comma-separated list of registry hostnames to monitor multiple custom registries.
2. **Config File** (`~/.aikido/config.json`):
```json
{
"npm": {
"customRegistries": ["npm.company.com", "registry.internal.net"]
}
}
```
# Usage in CI/CD
@ -208,36 +222,21 @@ Use the `--ci` flag to automatically configure Aikido Safe Chain for CI/CD envir
### Unix/Linux/macOS (GitHub Actions, Azure Pipelines, etc.)
**JavaScript only:**
```shell
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci
```
**With Python support:**
```shell
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci --include-python
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
```
### Windows (Azure Pipelines, etc.)
**JavaScript only:**
```powershell
iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -ci"
```
**With Python support:**
```powershell
iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -ci -includepython"
iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci"
```
## Supported Platforms
- ✅ **GitHub Actions**
- ✅ **Azure Pipelines**
- ✅ **CircleCI**
## GitHub Actions Example
@ -249,14 +248,12 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
cache: "npm"
- name: Install safe-chain
run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci --include-python
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- name: Install dependencies
run: npm ci
```
> **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv/poetry) support.
## Azure DevOps Example
```yaml
@ -265,13 +262,30 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
versionSpec: "22.x"
displayName: "Install Node.js"
- script: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci --include-python
- script: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
displayName: "Install safe-chain"
- script: npm ci
displayName: "Install dependencies"
```
> **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv/poetry) support.
## CircleCI Example
```yaml
version: 2.1
jobs:
build:
docker:
- image: cimg/node:lts
steps:
- checkout
- run: |
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci
- run: npm ci
workflows:
build_and_test:
jobs:
- build
```
After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

@ -2,7 +2,7 @@
## Overview
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
## Supported Shells
@ -28,7 +28,7 @@ This command:
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
- Detects all supported shells on your system
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3`
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx`
- Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name
❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
@ -78,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts:
This means the shell functions are working but the Aikido commands aren't installed or available in your PATH:
- Make sure Aikido Safe Chain is properly installed on your system
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, and `aikido-pip3` commands exist
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-poetry` and `aikido-pipx` commands exist
- Check that these commands are in your system's PATH
### Manual Verification
@ -121,7 +121,7 @@ npm() {
}
```
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions:

View file

@ -31,6 +31,46 @@ function Write-Error-Custom {
exit 1
}
# Get currently installed version of safe-chain
function Get-InstalledVersion {
# Check if safe-chain command exists
if (-not (Get-Command safe-chain -ErrorAction SilentlyContinue)) {
return $null
}
try {
# Execute safe-chain -v and capture output
$output = & safe-chain -v 2>&1
# Extract version from "Current safe-chain version: X.Y.Z" output
if ($output -match "Current safe-chain version:\s*(.+)") {
return $matches[1].Trim()
}
return $null
}
catch {
return $null
}
}
# Check if the requested version is already installed
function Test-VersionInstalled {
param([string]$RequestedVersion)
$installedVersion = Get-InstalledVersion
if ([string]::IsNullOrWhiteSpace($installedVersion)) {
return $false
}
# Strip leading 'v' from versions if present for comparison
$requestedClean = $RequestedVersion -replace '^v', ''
$installedClean = $installedVersion -replace '^v', ''
return $requestedClean -eq $installedClean
}
# Fetch latest release version tag from GitHub
function Get-LatestVersion {
try {
@ -115,14 +155,20 @@ function Install-SafeChain {
$Version = Get-LatestVersion
}
# Check if the requested version is already installed
if (Test-VersionInstalled -RequestedVersion $Version) {
Write-Info "safe-chain $Version is already installed"
return
}
# Build installation message
$installMsg = "Installing safe-chain $Version"
if ($includepython) {
$installMsg += " with python"
}
if ($ci) {
$installMsg += " in ci"
}
if ($includepython) {
Write-Warn "-includepython is deprecated and ignored. Python ecosystem is now included by default."
}
Write-Info $installMsg
@ -181,9 +227,6 @@ function Install-SafeChain {
# Build setup command based on parameters
$setupCmd = if ($ci) { "setup-ci" } else { "setup" }
$setupArgs = @()
if ($includepython) {
$setupArgs += "--include-python"
}
# Execute safe-chain setup
Write-Info "Running safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })..."

View file

@ -54,6 +54,38 @@ command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Get currently installed version of safe-chain
get_installed_version() {
if ! command_exists safe-chain; then
echo ""
return
fi
# Extract version from "Current safe-chain version: X.Y.Z" output
installed_version=$(safe-chain -v 2>/dev/null | grep "Current safe-chain version:" | sed -E 's/.*: (.*)/\1/')
echo "$installed_version"
}
# Check if the requested version is already installed
is_version_installed() {
requested_version="$1"
installed_version=$(get_installed_version)
if [ -z "$installed_version" ]; then
return 1 # Not installed
fi
# Strip leading 'v' from versions if present for comparison
requested_clean=$(echo "$requested_version" | sed 's/^v//')
installed_clean=$(echo "$installed_version" | sed 's/^v//')
if [ "$requested_clean" = "$installed_clean" ]; then
return 0 # Same version installed
else
return 1 # Different version installed
fi
}
# Fetch latest release version tag from GitHub
fetch_latest_version() {
# Try using GitHub API to get the latest release tag
@ -135,7 +167,7 @@ parse_arguments() {
USE_CI_SETUP=true
;;
--include-python)
INCLUDE_PYTHON=true
warn "--include-python is deprecated and ignored. Python ecosystem is now included by default."
;;
*)
error "Unknown argument: $arg"
@ -148,7 +180,6 @@ parse_arguments() {
main() {
# Initialize argument flags
USE_CI_SETUP=false
INCLUDE_PYTHON=false
# Parse command-line arguments
parse_arguments "$@"
@ -159,11 +190,14 @@ main() {
VERSION=$(fetch_latest_version)
fi
# Check if the requested version is already installed
if is_version_installed "$VERSION"; then
info "safe-chain ${VERSION} is already installed"
exit 0
fi
# Build installation message
INSTALL_MSG="Installing safe-chain ${VERSION}"
if [ "$INCLUDE_PYTHON" = "true" ]; then
INSTALL_MSG="${INSTALL_MSG} with python"
fi
if [ "$USE_CI_SETUP" = "true" ]; then
INSTALL_MSG="${INSTALL_MSG} in ci"
fi
@ -209,10 +243,6 @@ main() {
SETUP_CMD="setup-ci"
fi
if [ "$INCLUDE_PYTHON" = "true" ]; then
SETUP_ARGS="--include-python"
fi
# Execute safe-chain setup
info "Running safe-chain $SETUP_CMD $SETUP_ARGS..."
if ! "$FINAL_FILE" $SETUP_CMD $SETUP_ARGS; then

View file

@ -0,0 +1,165 @@
# Uninstalls safe-chain from Windows
#
# Usage with "iex (iwr {url} -UseBasicParsing)" --> See README.md
# Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform)
$HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
$InstallDir = Join-Path $HomeDir ".safe-chain/bin"
# Helper functions
function Write-Info {
param([string]$Message)
Write-Host "[INFO] $Message" -ForegroundColor Green
}
function Write-Warn {
param([string]$Message)
Write-Host "[WARN] $Message" -ForegroundColor Yellow
}
function Write-Error-Custom {
param([string]$Message)
Write-Host "[ERROR] $Message" -ForegroundColor Red
exit 1
}
# Check and uninstall npm global package if present
function Remove-NpmInstallation {
# Check if npm is available
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
return
}
# Check if safe-chain is installed as an npm global package
npm list -g @aikidosec/safe-chain 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Info "Detected npm global installation of @aikidosec/safe-chain"
Write-Info "Uninstalling npm version before installing binary version..."
npm uninstall -g @aikidosec/safe-chain 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Info "Successfully uninstalled npm version"
}
else {
Write-Warn "Failed to uninstall npm version automatically"
Write-Warn "Please run: npm uninstall -g @aikidosec/safe-chain"
}
}
}
# Check and uninstall Volta-managed package if present
function Remove-VoltaInstallation {
# Check if Volta is available
if (-not (Get-Command volta -ErrorAction SilentlyContinue)) {
return
}
# Volta manages global packages in its own directory
# Check if safe-chain is installed via Volta
volta list safe-chain 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Info "Detected Volta installation of @aikidosec/safe-chain"
Write-Info "Uninstalling Volta version before installing binary version..."
volta uninstall @aikidosec/safe-chain 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Info "Successfully uninstalled Volta version"
}
else {
Write-Warn "Failed to uninstall Volta version automatically"
Write-Warn "Please run: volta uninstall @aikidosec/safe-chain"
}
}
}
# Main uninstallation
function Uninstall-SafeChain {
Write-Info "Uninstalling safe-chain..."
# Run teardown if safe-chain is available
# Check for both safe-chain.exe (Windows) and safe-chain (Unix) since PowerShell Core runs on all platforms
$safeChainExe = Join-Path $InstallDir "safe-chain.exe"
$safeChainBin = Join-Path $InstallDir "safe-chain"
$safeChainPath = $null
if (Test-Path $safeChainExe) {
$safeChainPath = $safeChainExe
}
elseif (Test-Path $safeChainBin) {
$safeChainPath = $safeChainBin
}
if ($safeChainPath) {
Write-Info "Running safe-chain teardown..."
try {
& $safeChainPath teardown
if ($LASTEXITCODE -ne 0) {
Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..."
}
}
catch {
Write-Warn "safe-chain teardown encountered issues: $_"
Write-Warn "Continuing with uninstallation..."
}
}
elseif (Get-Command safe-chain -ErrorAction SilentlyContinue) {
Write-Info "Running safe-chain teardown..."
try {
safe-chain teardown
if ($LASTEXITCODE -ne 0) {
Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..."
}
}
catch {
Write-Warn "safe-chain teardown encountered issues: $_"
Write-Warn "Continuing with uninstallation..."
}
}
else {
Write-Warn "safe-chain command not found. Proceeding with uninstallation."
}
# Remove npm and Volta installations
Remove-NpmInstallation
Remove-VoltaInstallation
# Remove installation directory
if (Test-Path $InstallDir) {
Write-Info "Removing installation directory: $InstallDir"
try {
Remove-Item -Path $InstallDir -Recurse -Force
Write-Info "Successfully removed installation directory"
}
catch {
Write-Error-Custom "Failed to remove $InstallDir : $_"
}
}
else {
Write-Info "Installation directory $InstallDir does not exist. Nothing to remove."
}
# Also try to remove the parent .safe-chain directory if it's empty
$parentDir = Split-Path $InstallDir -Parent
if (Test-Path $parentDir) {
$items = Get-ChildItem -Path $parentDir -Force
if ($items.Count -eq 0) {
Write-Info "Removing empty parent directory: $parentDir"
try {
Remove-Item -Path $parentDir -Force
}
catch {
Write-Warn "Could not remove empty parent directory: $_"
}
}
}
Write-Info "safe-chain has been uninstalled successfully!"
}
# Run uninstallation
try {
Uninstall-SafeChain
}
catch {
Write-Error-Custom "Uninstallation failed: $_"
}

View file

@ -0,0 +1,104 @@
#!/bin/sh
# Downloads and installs safe-chain, depending on the operating system and architecture
#
# Usage with "curl -fsSL {url} | sh" --> See README.md
set -e # Exit on error
# Configuration
INSTALL_DIR="${HOME}/.safe-chain/bin"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Helper functions
info() {
printf "${GREEN}[INFO]${NC} %s\n" "$1"
}
warn() {
printf "${YELLOW}[WARN]${NC} %s\n" "$1"
}
error() {
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
exit 1
}
# Check if command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Check and uninstall npm global package if present
remove_npm_installation() {
if ! command_exists npm; then
return
fi
# Check if safe-chain is installed as an npm global package
if npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then
info "Detected npm global installation of @aikidosec/safe-chain"
info "Uninstalling npm version before installing binary version..."
if npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then
info "Successfully uninstalled npm version"
else
warn "Failed to uninstall npm version automatically"
warn "Please run: npm uninstall -g @aikidosec/safe-chain"
fi
fi
}
# Check and uninstall Volta-managed package if present
remove_volta_installation() {
if ! command_exists volta; then
return
fi
# Volta manages global packages in its own directory
# Check if safe-chain is installed via Volta
if volta list safe-chain >/dev/null 2>&1; then
info "Detected Volta installation of @aikidosec/safe-chain"
info "Uninstalling Volta version before installing binary version..."
if volta uninstall @aikidosec/safe-chain >/dev/null 2>&1; then
info "Successfully uninstalled Volta version"
else
warn "Failed to uninstall Volta version automatically"
warn "Please run: volta uninstall @aikidosec/safe-chain"
fi
fi
}
# Main uninstallation
main() {
SAFE_CHAIN_LOCATION="$INSTALL_DIR/safe-chain"
if [ -x "$SAFE_CHAIN_LOCATION" ]; then
info "Running safe-chain teardown..."
"$SAFE_CHAIN_LOCATION" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..."
elif command_exists safe-chain; then
info "Running safe-chain teardown..."
safe-chain teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..."
else
warn "safe-chain command not found. Proceeding with uninstallation."
fi
remove_npm_installation
remove_volta_installation
# Remove install dir recursively if it exists
if [ -d "$INSTALL_DIR" ]; then
info "Removing installation directory $INSTALL_DIR"
rm -rf "$INSTALL_DIR" || error "Failed to remove $INSTALL_DIR"
else
info "Installation directory $INSTALL_DIR does not exist. Nothing to remove."
fi
}
main "$@"

1
package-lock.json generated
View file

@ -3131,6 +3131,7 @@
"aikido-npx": "bin/aikido-npx.js",
"aikido-pip": "bin/aikido-pip.js",
"aikido-pip3": "bin/aikido-pip3.js",
"aikido-pipx": "bin/aikido-pipx.js",
"aikido-pnpm": "bin/aikido-pnpm.js",
"aikido-pnpx": "bin/aikido-pnpx.js",
"aikido-poetry": "bin/aikido-poetry.js",

View file

@ -0,0 +1,16 @@
#!/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";
// Set eco system
setEcoSystem(ECOSYSTEM_PY);
initializePackageManager("pipx");
(async () => {
// Pass through only user-supplied pipx args
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -3,7 +3,7 @@
import chalk from "chalk";
import { ui } from "../src/environment/userInteraction.js";
import { setup } from "../src/shell-integration/setup.js";
import { teardown } from "../src/shell-integration/teardown.js";
import { teardown, teardownDirectories } from "../src/shell-integration/teardown.js";
import { setupCi } from "../src/shell-integration/setup-ci.js";
import { initializeCliArguments } from "../src/config/cliArguments.js";
import { setEcoSystem } from "../src/config/settings.js";
@ -60,6 +60,7 @@ if (tool) {
} else if (command === "setup") {
setup();
} else if (command === "teardown") {
teardownDirectories();
teardown();
} else if (command === "setup-ci") {
setupCi();
@ -94,11 +95,6 @@ function writeHelp() {
"safe-chain setup"
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`
);
ui.writeInformation(
` ${chalk.yellow(
"--include-python"
)}: Experimental: include Python package managers (pip, pip3) in the setup.`
);
ui.writeInformation(
`- ${chalk.cyan(
"safe-chain teardown"
@ -109,11 +105,6 @@ function writeHelp() {
"safe-chain setup-ci"
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`
);
ui.writeInformation(
` ${chalk.yellow(
"--include-python"
)}: Experimental: include Python package managers (pip, pip3) in the setup.`
);
ui.writeInformation(
`- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan(
"-v"

View file

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

View file

@ -1,11 +1,12 @@
import { ui } from "../environment/userInteraction.js";
/**
* @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, includePython: boolean}}
* @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined}}
*/
const state = {
loggingLevel: undefined,
skipMinimumPackageAge: undefined,
minimumPackageAgeHours: undefined,
includePython: false,
};
const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
@ -34,8 +35,7 @@ export function initializeCliArguments(args) {
setLoggingLevel(safeChainArgs);
setSkipMinimumPackageAge(safeChainArgs);
setMinimumPackageAgeHours(safeChainArgs);
setIncludePython(args);
checkDeprecatedPythonFlag(args);
return remainingArgs;
}
@ -109,20 +109,6 @@ export function getMinimumPackageAgeHours() {
return state.minimumPackageAgeHours;
}
/**
* @param {string[]} args
*/
function setIncludePython(args) {
// This flag doesn't have the --safe-chain- prefix because
// it is only used for the safe-chain command itself and
// not when wrapped around package manager commands.
state.includePython = hasFlagArg(args, "--include-python");
}
export function includePython() {
return state.includePython;
}
/**
* @param {string[]} args
* @param {string} flagName
@ -136,3 +122,17 @@ function hasFlagArg(args, flagName) {
}
return false;
}
/**
* Emits a deprecation warning for legacy --include-python flag
*
* @param {string[]} args
* @returns {void}
*/
export function checkDeprecatedPythonFlag(args) {
if (hasFlagArg(args, "--include-python")) {
ui.writeWarning(
"--include-python is deprecated and ignored. Python tooling is included by default."
);
}
}

View file

@ -6,6 +6,7 @@ import {
getSkipMinimumPackageAge,
getMinimumPackageAgeHours,
} from "./cliArguments.js";
import { ui } from "../environment/userInteraction.js";
describe("initializeCliArguments", () => {
it("should return all args when no safe-chain args are present", () => {
@ -271,4 +272,40 @@ describe("initializeCliArguments", () => {
assert.strictEqual(getMinimumPackageAgeHours(), "-24");
});
it("should warn on deprecated --include-python for setup", () => {
const warnings = [];
const originalWriteWarning = ui.writeWarning;
ui.writeWarning = (msg, ..._rest) => {
warnings.push(String(msg));
};
try {
const argv = ["node", "safe-chain", "setup", "--include-python"];
initializeCliArguments(argv);
assert.ok(
warnings.some((m) => m.includes("--include-python is deprecated")),
"Expected a deprecation warning for --include-python in setup"
);
} finally {
ui.writeWarning = originalWriteWarning;
}
});
it("should warn on deprecated --include-python for setup-ci", () => {
const warnings = [];
const originalWriteWarning = ui.writeWarning;
ui.writeWarning = (msg, ..._rest) => {
warnings.push(String(msg));
};
try {
const argv = ["node", "safe-chain", "setup-ci", "--include-python"];
initializeCliArguments(argv);
assert.ok(
warnings.some((m) => m.includes("--include-python is deprecated")),
"Expected a deprecation warning for --include-python in setup-ci"
);
} finally {
ui.writeWarning = originalWriteWarning;
}
});
});

View file

@ -7,10 +7,14 @@ import { getEcoSystem } from "./settings.js";
/**
* @typedef {Object} SafeChainConfig
*
* This should be a number, but can be anything because it is user-input.
* We cannot trust the input and should add the necessary validations
* @property {unknown | Number} scanTimeout
* @property {unknown | Number} minimumPackageAgeHours
* @property {unknown | SafeChainRegistryConfiguration} npm
*
* @typedef {Object} SafeChainRegistryConfiguration
* We cannot trust the input and should add the necessary validations.
* @property {unknown} scanTimeout
* @property {unknown} minimumPackageAgeHours
* @property {unknown | string[]} customRegistries
*/
/**
@ -67,7 +71,7 @@ function validateMinimumPackageAgeHours(value) {
*/
export function getMinimumPackageAgeHours() {
const config = readConfigFile();
if (config.minimumPackageAgeHours) {
if (config.minimumPackageAgeHours !== undefined) {
const validated = validateMinimumPackageAgeHours(
config.minimumPackageAgeHours
);
@ -78,6 +82,28 @@ export function getMinimumPackageAgeHours() {
return undefined;
}
/**
* Gets the custom npm registries from the config file (format parsing only, no validation)
* @returns {string[]}
*/
export function getNpmCustomRegistries() {
const config = readConfigFile();
if (!config || !config.npm) {
return [];
}
// TypeScript needs help understanding that config.npm exists and has customRegistries
const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm);
const customRegistries = npmConfig.customRegistries;
if (!Array.isArray(customRegistries)) {
return [];
}
return customRegistries.filter((item) => typeof item === "string");
}
/**
* @param {import("../api/aikido.js").MalwarePackage[]} data
* @param {string | number} version
@ -136,23 +162,26 @@ export function readDatabaseFromLocalCache() {
* @returns {SafeChainConfig}
*/
function readConfigFile() {
/** @type {SafeChainConfig} */
const emptyConfig = {
scanTimeout: undefined,
minimumPackageAgeHours: undefined,
npm: {
customRegistries: undefined,
},
};
const configFilePath = getConfigFilePath();
if (!fs.existsSync(configFilePath)) {
return {
scanTimeout: undefined,
minimumPackageAgeHours: undefined,
};
return emptyConfig;
}
try {
const data = fs.readFileSync(configFilePath, "utf8");
return JSON.parse(data);
} catch {
return {
scanTimeout: undefined,
minimumPackageAgeHours: undefined,
};
return emptyConfig;
}
}

View file

@ -1,32 +1,24 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("getScanTimeout", () => {
let configFileContent = undefined;
mock.module("fs", {
namedExports: {
existsSync: () => configFileContent !== undefined,
readFileSync: () => configFileContent,
writeFileSync: (content) => (configFileContent = content),
mkdirSync: () => {},
},
});
describe("getScanTimeout", async () => {
let originalEnv;
let fsMock;
let getScanTimeout;
const { getScanTimeout } = await import("./configFile.js");
beforeEach(async () => {
// Save original environment
originalEnv = process.env.AIKIDO_SCAN_TIMEOUT_MS;
// Mock fs module
fsMock = {
existsSync: mock.fn(() => false),
readFileSync: mock.fn(() => "{}"),
writeFileSync: mock.fn(),
mkdirSync: mock.fn(),
};
mock.module("fs", {
namedExports: fsMock,
});
// Re-import the module to get the mocked version
const configFileModule = await import(
`./configFile.js?update=${Date.now()}`
);
getScanTimeout = configFileModule.getScanTimeout;
});
afterEach(() => {
@ -37,14 +29,12 @@ describe("getScanTimeout", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
}
// Reset all mocks
mock.restoreAll();
configFileContent = undefined;
});
it("should return default timeout of 10000ms when no config or env var is set", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
// Mock: config file doesn't exist
fsMock.existsSync.mock.mockImplementation(() => false);
configFileContent = undefined;
const timeout = getScanTimeout();
@ -53,11 +43,7 @@ describe("getScanTimeout", () => {
it("should return timeout from config file when set", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
// Mock: config file exists with scanTimeout: 5000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 5000 })
);
configFileContent = JSON.stringify({ scanTimeout: 5000 });
const timeout = getScanTimeout();
@ -66,11 +52,7 @@ describe("getScanTimeout", () => {
it("should prioritize environment variable over config file", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "20000";
// Mock: config file exists with scanTimeout: 5000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 5000 })
);
configFileContent = JSON.stringify({ scanTimeout: 5000 });
const timeout = getScanTimeout();
@ -79,11 +61,7 @@ describe("getScanTimeout", () => {
it("should handle invalid environment variable and fall back to config", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "invalid";
// Mock: config file exists with scanTimeout: 7000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 7000 })
);
configFileContent = JSON.stringify({ scanTimeout: 7000 });
const timeout = getScanTimeout();
@ -91,8 +69,7 @@ describe("getScanTimeout", () => {
});
it("should ignore zero and negative values and fall back to default", () => {
// Mock: config file doesn't exist
fsMock.existsSync.mock.mockImplementation(() => false);
configFileContent = undefined;
process.env.AIKIDO_SCAN_TIMEOUT_MS = "0";
@ -107,11 +84,7 @@ describe("getScanTimeout", () => {
it("should ignore textual non-numeric values in environment variable and fall back to config", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "fast";
// Mock: config file exists with scanTimeout: 8000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 8000 })
);
configFileContent = JSON.stringify({ scanTimeout: 8000 });
const timeout = getScanTimeout();
@ -120,11 +93,7 @@ describe("getScanTimeout", () => {
it("should ignore textual non-numeric values in config file and fall back to default", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
// Mock: config file exists with scanTimeout: "slow"
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: "slow" })
);
configFileContent = JSON.stringify({ scanTimeout: "slow" });
const timeout = getScanTimeout();
@ -133,11 +102,7 @@ describe("getScanTimeout", () => {
it("should ignore textual non-numeric values in both env and config, fall back to default", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "quick";
// Mock: config file exists with scanTimeout: "medium"
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: "medium" })
);
configFileContent = JSON.stringify({ scanTimeout: "medium" });
const timeout = getScanTimeout();
@ -146,11 +111,7 @@ describe("getScanTimeout", () => {
it("should ignore mixed alphanumeric strings in environment variable", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "5000ms";
// Mock: config file exists with scanTimeout: 6000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 6000 })
);
configFileContent = JSON.stringify({ scanTimeout: 6000 });
const timeout = getScanTimeout();
@ -159,11 +120,7 @@ describe("getScanTimeout", () => {
it("should ignore mixed alphanumeric strings in config file", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
// Mock: config file exists with scanTimeout: "3000ms"
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: "3000ms" })
);
configFileContent = JSON.stringify({ scanTimeout: "3000ms" });
const timeout = getScanTimeout();
@ -171,37 +128,15 @@ describe("getScanTimeout", () => {
});
});
describe("getMinimumPackageAgeHours", () => {
let fsMock;
let getMinimumPackageAgeHours;
beforeEach(async () => {
// Mock fs module
fsMock = {
existsSync: mock.fn(() => false),
readFileSync: mock.fn(() => "{}"),
writeFileSync: mock.fn(),
mkdirSync: mock.fn(),
};
mock.module("fs", {
namedExports: fsMock,
});
// Re-import the module to get the mocked version
const configFileModule = await import(
`./configFile.js?update=${Date.now()}`
);
getMinimumPackageAgeHours = configFileModule.getMinimumPackageAgeHours;
});
describe("getMinimumPackageAgeHours", async () => {
const { getMinimumPackageAgeHours } = await import("./configFile.js");
afterEach(() => {
// Reset all mocks
mock.restoreAll();
configFileContent = undefined;
});
it("should return null when config file doesn't exist", () => {
fsMock.existsSync.mock.mockImplementation(() => false);
configFileContent = undefined;
const hours = getMinimumPackageAgeHours();
@ -209,10 +144,7 @@ describe("getMinimumPackageAgeHours", () => {
});
it("should return null when config file exists but minimumPackageAgeHours is not set", () => {
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 5000 })
);
configFileContent = JSON.stringify({ scanTimeout: 5000 });
const hours = getMinimumPackageAgeHours();
@ -220,10 +152,7 @@ describe("getMinimumPackageAgeHours", () => {
});
it("should return value from config file when set to valid number", () => {
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ minimumPackageAgeHours: 48 })
);
configFileContent = JSON.stringify({ minimumPackageAgeHours: 48 });
const hours = getMinimumPackageAgeHours();
@ -231,10 +160,7 @@ describe("getMinimumPackageAgeHours", () => {
});
it("should handle string numbers in config file", () => {
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ minimumPackageAgeHours: "72" })
);
configFileContent = JSON.stringify({ minimumPackageAgeHours: "72" });
const hours = getMinimumPackageAgeHours();
@ -242,10 +168,7 @@ describe("getMinimumPackageAgeHours", () => {
});
it("should handle decimal values", () => {
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ minimumPackageAgeHours: 1.5 })
);
configFileContent = JSON.stringify({ minimumPackageAgeHours: 1.5 });
const hours = getMinimumPackageAgeHours();
@ -253,21 +176,15 @@ describe("getMinimumPackageAgeHours", () => {
});
it("should return null for non-numeric strings", () => {
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ minimumPackageAgeHours: "invalid" })
);
configFileContent = JSON.stringify({ minimumPackageAgeHours: "invalid" });
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, undefined);
});
it("should return null for values with units suffix", () => {
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ minimumPackageAgeHours: "48h" })
);
it("should return undefined for values with units suffix", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: "48h" });
const hours = getMinimumPackageAgeHours();
@ -275,11 +192,131 @@ describe("getMinimumPackageAgeHours", () => {
});
it("should handle malformed JSON and return null", () => {
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() => "{ invalid json");
configFileContent = "{ invalid json";
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, undefined);
});
it("should return 0 when minimumPackageAgeHours is set to 0", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: 0 });
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, 0);
});
it("should return 0 when minimumPackageAgeHours is set to string '0'", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: "0" });
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, 0);
});
it("should handle negative numeric values", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: -24 });
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, -24);
});
it("should handle negative string values", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: "-48" });
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, -48);
});
});
describe("getNpmCustomRegistries", async () => {
const { getNpmCustomRegistries } = await import("./configFile.js");
afterEach(() => {
configFileContent = undefined;
});
it("should return empty array when config file doesn't exist", () => {
configFileContent = undefined;
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should return empty array when npm config is not set", () => {
configFileContent = JSON.stringify({ scanTimeout: 5000 });
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should return empty array when customRegistries is not an array", () => {
configFileContent = JSON.stringify({
npm: { customRegistries: "not-an-array" },
});
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should return array of custom registries when set", () => {
configFileContent = JSON.stringify({
npm: {
customRegistries: ["npm.company.com", "registry.internal.net"],
},
});
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, [
"npm.company.com",
"registry.internal.net",
]);
});
it("should filter out non-string values", () => {
configFileContent = JSON.stringify({
npm: {
customRegistries: [
"npm.company.com",
123,
null,
"registry.internal.net",
undefined,
{},
],
},
});
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, [
"npm.company.com",
"registry.internal.net",
]);
});
it("should return empty array for empty customRegistries array", () => {
configFileContent = JSON.stringify({
npm: { customRegistries: [] },
});
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should handle malformed JSON and return empty array", () => {
configFileContent = "{ invalid json";
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, []);
});
});

View file

@ -6,8 +6,20 @@ export function getMinimumPackageAgeHours() {
return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS;
}
/**
* Gets the custom npm registries from environment variable
* Expected format: comma-separated list of registry domains
* Example: "npm.company.com,registry.internal.net"
* @returns {string | undefined}
*/
export function getNpmCustomRegistries() {
return process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
}
/**
* Gets the custom pip registries from environment variable
* Expected format: comma-separated list of registry domains
* Example: "pip.company.com,registry.internal.net"
* @returns {string | undefined}
*/
export function getPipCustomRegistries() {

View file

@ -81,7 +81,7 @@ function validateMinimumPackageAgeHours(value) {
return undefined;
}
if (numericValue > 0) {
if (numericValue >= 0) {
return numericValue;
}
@ -99,29 +99,66 @@ export function skipMinimumPackageAge() {
return defaultSkipMinimumPackageAge;
}
/** @type {string[]} */
const defaultPipCustomRegistries = [];
/** @returns {string[]} */
export function getPipCustomRegistries() {
// Priority 1: Environment variable
const envValue = validatePipCustomRegistries(
environmentVariables.getPipCustomRegistries()
);
if (envValue !== undefined) {
return envValue;
}
return defaultPipCustomRegistries;
/**
* Normalizes a registry URL by removing protocol if present
* @param {string} registry
* @returns {string}
*/
function normalizeRegistry(registry) {
// Remove protocol (http://, https://) if present
return registry.replace(/^https?:\/\//, "");
}
/**
* @param {string | undefined} value
* @returns {string[] | undefined}
* Parses comma-separated registries from environment variable
* @param {string | undefined} envValue
* @returns {string[]}
*/
function validatePipCustomRegistries(value) {
if (!value) {
return undefined;
function parseRegistriesFromEnv(envValue) {
if (!envValue || typeof envValue !== "string") {
return [];
}
return value.split(",");
// Split by comma and trim whitespace
return envValue
.split(",")
.map((registry) => registry.trim())
.filter((registry) => registry.length > 0);
}
/**
* Gets the custom npm registries from both environment variable and config file (merged)
* @returns {string[]}
*/
export function getNpmCustomRegistries() {
const envRegistries = parseRegistriesFromEnv(
environmentVariables.getNpmCustomRegistries()
);
const configRegistries = configFile.getNpmCustomRegistries();
// Merge both sources and remove duplicates
const allRegistries = [...envRegistries, ...configRegistries];
const uniqueRegistries = [...new Set(allRegistries)];
// Normalize each registry (remove protocol if any)
return uniqueRegistries.map(normalizeRegistry);
}
/**
* Gets the custom npm registries from both environment variable and config file (merged)
* @returns {string[]}
*/
export function getPipCustomRegistries() {
const envRegistries = parseRegistriesFromEnv(
environmentVariables.getPipCustomRegistries()
);
// const configRegistries = configFile.getPipCustomRegistries();
// Merge both sources and remove duplicates
// const allRegistries = [...envRegistries, ...configRegistries];
const allRegistries = [...envRegistries];
const uniqueRegistries = [...new Set(allRegistries)];
// Normalize each registry (remove protocol if any)
return uniqueRegistries.map(normalizeRegistry);
}

View file

@ -0,0 +1,249 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
let configFileContent = undefined;
mock.module("fs", {
namedExports: {
existsSync: () => configFileContent !== undefined,
readFileSync: () => configFileContent,
writeFileSync: (content) => (configFileContent = content),
mkdirSync: () => {},
},
});
describe("getNpmCustomRegistries", async () => {
let originalEnv;
const { getNpmCustomRegistries } = await import("./settings.js");
beforeEach(() => {
originalEnv = process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = originalEnv;
} else {
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
}
configFileContent = undefined;
});
it("should return empty array when no registries configured", () => {
configFileContent = undefined;
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should return registries without protocol", () => {
configFileContent = JSON.stringify({
npm: {
customRegistries: ["npm.company.com", "registry.internal.net"],
},
});
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, [
"npm.company.com",
"registry.internal.net",
]);
});
it("should strip https:// protocol from registries", () => {
configFileContent = JSON.stringify({
npm: {
customRegistries: [
"https://npm.company.com",
"https://registry.internal.net",
],
},
});
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, [
"npm.company.com",
"registry.internal.net",
]);
});
it("should strip http:// protocol from registries", () => {
configFileContent = JSON.stringify({
npm: {
customRegistries: [
"http://npm.company.com",
"http://registry.internal.net",
],
},
});
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, [
"npm.company.com",
"registry.internal.net",
]);
});
it("should handle mixed protocols and no protocol", () => {
configFileContent = JSON.stringify({
npm: {
customRegistries: [
"https://npm.company.com",
"registry.internal.net",
"http://private.registry.io",
],
},
});
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, [
"npm.company.com",
"registry.internal.net",
"private.registry.io",
]);
});
it("should preserve registry path after stripping protocol", () => {
configFileContent = JSON.stringify({
npm: {
customRegistries: [
"https://npm.company.com/custom/path",
"registry.internal.net/npm",
],
},
});
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, [
"npm.company.com/custom/path",
"registry.internal.net/npm",
]);
});
it("should parse comma-separated registries from environment variable", () => {
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES =
"env1.registry.com,env2.registry.net";
configFileContent = undefined;
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"env2.registry.net",
]);
});
it("should trim whitespace from environment variable registries", () => {
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES =
" env1.registry.com , env2.registry.net ";
configFileContent = undefined;
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"env2.registry.net",
]);
});
it("should merge environment variable and config file registries", () => {
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "env1.registry.com";
configFileContent = JSON.stringify({
npm: {
customRegistries: ["config1.registry.net"],
},
});
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"config1.registry.net",
]);
});
it("should remove duplicate registries when merging env and config", () => {
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES =
"npm.company.com,env.registry.com";
configFileContent = JSON.stringify({
npm: {
customRegistries: ["npm.company.com", "config.registry.net"],
},
});
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, [
"npm.company.com",
"env.registry.com",
"config.registry.net",
]);
});
it("should normalize protocols from environment variable registries", () => {
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES =
"https://env1.registry.com,http://env2.registry.net";
configFileContent = undefined;
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"env2.registry.net",
]);
});
it("should handle empty strings in comma-separated list", () => {
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES =
"env1.registry.com,,env2.registry.net,";
configFileContent = undefined;
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"env2.registry.net",
]);
});
it("should handle single registry in environment variable", () => {
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "single.registry.com";
configFileContent = undefined;
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, ["single.registry.com"]);
});
it("should return empty array for empty environment variable", () => {
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "";
configFileContent = undefined;
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should return empty array for whitespace-only environment variable", () => {
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = " , , ";
configFileContent = undefined;
const registries = getNpmCustomRegistries();
assert.deepStrictEqual(registries, []);
});
});

View file

@ -23,6 +23,7 @@ export async function main(args) {
process.on("uncaughtException", (error) => {
ui.writeError(`Safe-chain: Uncaught exception: ${error.message}`);
ui.writeVerbose(`Stack trace: ${error.stack}`);
ui.writeBufferedLogsAndStopBuffering();
process.exit(1);
});
@ -31,6 +32,7 @@ export async function main(args) {
if (reason instanceof Error) {
ui.writeVerbose(`Stack trace: ${reason.stack}`);
}
ui.writeBufferedLogsAndStopBuffering();
process.exit(1);
});
@ -89,6 +91,7 @@ export async function main(args) {
return packageManagerResult.status;
} catch (/** @type any */ error) {
ui.writeError("Failed to check for malicious packages:", error.message);
ui.writeBufferedLogsAndStopBuffering();
// Returning the exit code back to the caller allows the promise
// to be awaited in the bin files and return the correct exit code

View file

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

View file

@ -8,6 +8,7 @@ import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import ini from "ini";
import { spawn } from "child_process";
/**
* Checks if this pip invocation should bypass safe-chain and spawn directly.
@ -16,7 +17,7 @@ import ini from "ini";
* @param {string[]} args - The arguments
* @returns {boolean}
*/
function shouldBypassSafeChain(command, args) {
export function shouldBypassSafeChain(command, args) {
if (command === PYTHON_COMMAND || command === PYTHON3_COMMAND) {
// Check if args start with -m pip
if (args.length >= 2 && args[0] === "-m" && (args[1] === PIP_COMMAND || args[1] === PIP3_COMMAND)) {
@ -77,14 +78,16 @@ export async function runPip(command, args) {
if (shouldBypassSafeChain(command, args)) {
ui.writeVerbose(`Safe-chain: Bypassing safe-chain for non-pip invocation: ${command} ${args.join(" ")}`);
// Spawn the ORIGINAL command with ORIGINAL args
const { spawn } = await import("child_process");
return new Promise((_resolve) => {
const proc = spawn(command, args, { stdio: "inherit" });
proc.on("exit", (/** @type {number | null} */ code) => {
ui.writeVerbose(`${command} ${args.join(" ")} exited with status ${code}`);
ui.writeBufferedLogsAndStopBuffering();
process.exit(code ?? 0);
});
proc.on("error", (/** @type {Error} */ err) => {
ui.writeError(`Error executing command: ${err.message}`);
ui.writeBufferedLogsAndStopBuffering();
process.exit(1);
});
});
@ -93,7 +96,7 @@ export async function runPip(command, args) {
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
// Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots)
// Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots + user certs)
// so that any network request made by pip, including those outside explicit CLI args,
// validates correctly under both MITM'd and tunneled HTTPS.
const combinedCaPath = getCombinedCaBundlePath();

View file

@ -7,6 +7,7 @@ import ini from "ini";
describe("runPipCommand environment variable handling", () => {
let runPip;
let shouldBypassSafeChain;
let capturedArgs = null;
let customEnv = null;
let capturedConfigContent = null; // Capture config file content before cleanup
@ -56,6 +57,7 @@ describe("runPipCommand environment variable handling", () => {
const mod = await import("./runPipCommand.js");
runPip = mod.runPip;
shouldBypassSafeChain = mod.shouldBypassSafeChain;
});
afterEach(() => {
@ -397,4 +399,21 @@ describe("runPipCommand environment variable handling", () => {
assert.ok(output.includes("proxy found in PIP_CONFIG_FILE"), "Should warn about proxy overwrite in output");
customEnv = null;
});
it("should bypass safe-chain for python correctly", async () => {
assert.strictEqual(shouldBypassSafeChain("python", []), true);
assert.strictEqual(shouldBypassSafeChain("python3", []), true);
assert.strictEqual(shouldBypassSafeChain("python", ["--version"]), true);
assert.strictEqual(shouldBypassSafeChain("python3", ["--version"]), true);
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "http.server"]), true);
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "http.server"]), true);
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip"]), false);
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip"]), false);
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip3"]), false);
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip3"]), false);
});
});

View file

@ -0,0 +1,18 @@
import { runPipX } from "./runPipXCommand.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createPipXPackageManager() {
return {
/**
* @param {string[]} args
*/
runCommand: (args) => {
return runPipX("pipx", args);
},
// MITM only
isSupportedCommand: () => false,
getDependencyUpdatesForCommand: () => [],
};
}

View file

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

View file

@ -0,0 +1,65 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
/**
* Sets CA bundle environment variables used by Python libraries and pipx.
*
* @param {NodeJS.ProcessEnv} env - Env object
* @param {string} combinedCaPath - Path to the combined CA bundle
* @return {NodeJS.ProcessEnv} Modified environment object
*/
function getPipXCaBundleEnvironmentVariables(env, combinedCaPath) {
let retVal = { ...env };
if (env.SSL_CERT_FILE) {
ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.");
}
retVal.SSL_CERT_FILE = combinedCaPath;
if (env.REQUESTS_CA_BUNDLE) {
ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.");
}
retVal.REQUESTS_CA_BUNDLE = combinedCaPath;
if (env.PIP_CERT) {
ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.");
}
retVal.PIP_CERT = combinedCaPath;
return retVal;
}
/**
* Runs a pipx command with safe-chain's certificate bundle and proxy configuration.
*
* @param {string} command - The command to execute
* @param {string[]} args - Command line arguments
* @returns {Promise<{status: number}>} Exit status of the command
*/
export async function runPipX(command, args) {
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
const combinedCaPath = getCombinedCaBundlePath();
const modifiedEnv = getPipXCaBundleEnvironmentVariables(env, combinedCaPath);
// Note: pipx uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration
// These are already set by mergeSafeChainProxyEnvironmentVariables
const result = await safeSpawn(command, args, {
stdio: "inherit",
env: modifiedEnv,
});
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 '${command}' installed and available on your system?`);
return { status: 1 };
}
}
}

View file

@ -0,0 +1,80 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("runPipXCommand", () => {
let runPipX;
let safeSpawnMock;
let warnMock;
let errorMock;
let mergeCalls;
let mergedEnvReturn;
beforeEach(async () => {
mergeCalls = [];
mergedEnvReturn = {
HTTPS_PROXY: "http://localhost:8080",
HTTP_PROXY: "",
};
safeSpawnMock = mock.fn(async () => ({ status: 0 }));
warnMock = mock.fn();
errorMock = mock.fn();
mock.module("../../environment/userInteraction.js", {
namedExports: {
ui: {
writeWarning: warnMock,
writeError: errorMock,
writeInfo: () => {},
writeVerbose: () => {},
writeSuccess: () => {},
},
},
});
mock.module("../../registryProxy/registryProxy.js", {
namedExports: {
mergeSafeChainProxyEnvironmentVariables: (env) => {
mergeCalls.push(env);
return { ...env, ...mergedEnvReturn };
},
},
});
mock.module("../../registryProxy/certBundle.js", {
namedExports: {
getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem",
},
});
mock.module("../../utils/safeSpawn.js", {
namedExports: {
safeSpawn: safeSpawnMock,
},
});
const mod = await import("./runPipXCommand.js");
runPipX = mod.runPipX;
});
afterEach(() => {
mock.reset();
});
it("sets CA env vars and proxies before spawning", async () => {
const res = await runPipX("pipx", ["install", "ruff"]);
assert.strictEqual(res.status, 0);
assert.strictEqual(safeSpawnMock.mock.calls.length, 1, "safeSpawn should be called once");
const [, , options] = safeSpawnMock.mock.calls[0].arguments;
const env = options.env;
assert.strictEqual(env.SSL_CERT_FILE, "/tmp/test-combined-ca.pem");
assert.strictEqual(env.REQUESTS_CA_BUNDLE, "/tmp/test-combined-ca.pem");
assert.strictEqual(env.PIP_CERT, "/tmp/test-combined-ca.pem");
assert.strictEqual(env.HTTPS_PROXY, "http://localhost:8080");
assert.strictEqual(env.HTTP_PROXY, "");
assert.ok(mergeCalls.length >= 1, "proxy merge should be invoked");
});
});

View file

@ -6,6 +6,7 @@ import certifi from "certifi";
import tls from "node:tls";
import { X509Certificate } from "node:crypto";
import { getCaCertPath } from "./certUtils.js";
import { ui } from "../environment/userInteraction.js";
/**
* Check if a PEM string contains only parsable cert blocks.
@ -14,6 +15,7 @@ import { getCaCertPath } from "./certUtils.js";
*/
function isParsable(pem) {
if (!pem || typeof pem !== "string") return false;
pem = normalizeLineEndings(pem);
const begin = "-----BEGIN CERTIFICATE-----";
const end = "-----END CERTIFICATE-----";
const blocks = [];
@ -41,20 +43,17 @@ function isParsable(pem) {
}
}
/** @type {string | null} */
let cachedPath = null;
/**
* Build a combined CA bundle for Python and Node HTTPS flows.
* - Includes Safe Chain CA (for MITM of known registries)
* - Includes Mozilla roots via npm `certifi` (public HTTPS)
* - Includes Node's built-in root certificates as a portable fallback
* Build a combined CA bundle.
* Automatically includes:
* - Safe Chain CA (for MITM of known registries)
* - Mozilla roots via certifi (for public HTTPS)
* - Node's built-in root certificates (fallback)
* - User's custom certificates (if NODE_EXTRA_CA_CERTS environment variable is set)
*
* @returns {string} Path to the combined CA bundle PEM file
*/
export function getCombinedCaBundlePath() {
if (cachedPath && fs.existsSync(cachedPath)) return cachedPath;
// Concatenate PEM files
const parts = [];
// 1) Safe Chain CA (for MITM'd registries)
@ -87,9 +86,96 @@ export function getCombinedCaBundlePath() {
// Ignore if unavailable
}
// 4) User's NODE_EXTRA_CA_CERTS (if set)
const userCertPath = process.env.NODE_EXTRA_CA_CERTS;
if (userCertPath) {
const userPem = readUserCertificateFile(userCertPath);
if (userPem) {
parts.push(userPem.trim());
ui.writeVerbose(`Safe-chain: Merging user's NODE_EXTRA_CA_CERTS from ${userCertPath}`);
} else {
ui.writeWarning(`Safe-chain: Could not read or parse user's NODE_EXTRA_CA_CERTS from ${userCertPath}`);
}
}
const combined = parts.filter(Boolean).join("\n");
const target = path.join(os.tmpdir(), "safe-chain-ca-bundle.pem");
const target = path.join(os.tmpdir(), `safe-chain-ca-bundle-${Date.now()}.pem`);
fs.writeFileSync(target, combined, { encoding: "utf8" });
cachedPath = target;
return cachedPath;
return target;
}
/**
* Normalize path
* @param {string} p - Path to normalize
* @returns {string}
*/
function normalizePathF(p) {
return p.replace(/\\/g, "/");
}
/**
* Normalize line endings to LF
* @param {string} text - Text with mixed line endings
* @returns {string}
*/
function normalizeLineEndings(text) {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
/**
* Read and validate user certificate file
* @param {string} certPath - Path to certificate file
* @returns {string | null} Certificate PEM content or null if invalid/unreadable
*/
function readUserCertificateFile(certPath) {
try {
// 1) Basic validation
if (typeof certPath !== "string" || certPath.trim().length === 0) {
return null;
}
// 2) Reject path traversal attempts (normalize backslashes first for Windows paths)
const normalizedPath = normalizePathF(certPath);
if (normalizedPath.includes("..")) {
return null;
}
// 3) Check if file exists and is not a directory or symlink
let stats;
try {
stats = fs.lstatSync(certPath);
} catch {
// File doesn't exist or can't be accessed
return null;
}
if (!stats.isFile()) {
// Reject directories and symlinks
return null;
}
// 4) Read file content
let content;
try {
content = fs.readFileSync(certPath, "utf8");
} catch {
return null;
}
if (!content || typeof content !== "string") {
return null;
}
// 5) Validate PEM format
if (!isParsable(content)) {
return null;
}
return content;
} catch {
// Silently fail on any errors
return null;
}
}

View file

@ -15,6 +15,13 @@ function removeBundleIfExists() {
}
}
// Utility to get a valid PEM certificate for testing
function getValidCert() {
const cert = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : "";
assert.ok(cert.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test");
return cert;
}
describe("certBundle.getCombinedCaBundlePath", () => {
beforeEach(() => {
mock.restoreAll();
@ -69,3 +76,304 @@ describe("certBundle.getCombinedCaBundlePath", () => {
assert.ok(!contents.includes(invalidMarker), "Bundle should not include invalid Safe Chain content");
});
});
describe("certBundle.getCombinedCaBundlePath with user certs", () => {
beforeEach(() => {
mock.restoreAll();
delete process.env.NODE_EXTRA_CA_CERTS;
});
it("returns a path with full CA bundle (Safe Chain + Mozilla + Node roots) when no user cert in env", async () => {
// Mock getCaCertPath to return valid cert
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain certificate blocks");
// Should include base bundle (Safe Chain + Mozilla/Node roots)
assert.ok(contents.length > 1000, "Bundle should be substantial with Mozilla/Node roots included");
});
it("merges user cert with full base bundle (Safe Chain CA + Mozilla + Node roots)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
// Create Safe Chain CA
const safeChainPath = path.join(tmpDir, "safechain.pem");
const safeChainCert = getValidCert();
fs.writeFileSync(safeChainPath, safeChainCert, "utf8");
// Create user cert file
const userCertPath = path.join(tmpDir, "user-cert.pem");
const userCert = getValidCert();
fs.writeFileSync(userCertPath, userCert, "utf8");
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Both certs should be in the bundle
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
assert.ok(certCount >= 2, "Bundle should contain both Safe Chain and user certificates");
});
it("ignores non-existent user cert path", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
process.env.NODE_EXTRA_CA_CERTS = "/nonexistent/path.pem";
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should still have Safe Chain CA
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
});
it("ignores invalid PEM user cert", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
const userCertPath = path.join(tmpDir, "invalid.pem");
fs.writeFileSync(userCertPath, "NOT A VALID PEM", "utf8");
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should still have Safe Chain CA only
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
assert.ok(!contents.includes("NOT A VALID"), "Should not include invalid cert");
});
it("rejects user cert with path traversal attempts", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
process.env.NODE_EXTRA_CA_CERTS = "../../../etc/passwd";
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should only have Safe Chain CA, rejected the traversal path
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
});
it("rejects user cert with symlink", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
// Create a target file and a symlink to it
const targetCert = path.join(tmpDir, "target.pem");
fs.writeFileSync(targetCert, getValidCert(), "utf8");
const symlinkPath = path.join(tmpDir, "symlink.pem");
try {
fs.symlinkSync(targetCert, symlinkPath);
} catch {
// Skip test if symlinks are not supported (e.g., on Windows without admin)
return;
}
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
process.env.NODE_EXTRA_CA_CERTS = symlinkPath;
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should only have Safe Chain CA, symlinks are rejected
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
});
it("rejects user cert that is a directory", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
const certDir = path.join(tmpDir, "certs");
fs.mkdirSync(certDir);
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
process.env.NODE_EXTRA_CA_CERTS = certDir;
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should only have Safe Chain CA
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
});
it("handles empty string user cert path", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
process.env.NODE_EXTRA_CA_CERTS = " ";
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
});
it("accepts files with CRLF line endings (Windows-style)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
// Create a real file with CRLF content to test Windows line ending support
const userCertPath = path.join(tmpDir, "user-cert-crlf.pem");
const userCert = getValidCert();
const certWithCRLF = userCert.replace(/\n/g, "\r\n");
fs.writeFileSync(userCertPath, certWithCRLF, "utf8");
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
assert.ok(certCount >= 2, "Bundle should contain Safe Chain and user certificates with CRLF");
});
it("detects and handles Windows-style path syntax (drive letters and UNC)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
// Test that Windows path syntax is recognized (even if files don't exist on macOS/Linux)
// These should gracefully fail (return Safe Chain CA only) rather than crash
const winPaths = [
"C:\\temp\\cert.pem",
"D:\\Users\\name\\certs\\ca.pem",
"\\\\server\\share\\cert.pem"
];
for (const winPath of winPaths) {
process.env.NODE_EXTRA_CA_CERTS = winPath;
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), `Bundle should exist for ${winPath}`);
const contents = fs.readFileSync(bundlePath, "utf8");
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
}
});
it("rejects path traversal with Windows-style paths (C:\\temp\\..\\etc\\passwd)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
// Test various Windows-style traversal attempts
const traversalPaths = [
"C:\\temp\\..\\etc\\passwd",
"D:\\Users\\..\\..\\Windows\\System32",
"\\\\server\\share\\..\\admin",
"../../../etc/passwd", // Unix-style for comparison
];
// First, get baseline bundle without user certs to know expected cert count
delete process.env.NODE_EXTRA_CA_CERTS;
const baselineBundlePath = getCombinedCaBundlePath();
const baselineContents = fs.readFileSync(baselineBundlePath, "utf8");
const baselineCertCount = (baselineContents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
for (const badPath of traversalPaths) {
process.env.NODE_EXTRA_CA_CERTS = badPath;
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should contain base bundle (Safe Chain + Mozilla + Node roots) but NOT user cert
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
assert.strictEqual(certCount, baselineCertCount, `Traversal path ${badPath} should be rejected; base bundle only (no user cert added)`);
}
});
});

View file

@ -1,4 +1,7 @@
import { skipMinimumPackageAge } from "../../../config/settings.js";
import {
getNpmCustomRegistries,
skipMinimumPackageAge,
} from "../../../config/settings.js";
import { isMalwarePackage } from "../../../scanning/audit/index.js";
import { interceptRequests } from "../interceptorBuilder.js";
import {
@ -8,14 +11,20 @@ import {
} from "./modifyNpmInfo.js";
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
const knownJsRegistries = [
"registry.npmjs.org",
"registry.yarnpkg.com",
"registry.npmjs.com",
];
/**
* @param {string} url
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
*/
export function npmInterceptorForUrl(url) {
const registry = knownJsRegistries.find((reg) => url.includes(reg));
const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find(
(reg) => url.includes(reg)
);
if (registry) {
return buildNpmInterceptor(registry);

View file

@ -9,6 +9,7 @@ describe("npmInterceptor minimum package age", async () => {
namedExports: {
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
getNpmCustomRegistries: () => [],
},
});

View file

@ -1,19 +1,36 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
describe("npmInterceptor", async () => {
let lastPackage;
let malwareResponse = false;
let lastPackage;
let malwareResponse = false;
let customRegistries = [];
mock.module("../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };
return malwareResponse;
},
mock.module("../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };
return malwareResponse;
},
});
},
});
mock.module("../../../config/settings.js", {
namedExports: {
LOGGING_SILENT: "silent",
LOGGING_NORMAL: "normal",
LOGGING_VERBOSE: "verbose",
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
getLoggingLevel: () => "normal",
getEcoSystem: () => "js",
setEcoSystem: () => {},
getMinimumPackageAgeHours: () => 24,
getNpmCustomRegistries: () => customRegistries,
skipMinimumPackageAge: () => false,
},
});
describe("npmInterceptor", async () => {
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
const parserCases = [
@ -161,3 +178,90 @@ describe("npmInterceptor", async () => {
);
});
});
describe("npmInterceptor with custom registries", async () => {
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
it("should create interceptor for custom registry", async () => {
// Set custom registries for this test
customRegistries = ["npm.company.com", "registry.internal.net"];
const url = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz";
const interceptor = npmInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created for custom registry");
await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, {
packageName: "lodash",
version: "4.17.21",
});
});
it("should create interceptor for custom registry with scoped packages", async () => {
// Set custom registries for this test
customRegistries = ["npm.company.com", "registry.internal.net"];
malwareResponse = false;
const url =
"https://registry.internal.net/@company/package/-/package-1.0.0.tgz";
const interceptor = npmInterceptorForUrl(url);
assert.ok(
interceptor,
"Interceptor should be created for custom registry with scoped package"
);
await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, {
packageName: "@company/package",
version: "1.0.0",
});
});
it("should handle multiple custom registries", async () => {
// Set custom registries for this test
customRegistries = ["npm.company.com", "registry.internal.net"];
malwareResponse = false;
const url1 = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz";
const url2 = "https://registry.internal.net/express/-/express-4.18.2.tgz";
const interceptor1 = npmInterceptorForUrl(url1);
const interceptor2 = npmInterceptorForUrl(url2);
assert.ok(interceptor1, "Should create interceptor for first registry");
assert.ok(interceptor2, "Should create interceptor for second registry");
await interceptor1.handleRequest(url1);
assert.deepEqual(lastPackage, {
packageName: "lodash",
version: "4.17.21",
});
await interceptor2.handleRequest(url2);
assert.deepEqual(lastPackage, {
packageName: "express",
version: "4.18.2",
});
});
it("should not create interceptor for non-custom registry", () => {
// Set custom registries for this test
customRegistries = ["npm.company.com", "registry.internal.net"];
malwareResponse = false;
const url = "https://unknown.registry.com/package/-/package-1.0.0.tgz";
const interceptor = npmInterceptorForUrl(url);
assert.equal(
interceptor,
undefined,
"Should not create interceptor for unknown registry"
);
});
});

View file

@ -182,13 +182,13 @@ describe("registryProxy.connectTunnel", () => {
const duration = Date.now() - startTime;
// Should return 502 Bad Gateway
// Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts)
assert.ok(
responseData.includes("HTTP/1.1 502 Bad Gateway"),
"Should return 502 for timeout"
responseData.includes("HTTP/1.1 504 Gateway Timeout"),
"Should return 504 for timeout"
);
// Should timeout around 3 seconds for IMDS endpoints (allow some margin)
// Should timeout around 100ms for IMDS endpoints (allow some margin)
assert.ok(
duration >= 80 && duration < 200,
`IMDS timeout should be ~80-200ms, got ${duration}ms`
@ -280,10 +280,10 @@ describe("registryProxy.connectTunnel", () => {
const duration = Date.now() - startTime;
// Should return 502 Bad Gateway (timeout)
// Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts)
assert.ok(
responseData.includes("HTTP/1.1 502 Bad Gateway"),
"Should return 502 for timeout"
responseData.includes("HTTP/1.1 504 Gateway Timeout"),
"Should return 504 for timeout"
);
// Should NOT be instant - it should retry the connection (taking ~500ms due to mock timeout)

View file

@ -2,7 +2,7 @@ import * as http from "http";
import { tunnelRequest } from "./tunnelRequestHandler.js";
import { mitmConnect } from "./mitmRequestHandler.js";
import { handleHttpProxyRequest } from "./plainHttpProxy.js";
import { getCaCertPath } from "./certUtils.js";
import { getCombinedCaBundlePath } from "./certBundle.js";
import { ui } from "../environment/userInteraction.js";
import chalk from "chalk";
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
@ -37,10 +37,12 @@ function getSafeChainProxyEnvironmentVariables() {
}
const proxyUrl = `http://localhost:${state.port}`;
const caCertPath = getCombinedCaBundlePath();
return {
HTTPS_PROXY: proxyUrl,
GLOBAL_AGENT_HTTP_PROXY: proxyUrl,
NODE_EXTRA_CA_CERTS: getCaCertPath(),
NODE_EXTRA_CA_CERTS: caCertPath,
};
}

View file

@ -43,6 +43,7 @@ export function tunnelRequest(req, clientSocket, head) {
function tunnelRequestToDestination(req, clientSocket, head) {
const { port, hostname } = new URL(`http://${req.url}`);
const isImds = isImdsEndpoint(hostname);
const targetPort = Number.parseInt(port) || 443;
if (timedoutImdsEndpoints.includes(hostname)) {
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
@ -58,64 +59,77 @@ function tunnelRequestToDestination(req, clientSocket, head) {
return;
}
const serverSocket = net.connect(
Number.parseInt(port) || 443,
hostname,
() => {
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
serverSocket.write(head);
serverSocket.pipe(clientSocket);
clientSocket.pipe(serverSocket);
}
);
// Set explicit connection timeout to avoid waiting for OS default (~2 minutes).
// IMDS endpoints get shorter timeout (3s) since they're commonly unreachable outside cloud environments.
const connectTimeout = getConnectTimeout(hostname);
serverSocket.setTimeout(connectTimeout);
serverSocket.on("timeout", () => {
// Suppress error logging for IMDS endpoints - timeouts are expected when not in cloud
// Use JS setTimeout for true connection timeout (not idle timeout).
// socket.setTimeout() measures inactivity, not time since connection attempt.
const connectTimer = setTimeout(() => {
if (isImds) {
timedoutImdsEndpoints.push(hostname);
ui.writeVerbose(
`Safe-chain: connect to ${hostname}:${
port || 443
} timed out after ${connectTimeout}ms`
`Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`
);
} else {
ui.writeError(
`Safe-chain: connect to ${hostname}:${
port || 443
} timed out after ${connectTimeout}ms`
`Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`
);
}
serverSocket.destroy(); // Clean up socket to prevent event loop hanging
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
serverSocket.destroy();
if (clientSocket.writable) {
clientSocket.end("HTTP/1.1 504 Gateway Timeout\r\n\r\n");
}
}, connectTimeout);
const serverSocket = net.connect(targetPort, hostname, () => {
// Clear timer to prevent false timeout errors after successful connection
clearTimeout(connectTimer);
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
serverSocket.write(head);
serverSocket.pipe(clientSocket);
clientSocket.pipe(serverSocket);
});
clientSocket.on("error", () => {
// This can happen if the client TCP socket sends RST instead of FIN.
// Not subscribing to 'error' event will cause node to throw and crash.
clearTimeout(connectTimer);
if (serverSocket.writable) {
serverSocket.end();
}
});
clientSocket.on("close", () => {
// Client closed connection - clean up server socket
clearTimeout(connectTimer);
if (serverSocket.writable) {
serverSocket.end();
}
});
serverSocket.on("error", (err) => {
clearTimeout(connectTimer);
if (isImds) {
ui.writeVerbose(
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`
);
} else {
ui.writeError(
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`
);
}
if (clientSocket.writable) {
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
}
});
serverSocket.on("close", () => {
// Server closed connection - clean up client socket
clearTimeout(connectTimer);
if (clientSocket.writable) {
clientSocket.end();
}
});
}
/**

View file

@ -94,6 +94,12 @@ export const knownAikidoTools = [
ecoSystem: ECOSYSTEM_PY,
internalPackageManagerName: "pip",
},
{
tool: "pipx",
aikidoCommand: "aikido-pipx",
ecoSystem: ECOSYSTEM_PY,
internalPackageManagerName: "pipx",
}
// When adding a new tool here, also update the documentation for the new tool in the README.md
];
@ -113,6 +119,20 @@ export function getPackageManagerList() {
return `${tools.join(", ")}, and ${lastTool} commands`;
}
/**
* @returns {string}
*/
export function getShimsDir() {
return path.join(os.homedir(), ".safe-chain", "shims");
}
/**
* @returns {string}
*/
export function getScriptsDir() {
return path.join(os.homedir(), ".safe-chain", "scripts");
}
/**
* @param {string} executableName
*

View file

@ -1,12 +1,10 @@
import chalk from "chalk";
import { ui } from "../environment/userInteraction.js";
import { getPackageManagerList, knownAikidoTools } from "./helpers.js";
import { getPackageManagerList, knownAikidoTools, getShimsDir } from "./helpers.js";
import fs from "fs";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { includePython } from "../config/cliArguments.js";
import { ECOSYSTEM_PY } from "../config/settings.js";
/** @type {string} */
// This checks the current file's dirname in a way that's compatible with:
@ -32,7 +30,7 @@ export async function setupCi() {
);
ui.emptyLine();
const shimsDir = path.join(os.homedir(), ".safe-chain", "shims");
const shimsDir = getShimsDir();
const binDir = path.join(os.homedir(), ".safe-chain", "bin");
// Create the shims directory if it doesn't exist
if (!fs.existsSync(shimsDir)) {
@ -159,12 +157,16 @@ function modifyPathForCi(shimsDir, binDir) {
ui.writeInformation("##vso[task.prependpath]" + shimsDir);
ui.writeInformation("##vso[task.prependpath]" + binDir);
}
if (process.env.BASH_ENV) {
// In CircleCI, persisting PATH across steps is done by appending shell exports
// to the file referenced by BASH_ENV. CircleCI sources this file for 'run' each step.
const exportLine = `export PATH="${shimsDir}:${binDir}:$PATH"` + os.EOL;
fs.appendFileSync(process.env.BASH_ENV, exportLine, "utf-8");
ui.writeInformation(`Added shims directory to BASH_ENV for CircleCI.`);
}
}
function getToolsToSetup() {
if (includePython()) {
return knownAikidoTools;
} else {
return knownAikidoTools.filter((tool) => tool.ecoSystem !== ECOSYSTEM_PY);
}
return knownAikidoTools;
}

View file

@ -50,6 +50,7 @@ describe("Setup CI shell integration", () => {
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
],
getPackageManagerList: () => "npm, yarn",
getShimsDir: () => mockShimsDir,
},
});

View file

@ -1,11 +1,9 @@
import chalk from "chalk";
import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.js";
import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
import { knownAikidoTools, getPackageManagerList, getScriptsDir } from "./helpers.js";
import fs from "fs";
import os from "os";
import path from "path";
import { includePython } from "../config/cliArguments.js";
import { fileURLToPath } from "url";
/** @type {string} */
@ -107,10 +105,10 @@ function setupShell(shell) {
function copyStartupFiles() {
const startupFiles = ["init-posix.sh", "init-pwsh.ps1", "init-fish.fish"];
const targetDir = getScriptsDir();
for (const file of startupFiles) {
const targetDir = path.join(os.homedir(), ".safe-chain", "scripts");
const targetPath = path.join(os.homedir(), ".safe-chain", "scripts", file);
const targetPath = path.join(targetDir, file);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
@ -119,7 +117,7 @@ function copyStartupFiles() {
// Use absolute path for source
const sourcePath = path.join(
dirname,
includePython() ? "startup-scripts/include-python" : "startup-scripts",
"startup-scripts",
file
);
fs.copyFileSync(sourcePath, targetPath);

View file

@ -1,98 +0,0 @@
set -gx PATH $PATH $HOME/.safe-chain/bin
function npx
wrapSafeChainCommand "npx" $argv
end
function yarn
wrapSafeChainCommand "yarn" $argv
end
function pnpm
wrapSafeChainCommand "pnpm" $argv
end
function pnpx
wrapSafeChainCommand "pnpx" $argv
end
function bun
wrapSafeChainCommand "bun" $argv
end
function bunx
wrapSafeChainCommand "bunx" $argv
end
function npm
# If args is just -v or --version and nothing else, just run the `npm -v` command
# This is because nvm uses this to check the version of npm
set argc (count $argv)
if test $argc -eq 1
switch $argv[1]
case "-v" "--version"
command npm $argv
return
end
end
wrapSafeChainCommand "npm" $argv
end
function pip
wrapSafeChainCommand "pip" $argv
end
function pip3
wrapSafeChainCommand "pip3" $argv
end
function uv
wrapSafeChainCommand "uv" $argv
end
function poetry
wrapSafeChainCommand "poetry" $argv
end
# `python -m pip`, `python -m pip3`.
function python
wrapSafeChainCommand "python" $argv
end
# `python3 -m pip`, `python3 -m pip3'.
function python3
wrapSafeChainCommand "python3" $argv
end
function printSafeChainWarning
set original_cmd $argv[1]
# Fish equivalent of ANSI color codes: yellow background, black text for "Warning:"
set_color -b yellow black
printf "Warning:"
set_color normal
printf " safe-chain is not available to protect you from installing malware. %s will run without it.\n" $original_cmd
# Cyan text for the install command
printf "Install safe-chain by using "
set_color cyan
printf "npm install -g @aikidosec/safe-chain"
set_color normal
printf ".\n"
end
function wrapSafeChainCommand
set original_cmd $argv[1]
set cmd_args $argv[2..-1]
if type -q safe-chain
# If the safe-chain command is available, just run it with the provided arguments
safe-chain $original_cmd $cmd_args
else
# If the safe-chain command is not available, print a warning and run the original command
printSafeChainWarning $original_cmd
command $original_cmd $cmd_args
end
end

View file

@ -1,85 +0,0 @@
export PATH="$PATH:$HOME/.safe-chain/bin"
function npx() {
wrapSafeChainCommand "npx" "$@"
}
function yarn() {
wrapSafeChainCommand "yarn" "$@"
}
function pnpm() {
wrapSafeChainCommand "pnpm" "$@"
}
function pnpx() {
wrapSafeChainCommand "pnpx" "$@"
}
function bun() {
wrapSafeChainCommand "bun" "$@"
}
function bunx() {
wrapSafeChainCommand "bunx" "$@"
}
function npm() {
if [[ "$1" == "-v" || "$1" == "--version" ]] && [[ $# -eq 1 ]]; then
# If args is just -v or --version and nothing else, just run the npm version command
# This is because nvm uses this to check the version of npm
command npm "$@"
return
fi
wrapSafeChainCommand "npm" "$@"
}
function pip() {
wrapSafeChainCommand "pip" "$@"
}
function pip3() {
wrapSafeChainCommand "pip3" "$@"
}
function uv() {
wrapSafeChainCommand "uv" "$@"
}
function poetry() {
wrapSafeChainCommand "poetry" "$@"
}
# `python -m pip`, `python -m pip3`.
function python() {
wrapSafeChainCommand "python" "$@"
}
# `python3 -m pip`, `python3 -m pip3'.
function python3() {
wrapSafeChainCommand "python3" "$@"
}
function printSafeChainWarning() {
# \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
printf "\033[43;30mWarning:\033[0m safe-chain is not available to protect you from installing malware. %s will run without it.\n" "$1"
# \033[36m is used to set the text color to cyan
printf "Install safe-chain by using \033[36mnpm install -g @aikidosec/safe-chain\033[0m.\n"
}
function wrapSafeChainCommand() {
local original_cmd="$1"
if command -v safe-chain > /dev/null 2>&1; then
# If the aikido command is available, just run it with the provided arguments
safe-chain "$@"
else
# If the aikido command is not available, print a warning and run the original command
printSafeChainWarning "$original_cmd"
command "$original_cmd" "$@"
fi
}

View file

@ -1,119 +0,0 @@
# Use cross-platform path separator (: on Unix, ; on Windows)
$pathSeparator = if ($IsWindows) { ';' } else { ':' }
$safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin'
$env:PATH = "$env:PATH$pathSeparator$safeChainBin"
function npx {
Invoke-WrappedCommand "npx" $args
}
function yarn {
Invoke-WrappedCommand "yarn" $args
}
function pnpm {
Invoke-WrappedCommand "pnpm" $args
}
function pnpx {
Invoke-WrappedCommand "pnpx" $args
}
function bun {
Invoke-WrappedCommand "bun" $args
}
function bunx {
Invoke-WrappedCommand "bunx" $args
}
function npm {
# If args is just -v or --version and nothing else, just run the npm version command
# This is because nvm uses this to check the version of npm
if (($args.Length -eq 1) -and (($args[0] -eq "-v") -or ($args[0] -eq "--version"))) {
Invoke-RealCommand "npm" $args
return
}
Invoke-WrappedCommand "npm" $args
}
function pip {
Invoke-WrappedCommand "pip" $args
}
function pip3 {
Invoke-WrappedCommand "pip3" $args
}
function uv {
Invoke-WrappedCommand "uv" $args
}
function poetry {
Invoke-WrappedCommand "poetry" $args
}
# `python -m pip`, `python -m pip3`.
function python {
Invoke-WrappedCommand 'python' $args
}
# `python3 -m pip`, `python3 -m pip3'.
function python3 {
Invoke-WrappedCommand 'python3' $args
}
function Write-SafeChainWarning {
param([string]$Command)
# PowerShell equivalent of ANSI color codes: yellow background, black text for "Warning:"
Write-Host "Warning:" -BackgroundColor Yellow -ForegroundColor Black -NoNewline
Write-Host " safe-chain is not available to protect you from installing malware. $Command will run without it."
# Cyan text for the install command
Write-Host "Install safe-chain by using " -NoNewline
Write-Host "npm install -g @aikidosec/safe-chain" -ForegroundColor Cyan -NoNewline
Write-Host "."
}
function Test-CommandAvailable {
param([string]$Command)
try {
Get-Command $Command -ErrorAction Stop | Out-Null
return $true
}
catch {
return $false
}
}
function Invoke-RealCommand {
param(
[string]$Command,
[string[]]$Arguments
)
# Find the real executable to avoid calling our wrapped functions
$realCommand = Get-Command -Name $Command -CommandType Application | Select-Object -First 1
if ($realCommand) {
& $realCommand.Source @Arguments
}
}
function Invoke-WrappedCommand {
param(
[string]$OriginalCmd,
[string[]]$Arguments
)
if (Test-CommandAvailable "safe-chain") {
& safe-chain $OriginalCmd @Arguments
}
else {
Write-SafeChainWarning $OriginalCmd
Invoke-RealCommand $OriginalCmd $Arguments
}
}

View file

@ -39,6 +39,36 @@ function npm
wrapSafeChainCommand "npm" $argv
end
function pip
wrapSafeChainCommand "pip" $argv
end
function pip3
wrapSafeChainCommand "pip3" $argv
end
function uv
wrapSafeChainCommand "uv" $argv
end
function poetry
wrapSafeChainCommand "poetry" $argv
end
# `python -m pip`, `python -m pip3`.
function python
wrapSafeChainCommand "python" $argv
end
# `python3 -m pip`, `python3 -m pip3'.
function python3
wrapSafeChainCommand "python3" $argv
end
function pipx
wrapSafeChainCommand "pipx" $argv
end
function printSafeChainWarning
set original_cmd $argv[1]

View file

@ -35,6 +35,36 @@ function npm() {
wrapSafeChainCommand "npm" "$@"
}
function pip() {
wrapSafeChainCommand "pip" "$@"
}
function pip3() {
wrapSafeChainCommand "pip3" "$@"
}
function uv() {
wrapSafeChainCommand "uv" "$@"
}
function poetry() {
wrapSafeChainCommand "poetry" "$@"
}
# `python -m pip`, `python -m pip3`.
function python() {
wrapSafeChainCommand "python" "$@"
}
# `python3 -m pip`, `python3 -m pip3'.
function python3() {
wrapSafeChainCommand "python3" "$@"
}
function pipx() {
wrapSafeChainCommand "pipx" "$@"
}
function printSafeChainWarning() {
# \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

View file

@ -1,5 +1,7 @@
# Use cross-platform path separator (: on Unix, ; on Windows)
$pathSeparator = if ($IsWindows) { ';' } else { ':' }
# $IsWindows is only available in PowerShell Core 6.0+. If it doesn't exist, assume Windows PowerShell
$isWindowsPlatform = if (Test-Path variable:IsWindows) { $IsWindows } else { $true }
$pathSeparator = if ($isWindowsPlatform) { ';' } else { ':' }
$safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin'
$env:PATH = "$env:PATH$pathSeparator$safeChainBin"
@ -38,6 +40,36 @@ function npm {
Invoke-WrappedCommand "npm" $args
}
function pip {
Invoke-WrappedCommand "pip" $args
}
function pip3 {
Invoke-WrappedCommand "pip3" $args
}
function uv {
Invoke-WrappedCommand "uv" $args
}
function poetry {
Invoke-WrappedCommand "poetry" $args
}
# `python -m pip`, `python -m pip3`.
function python {
Invoke-WrappedCommand 'python' $args
}
# `python3 -m pip`, `python3 -m pip3'.
function python3 {
Invoke-WrappedCommand 'python3' $args
}
function pipx {
Invoke-WrappedCommand "pipx" $args
}
function Write-SafeChainWarning {
param([string]$Command)

View file

@ -1,7 +1,8 @@
import chalk from "chalk";
import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.js";
import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
import { knownAikidoTools, getPackageManagerList, getShimsDir, getScriptsDir } from "./helpers.js";
import fs from "fs";
/**
* @returns {Promise<void>}
@ -62,3 +63,44 @@ export async function teardown() {
return;
}
}
/**
* Removes directories created by setup-ci and setup commands
* @returns {Promise<void>}
*/
export async function teardownDirectories() {
const shimsDir = getShimsDir();
const scriptsDir = getScriptsDir();
// Remove CI shims directory
if (fs.existsSync(shimsDir)) {
try {
fs.rmSync(shimsDir, { recursive: true, force: true });
ui.writeInformation(
`${chalk.bold("- CI Shims:")} ${chalk.green("Removed successfully")}`
);
} catch (/** @type {any} */ error) {
ui.writeError(
`${chalk.bold("- CI Shims:")} ${chalk.red(
"Failed to remove"
)}. Error: ${error.message}`
);
}
}
// Remove scripts directory
if (fs.existsSync(scriptsDir)) {
try {
fs.rmSync(scriptsDir, { recursive: true, force: true });
ui.writeInformation(
`${chalk.bold("- Scripts:")} ${chalk.green("Removed successfully")}`
);
} catch (/** @type {any} */ error) {
ui.writeError(
`${chalk.bold("- Scripts:")} ${chalk.red(
"Failed to remove"
)}. Error: ${error.message}`
);
}
}
}

View file

@ -33,12 +33,16 @@ export class DockerTestContainer {
].join(" ");
execSync(
`docker build -t ${imageName} -f ${dockerFile} ${contextPath} ${buildArgs}`,
`docker build --progress=plain -t ${imageName} -f ${dockerFile} ${contextPath} ${buildArgs}`,
{
stdio: "ignore",
stdio: "pipe",
maxBuffer: 10 * 1024 * 1024, // Default is 1MB, increase to 10MB to account for large build logs
}
);
} catch (error) {
// Only print the build logs if the build fails
if (error.stdout) console.log(error.stdout.toString());
if (error.stderr) console.error(error.stderr.toString());
throw new Error(`Failed to build Docker image: ${error.message}`);
}
}

View file

@ -41,7 +41,7 @@ RUN apt-get install -y fish && \
touch /root/.config/fish/config.fish
# Install Volta and Node.js
RUN curl https://get.volta.sh | bash
RUN curl -fsSL https://get.volta.sh | bash
RUN volta install node@${NODE_VERSION}
RUN volta install npm@${NPM_VERSION}
RUN volta install yarn@${YARN_VERSION}

View file

@ -0,0 +1,347 @@
import { describe, it, before, beforeEach, afterEach } from "node:test";
import { DockerTestContainer } from "./DockerTestContainer.js";
import assert from "node:assert";
describe("E2E: NODE_EXTRA_CA_CERTS merging", () => {
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");
});
afterEach(async () => {
// Stop and clean up the container after each test
if (container) {
await container.stop();
container = null;
}
});
it(`npm install works without NODE_EXTRA_CA_CERTS set`, async () => {
const shell = await container.openShell("zsh");
// Ensure NODE_EXTRA_CA_CERTS is not set
await shell.runCommand("unset NODE_EXTRA_CA_CERTS");
const result = await shell.runCommand("npm install axios");
assert.ok(
result.output.includes("added") || result.output.includes("up to date"),
`npm install failed without NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
);
});
it(`npm install works with valid NODE_EXTRA_CA_CERTS set`, async () => {
const shell = await container.openShell("zsh");
// Create a temporary valid certificate (using the system's Mozilla CA bundle)
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/valid-certs.pem");
// Verify the cert file was created
const { output: checkOutput } = await shell.runCommand("test -f /tmp/valid-certs.pem && echo exists");
assert.ok(
checkOutput.includes("exists"),
`Certificate file was not created at /tmp/valid-certs.pem`
);
// Set NODE_EXTRA_CA_CERTS and run npm install
const result = await shell.runCommand(
"NODE_EXTRA_CA_CERTS=/tmp/valid-certs.pem npm install axios"
);
assert.ok(
result.output.includes("added") || result.output.includes("up to date"),
`npm install failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
);
});
it(`npm install works with non-existent NODE_EXTRA_CA_CERTS path`, async () => {
const shell = await container.openShell("zsh");
// Set NODE_EXTRA_CA_CERTS to a non-existent path
const result = await shell.runCommand(
'export NODE_EXTRA_CA_CERTS="/tmp/nonexistent-certs.pem" && npm install axios'
);
// Should still succeed - safe-chain should gracefully handle missing user certs
assert.ok(
result.output.includes("added") || result.output.includes("up to date"),
`npm install failed with non-existent NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
);
// Should show a warning
assert.ok(
result.output.includes("Safe-chain") || result.output.includes("Could not read"),
`Expected safe-chain warning about missing certs. Output was:\n${result.output}`
);
});
it(`npm install works with invalid (non-PEM) NODE_EXTRA_CA_CERTS`, async () => {
const shell = await container.openShell("zsh");
// Create an invalid certificate file (not valid PEM)
await shell.runCommand(
'echo "This is not a valid PEM certificate" > /tmp/invalid-certs.pem'
);
// Set NODE_EXTRA_CA_CERTS to invalid cert
const result = await shell.runCommand(
'export NODE_EXTRA_CA_CERTS="/tmp/invalid-certs.pem" && npm install axios'
);
// Should still succeed - safe-chain should skip invalid user certs
assert.ok(
result.output.includes("added") || result.output.includes("up to date"),
`npm install failed with invalid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
);
// Should show a warning about invalid cert
assert.ok(
result.output.includes("Safe-chain") || result.output.includes("Could not read"),
`Expected safe-chain warning about invalid certs. Output was:\n${result.output}`
);
});
it(`npm install handles NODE_EXTRA_CA_CERTS with path traversal attempt`, async () => {
const shell = await container.openShell("zsh");
// Try to set NODE_EXTRA_CA_CERTS with path traversal
const result = await shell.runCommand(
'export NODE_EXTRA_CA_CERTS="/tmp/../../../etc/passwd" && npm install axios'
);
// Should still succeed - safe-chain should reject path traversal
assert.ok(
result.output.includes("added") || result.output.includes("up to date"),
`npm install failed with path traversal NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
);
});
it(`npm install handles empty NODE_EXTRA_CA_CERTS`, async () => {
const shell = await container.openShell("zsh");
// Create an empty certificate file
await shell.runCommand("touch /tmp/empty-certs.pem");
const result = await shell.runCommand(
'export NODE_EXTRA_CA_CERTS="/tmp/empty-certs.pem" && npm install axios'
);
// Should still succeed - empty file should be ignored gracefully
assert.ok(
result.output.includes("added") || result.output.includes("up to date"),
`npm install failed with empty NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
);
});
it(`npm install handles NODE_EXTRA_CA_CERTS pointing to a directory`, async () => {
const shell = await container.openShell("zsh");
// Create a directory instead of a file
await shell.runCommand("mkdir -p /tmp/cert-dir");
const result = await shell.runCommand(
'export NODE_EXTRA_CA_CERTS="/tmp/cert-dir" && npm install axios'
);
// Should still succeed - directory should be treated as invalid cert file
assert.ok(
result.output.includes("added") || result.output.includes("up to date"),
`npm install failed when NODE_EXTRA_CA_CERTS points to directory. Output was:\n${result.output}`
);
});
it(`npm install handles relative NODE_EXTRA_CA_CERTS path`, async () => {
const shell = await container.openShell("zsh");
// Create a cert file and try to reference it with relative path
await shell.runCommand(
"mkdir -p /tmp/cert-test && cp /etc/ssl/certs/ca-certificates.crt /tmp/cert-test/certs.pem"
);
const result = await shell.runCommand(
'cd /tmp/cert-test && export NODE_EXTRA_CA_CERTS="./certs.pem" && npm install axios'
);
// Should still succeed - relative paths should be resolved properly
assert.ok(
result.output.includes("added") || result.output.includes("up to date"),
`npm install failed with relative NODE_EXTRA_CA_CERTS path. Output was:\n${result.output}`
);
});
it(`npm install handles absolute NODE_EXTRA_CA_CERTS path`, async () => {
const shell = await container.openShell("zsh");
// Create cert file with absolute path
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/absolute-certs.pem");
const result = await shell.runCommand(
"NODE_EXTRA_CA_CERTS=/tmp/absolute-certs.pem npm install axios"
);
assert.ok(
result.output.includes("added") || result.output.includes("up to date"),
`npm install failed with absolute NODE_EXTRA_CA_CERTS path. Output was:\n${result.output}`
);
});
it(`npm install with multiple packages still respects merged certificates`, async () => {
const shell = await container.openShell("zsh");
// Create valid cert
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/merge-certs.pem");
const result = await shell.runCommand(
"NODE_EXTRA_CA_CERTS=/tmp/merge-certs.pem npm install axios lodash"
);
assert.ok(
result.output.includes("added") || result.output.includes("up to date"),
`npm install with multiple packages failed. Output was:\n${result.output}`
);
});
it(`npm install correctly blocks malware even with merged certificates`, async () => {
const shell = await container.openShell("zsh");
// Create valid cert
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/secure-merge-certs.pem");
const result = await shell.runCommand(
"NODE_EXTRA_CA_CERTS=/tmp/secure-merge-certs.pem npm install safe-chain-test"
);
// Should block the malware package
assert.ok(
result.output.includes("Malicious") || result.output.includes("blocked"),
`Malware package should be blocked even with merged certificates. Output was:\n${result.output}`
);
});
it(`pip install works without NODE_EXTRA_CA_CERTS set`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("safe-chain setup");
await shell.runCommand("unset NODE_EXTRA_CA_CERTS");
const result = await shell.runCommand(
"pip3 install --break-system-packages requests"
);
assert.ok(
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
`pip3 install failed without NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
);
});
it(`pip install works with valid NODE_EXTRA_CA_CERTS set`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("safe-chain setup");
// Create a temporary valid certificate
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/pip-valid-certs.pem");
const result = await shell.runCommand(
"NODE_EXTRA_CA_CERTS=/tmp/pip-valid-certs.pem pip3 install --break-system-packages requests"
);
assert.ok(
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
`pip3 install failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
);
});
it(`pip install handles non-existent NODE_EXTRA_CA_CERTS gracefully`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("safe-chain setup");
const result = await shell.runCommand(
'export NODE_EXTRA_CA_CERTS="/tmp/nonexistent-pip-certs.pem" && pip3 install --break-system-packages requests'
);
// Should still work - gracefully handle missing user certs
assert.ok(
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
`pip3 install failed with non-existent NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
);
});
it(`pip install handles invalid NODE_EXTRA_CA_CERTS gracefully`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("safe-chain setup");
// Create invalid cert
await shell.runCommand(
'echo "invalid certificate content" > /tmp/pip-invalid-certs.pem'
);
const result = await shell.runCommand(
'export NODE_EXTRA_CA_CERTS="/tmp/pip-invalid-certs.pem" && pip3 install --break-system-packages requests'
);
// Should still work - skip invalid user certs
assert.ok(
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
`pip3 install failed with invalid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
);
});
it(`yarn install works with valid NODE_EXTRA_CA_CERTS set`, async () => {
const shell = await container.openShell("zsh");
// Create valid cert
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/yarn-certs.pem");
const result = await shell.runCommand(
"NODE_EXTRA_CA_CERTS=/tmp/yarn-certs.pem yarn add axios"
);
assert.ok(
!result.output.toLowerCase().includes("error") || result.output.includes("Done"),
`yarn add failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
);
});
it(`pnpm install works with valid NODE_EXTRA_CA_CERTS set`, async () => {
const shell = await container.openShell("zsh");
// Create valid cert
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/pnpm-certs.pem");
const result = await shell.runCommand(
"NODE_EXTRA_CA_CERTS=/tmp/pnpm-certs.pem pnpm add axios"
);
assert.ok(
!result.output.toLowerCase().includes("error") || result.output.includes("Progress"),
`pnpm add failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
);
});
it(`bun install works with valid NODE_EXTRA_CA_CERTS set`, async () => {
const shell = await container.openShell("bash");
// Create valid cert and run bun in the same command to ensure file exists
const result = await shell.runCommand(
"cp /etc/ssl/certs/ca-certificates.crt /tmp/bun-certs.pem && NODE_EXTRA_CA_CERTS=/tmp/bun-certs.pem bun i axios"
);
assert.ok(
!result.output.toLowerCase().includes("error") || result.output.includes("installed"),
`bun i failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
);
});
});

View file

@ -0,0 +1,45 @@
import { describe, it, before, beforeEach, afterEach } from "node:test";
import { DockerTestContainer } from "./DockerTestContainer.js";
import assert from "node:assert";
describe("E2E: deprecated --include-python handling", () => {
let container;
before(async () => {
DockerTestContainer.buildImage();
});
beforeEach(async () => {
container = new DockerTestContainer();
await container.start();
});
afterEach(async () => {
if (container) {
await container.stop();
container = null;
}
});
for (let shell of ["bash", "zsh"]) {
it(`safe-chain setup warns and continues for ${shell}`, async () => {
const sh = await container.openShell(shell);
const result = await sh.runCommand("safe-chain setup --include-python");
assert.ok(
result.output.toLowerCase().includes("deprecated and ignored"),
`Expected warning about deprecated --include-python. Output was:\n${result.output}`
);
});
it(`safe-chain setup-ci warns and continues for ${shell}`, async () => {
const sh = await container.openShell(shell);
const result = await sh.runCommand("safe-chain setup-ci --include-python");
assert.ok(
result.output.toLowerCase().includes("deprecated and ignored"),
`Expected warning about deprecated --include-python. Output was:\n${result.output}`
);
});
}
});

View file

@ -86,7 +86,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => {
// Setup safe-chain CI shims
const installationShell = await container.openShell(shell);
await installationShell.runCommand(
"safe-chain setup-ci --include-python"
"safe-chain setup-ci"
);
// Add $HOME/.safe-chain/shims to PATH for subsequent shells
@ -115,7 +115,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => {
it(`setup-ci routes python -m pip through safe-chain for ${shell}`, async () => {
const installationShell = await container.openShell(shell);
await installationShell.runCommand(
"safe-chain setup-ci --include-python"
"safe-chain setup-ci"
);
await installationShell.runCommand(
"echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc"
@ -138,7 +138,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => {
it(`setup-ci routes python3 -m pip through safe-chain for ${shell}`, async () => {
const installationShell = await container.openShell(shell);
await installationShell.runCommand(
"safe-chain setup-ci --include-python"
"safe-chain setup-ci"
);
await installationShell.runCommand(
"echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc"
@ -161,7 +161,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => {
it(`setup-ci routes pip through safe-chain for ${shell}`, async () => {
const installationShell = await container.openShell(shell);
await installationShell.runCommand(
"safe-chain setup-ci --include-python"
"safe-chain setup-ci"
);
await installationShell.runCommand(
"echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc"
@ -184,7 +184,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => {
it(`setup-ci routes pip3 through safe-chain for ${shell}`, async () => {
const installationShell = await container.openShell(shell);
await installationShell.runCommand(
"safe-chain setup-ci --include-python"
"safe-chain setup-ci"
);
await installationShell.runCommand(
"echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc"

View file

@ -15,7 +15,7 @@ describe("E2E: pip coverage", () => {
await container.start();
const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup --include-python");
await installationShell.runCommand("safe-chain setup");
// Clear pip cache before each test to ensure fresh downloads through proxy
await installationShell.runCommand("pip3 cache purge");

200
test/e2e/pipx.e2e.spec.js Normal file
View file

@ -0,0 +1,200 @@
import { describe, it, before, beforeEach, afterEach } from "node:test";
import { DockerTestContainer } from "./DockerTestContainer.js";
import assert from "node:assert";
describe("E2E: pipx coverage", () => {
let container;
before(async () => {
DockerTestContainer.buildImage();
});
beforeEach(async () => {
container = new DockerTestContainer();
await container.start();
const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup");
});
afterEach(async () => {
if (container) {
await container.stop();
container = null;
}
});
it(`successfully installs known safe packages with pipx install`, async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"pipx install ruff --safe-chain-logging=verbose"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("installed successfully"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`safe-chain blocks installation of malicious Python packages via pipx`, async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"pipx install 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(`pipx upgrade upgrades installed packages`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("pipx install ruff==0.1.0");
const result = await shell.runCommand(
"pipx upgrade ruff"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Upgraded") || result.output.includes("upgraded"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`pipx run downloads and executes a safe tool`, async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"pipx run ruff --version"
);
assert.ok(
result.output.includes("no malware found.") || /ruff/i.test(result.output),
`Expected safe run to succeed. Output was:\n${result.output}`
);
});
it(`pipx run blocks malicious tool download`, async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"pipx run safe-chain-pi-test --version"
);
assert.ok(
result.output.includes("blocked by safe-chain"),
`Expected malicious run 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(`pipx runpip installs safe dependency inside an app venv`, async () => {
const shell = await container.openShell("zsh");
// Prepare an app environment
await shell.runCommand("pipx install ruff");
const result = await shell.runCommand(
"pipx runpip ruff install requests==2.32.3"
);
assert.ok(
result.output.includes("no malware found.") || /Successfully installed/i.test(result.output) || /requests/i.test(result.output),
`Expected safe dependency install inside app venv. Output was:\n${result.output}`
);
});
it(`pipx runpip blocks malicious dependency install`, async () => {
const shell = await container.openShell("zsh");
// Prepare an app environment
await shell.runCommand("pipx install ruff");
const result = await shell.runCommand(
"pipx runpip ruff install safe-chain-pi-test"
);
assert.ok(
result.output.includes("blocked by safe-chain"),
`Expected malicious dependency 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(`pipx list shows installed packages`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("pipx install ruff");
const result = await shell.runCommand(
"pipx list"
);
assert.ok(
result.output.includes("ruff"),
`Expected ruff in list output. Output was:\n${result.output}`
);
});
it(`pipx uninstall removes packages`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("pipx install ruff --safe-chain-logging=verbose");
await shell.runCommand("pipx uninstall ruff --safe-chain-logging=verbose");
const result = await shell.runCommand(
"pipx list"
);
assert.ok(
!result.output.includes("ruff"),
`Expected ruff to be removed from list. Output was:\n${result.output}`
);
});
it('pipx inject installs safe packages into existing venvs', async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("pipx install ruff --safe-chain-logging=verbose");
const result = await shell.runCommand(
"pipx inject ruff requests==2.32.3 --safe-chain-logging=verbose"
);
assert.ok(
result.output.includes("no malware found.") || /Successfully installed/i.test(result.output) || /requests/i.test(result.output),
`Expected safe package to be injected. Output was:\n${result.output}`
);
});
it('pipx inject blocks malicious packages from being installed into existing venvs', async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("pipx install ruff --safe-chain-logging=verbose");
const result = await shell.runCommand(
"pipx inject ruff safe-chain-pi-test --safe-chain-logging=verbose"
);
assert.ok(
result.output.includes("blocked by safe-chain"),
`Expected malicious package 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}`
);
});
});

View file

@ -15,7 +15,7 @@ describe("E2E: poetry coverage", () => {
await container.start();
const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup --include-python");
await installationShell.runCommand("safe-chain setup");
// Clear poetry cache
await installationShell.runCommand("command poetry cache clear pypi --all -n");

View file

@ -0,0 +1,96 @@
import { describe, it, before, beforeEach, afterEach } from "node:test";
import { DockerTestContainer } from "./DockerTestContainer.js";
import assert from "node:assert";
describe("E2E: safe-chain teardown command", () => {
let container;
before(async () => {
DockerTestContainer.buildImage();
});
beforeEach(async () => {
container = new DockerTestContainer();
await container.start();
});
afterEach(async () => {
if (container) {
await container.stop();
container = null;
}
});
it("safe-chain teardown removes shims directory created by setup-ci", async () => {
const shell = await container.openShell("bash");
// Run setup-ci
await shell.runCommand("safe-chain setup-ci");
// Verify shims directory exists
const checkShimsExist = await shell.runCommand("test -d ~/.safe-chain/shims && echo 'exists' || echo 'missing'");
assert.ok(checkShimsExist.output.includes("exists"), "Shims directory should exist after setup-ci");
// Run teardown
await shell.runCommand("safe-chain teardown");
// Verify shims directory is gone
const checkShimsGone = await shell.runCommand("test -d ~/.safe-chain/shims && echo 'exists' || echo 'missing'");
assert.ok(checkShimsGone.output.includes("missing"), "Shims directory should be removed after teardown");
});
it("safe-chain teardown removes scripts directory created by setup", async () => {
const shell = await container.openShell("bash");
// Run setup
await shell.runCommand("safe-chain setup");
// Verify scripts directory exists
const checkScriptsExist = await shell.runCommand("test -d ~/.safe-chain/scripts && echo 'exists' || echo 'missing'");
assert.ok(checkScriptsExist.output.includes("exists"), "Scripts directory should exist after setup");
// Run teardown
await shell.runCommand("safe-chain teardown");
// Verify scripts directory is gone
const checkScriptsGone = await shell.runCommand("test -d ~/.safe-chain/scripts && echo 'exists' || echo 'missing'");
assert.ok(checkScriptsGone.output.includes("missing"), "Scripts directory should be removed after teardown");
});
it("safe-chain teardown removes shims directory created by setup-ci", async () => {
const shell = await container.openShell("bash");
// Run setup-ci
await shell.runCommand("safe-chain setup-ci");
// Verify shims directory exists
const checkShimsExist = await shell.runCommand("test -d ~/.safe-chain/shims && echo 'exists' || echo 'missing'");
assert.ok(checkShimsExist.output.includes("exists"), "Shims directory should exist after setup-ci");
// Verify Python shims were created
const checkPythonShims = await shell.runCommand("test -f ~/.safe-chain/shims/pip && echo 'exists' || echo 'missing'");
assert.ok(checkPythonShims.output.includes("exists"), "Python shims should exist after setup-ci");
// Run teardown
await shell.runCommand("safe-chain teardown");
// Verify shims directory is gone
const checkShimsGone = await shell.runCommand("test -d ~/.safe-chain/shims && echo 'exists' || echo 'missing'");
assert.ok(checkShimsGone.output.includes("missing"), "Shims directory should be removed after teardown");
});
it("safe-chain teardown removes scripts directory created by setup", async () => {
const shell = await container.openShell("bash");
// Run setup
await shell.runCommand("safe-chain setup");
// Verify scripts directory exists
const checkScriptsExist = await shell.runCommand("test -d ~/.safe-chain/scripts && echo 'exists' || echo 'missing'");
assert.ok(checkScriptsExist.output.includes("exists"), "Scripts directory should exist after setup");
// Run teardown
await shell.runCommand("safe-chain teardown");
// Verify scripts directory is gone
const checkScriptsGone = await shell.runCommand("test -d ~/.safe-chain/scripts && echo 'exists' || echo 'missing'");
assert.ok(checkScriptsGone.output.includes("missing"), "Scripts directory should be removed after teardown");
});
});

View file

@ -15,7 +15,7 @@ describe("E2E: uv coverage", () => {
await container.start();
const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup --include-python");
await installationShell.runCommand("safe-chain setup");
// Clear uv cache
await installationShell.runCommand("uv cache clean");