Merge remote-tracking branch 'aikido/main' into feat/pdm-support

This commit is contained in:
Chris Ingram 2026-05-14 09:51:31 +01:00
commit 8453012f7b
No known key found for this signature in database
44 changed files with 1311 additions and 202 deletions

View file

@ -60,12 +60,43 @@ jobs:
mv binaries/safe-chain-win-x64/safe-chain.exe release-artifacts/safe-chain-win-x64.exe 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 mv binaries/safe-chain-win-arm64/safe-chain.exe release-artifacts/safe-chain-win-arm64.exe
- name: Move install scripts and hard-code version - name: Move install scripts and hard-code version and checksums
env: env:
VERSION: ${{ needs.set-version.outputs.version }} VERSION: ${{ needs.set-version.outputs.version }}
run: | run: |
sed "s/\$(fetch_latest_version)/${VERSION}/" install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh SHA_MACOS_X64=$(sha256sum release-artifacts/safe-chain-macos-x64 | awk '{print $1}')
sed "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1 SHA_MACOS_ARM64=$(sha256sum release-artifacts/safe-chain-macos-arm64 | awk '{print $1}')
SHA_LINUX_X64=$(sha256sum release-artifacts/safe-chain-linux-x64 | awk '{print $1}')
SHA_LINUX_ARM64=$(sha256sum release-artifacts/safe-chain-linux-arm64 | awk '{print $1}')
SHA_LINUXSTATIC_X64=$(sha256sum release-artifacts/safe-chain-linuxstatic-x64 | awk '{print $1}')
SHA_LINUXSTATIC_ARM64=$(sha256sum release-artifacts/safe-chain-linuxstatic-arm64 | awk '{print $1}')
SHA_WIN_X64=$(sha256sum release-artifacts/safe-chain-win-x64.exe | awk '{print $1}')
SHA_WIN_ARM64=$(sha256sum release-artifacts/safe-chain-win-arm64.exe | awk '{print $1}')
sed \
-e "s/\$(fetch_latest_version)/${VERSION}/" \
-e "s|^SHA256_MACOS_X64=\"\"|SHA256_MACOS_X64=\"${SHA_MACOS_X64}\"|" \
-e "s|^SHA256_MACOS_ARM64=\"\"|SHA256_MACOS_ARM64=\"${SHA_MACOS_ARM64}\"|" \
-e "s|^SHA256_LINUX_X64=\"\"|SHA256_LINUX_X64=\"${SHA_LINUX_X64}\"|" \
-e "s|^SHA256_LINUX_ARM64=\"\"|SHA256_LINUX_ARM64=\"${SHA_LINUX_ARM64}\"|" \
-e "s|^SHA256_LINUXSTATIC_X64=\"\"|SHA256_LINUXSTATIC_X64=\"${SHA_LINUXSTATIC_X64}\"|" \
-e "s|^SHA256_LINUXSTATIC_ARM64=\"\"|SHA256_LINUXSTATIC_ARM64=\"${SHA_LINUXSTATIC_ARM64}\"|" \
-e "s|^SHA256_WIN_X64=\"\"|SHA256_WIN_X64=\"${SHA_WIN_X64}\"|" \
-e "s|^SHA256_WIN_ARM64=\"\"|SHA256_WIN_ARM64=\"${SHA_WIN_ARM64}\"|" \
install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh
sed \
-e "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" \
-e "s|^\$SHA256_MACOS_X64 = \"\"|\$SHA256_MACOS_X64 = \"${SHA_MACOS_X64}\"|" \
-e "s|^\$SHA256_MACOS_ARM64 = \"\"|\$SHA256_MACOS_ARM64 = \"${SHA_MACOS_ARM64}\"|" \
-e "s|^\$SHA256_LINUX_X64 = \"\"|\$SHA256_LINUX_X64 = \"${SHA_LINUX_X64}\"|" \
-e "s|^\$SHA256_LINUX_ARM64 = \"\"|\$SHA256_LINUX_ARM64 = \"${SHA_LINUX_ARM64}\"|" \
-e "s|^\$SHA256_LINUXSTATIC_X64 = \"\"|\$SHA256_LINUXSTATIC_X64 = \"${SHA_LINUXSTATIC_X64}\"|" \
-e "s|^\$SHA256_LINUXSTATIC_ARM64 = \"\"|\$SHA256_LINUXSTATIC_ARM64 = \"${SHA_LINUXSTATIC_ARM64}\"|" \
-e "s|^\$SHA256_WIN_X64 = \"\"|\$SHA256_WIN_X64 = \"${SHA_WIN_X64}\"|" \
-e "s|^\$SHA256_WIN_ARM64 = \"\"|\$SHA256_WIN_ARM64 = \"${SHA_WIN_ARM64}\"|" \
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.sh release-artifacts/uninstall-safe-chain.sh
cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1 cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1
cp install-scripts/install-endpoint-mac.sh release-artifacts/install-endpoint-mac.sh cp install-scripts/install-endpoint-mac.sh release-artifacts/install-endpoint-mac.sh

View file

@ -77,7 +77,7 @@ jobs:
- node_version: "20" - node_version: "20"
npm_version: "9.0.0" npm_version: "9.0.0"
yarn_version: "latest" yarn_version: "latest"
pnpm_version: "latest" pnpm_version: "10.0.0"
# Version pinning scenario # Version pinning scenario
- node_version: "22" - node_version: "22"
npm_version: "10.2.0" npm_version: "10.2.0"
@ -87,7 +87,7 @@ jobs:
- node_version: "18" - node_version: "18"
npm_version: "latest" npm_version: "latest"
yarn_version: "latest" yarn_version: "latest"
pnpm_version: "latest" pnpm_version: "10.0.0"
# Future compatibility (becomes LTS October 2025) # Future compatibility (becomes LTS October 2025)
- node_version: "24" - node_version: "24"
npm_version: "latest" npm_version: "latest"

View file

@ -10,6 +10,14 @@
- ✅ **Blocks packages newer than 48 hours** without breaking your build - ✅ **Blocks packages newer than 48 hours** without breaking your build
- ✅ **Tokenless, free, no build data shared** - ✅ **Tokenless, free, no build data shared**
## Need protection beyond npm & PyPI?
[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection?utm_source=github.com&utm_medium=referral&utm_campaign=safechain) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more.
Get centralized policy management, request-and-approval workflows, and visibility across every developer workstation in your org. Powered by the same Aikido Intel feed. Deploy it manually or manage it through your MDM tool (Jamf, Fleet, or Iru).
---
Aikido Safe Chain supports the following package managers: Aikido Safe Chain supports the following package managers:
- 📦 **npm** - 📦 **npm**
@ -17,6 +25,8 @@ Aikido Safe Chain supports the following package managers:
- 📦 **yarn** - 📦 **yarn**
- 📦 **pnpm** - 📦 **pnpm**
- 📦 **pnpx** - 📦 **pnpx**
- 📦 **rush**
- 📦 **rushx**
- 📦 **bun** - 📦 **bun**
- 📦 **bunx** - 📦 **bunx**
- 📦 **pip** - 📦 **pip**
@ -31,12 +41,6 @@ Aikido Safe Chain supports the following package managers:
![Aikido Safe Chain demo](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/safe-package-manager-demo.gif) ![Aikido Safe Chain demo](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/safe-package-manager-demo.gif)
# Using Safe Chain across a team?
[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more.
Get centralized policy management, request-and-approval workflows, and visibility across every developer workstation in your org. Powered by the same Aikido Intel feed. Deploy it manually or manage it through your MDM tool (Jamf, Fleet, or Iru).
## Installation ## Installation
Installing the Aikido Safe Chain is easy with our one-line installer. Installing the Aikido Safe Chain is easy with our one-line installer.
@ -74,7 +78,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
### Verify the installation ### Verify the installation
1. **❗Restart your terminal** to start using the Aikido Safe Chain. 1. **❗Restart your terminal** to start using the Aikido Safe Chain.
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv, uvx, pipx and pdm are loaded correctly. If you do not restart your terminal, the aliases will not be available. - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, poetry, uv, uvx, pipx and pdm are loaded correctly. If you do not restart your terminal, the aliases will not be available.
2. **Verify the installation** by running the verification command: 2. **Verify the installation** by running the verification command:
@ -105,7 +109,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
- The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware.
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx` and `pdm` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx` and `pdm` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
You can check the installed version by running: You can check the installed version by running:
@ -117,7 +121,7 @@ safe-chain --version
### Malware Blocking ### Malware Blocking
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, uv, uvx, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, uv, uvx, poetry, pipx or pdm commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
### Minimum package age ### Minimum package age
@ -136,7 +140,7 @@ By default, the minimum package age is 48 hours. This provides an additional sec
### Shell Integration ### Shell Integration
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx, pdm). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
- ✅ **Bash** - ✅ **Bash**
- ✅ **Zsh** - ✅ **Zsh**
@ -289,6 +293,12 @@ You can set custom registries through environment variable or config file. Both
} }
``` ```
## PYPI Configuration File
If you rely on a `pip.conf` file for pip configuration you must point pip at it explicitly via the `PIP_CONFIG_FILE` environment variable so Safe Chain can merge it.
Safe Chain runs pip behind its MITM proxy and writes a temporary pip configuration file to inject its certificate and proxy settings. When `PIP_CONFIG_FILE` is set, Safe Chain merges its settings into a copy of your file (your original file is never modified) so your `index-url`, credentials, and other options are preserved. When `PIP_CONFIG_FILE` is not set, pip's user-level config (e.g. `~/.config/pip/pip.conf`) might be overridden by Safe Chain's temporary file and your settings will not be picked up.
## Malware List Base URL ## Malware List Base URL
Configure Safe Chain to fetch malware databases and new packages lists from a custom mirror URL. This allows you to host your own copy of the Aikido malware database. Configure Safe Chain to fetch malware databases and new packages lists from a custom mirror URL. This allows you to host your own copy of the Aikido malware database.
@ -470,7 +480,7 @@ steps:
name: Install name: Install
script: script:
- curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- export PATH=~/.safe-chain/shims:$PATH - export PATH=~/.safe-chain/shims:~/.safe-chain/bin:$PATH
- npm ci - npm ci
``` ```
@ -541,4 +551,16 @@ npm-ci:
# Troubleshooting # Troubleshooting
Having issues? See the [Troubleshooting Guide](https://help.aikido.dev/code-scanning/aikido-malware-scanning/safe-chain-troubleshooting) for help with common problems. Having issues? See the [Troubleshooting Guide](./docs/troubleshooting) for help with common problems.
# Report Issues
If you encounter problems:
1. Visit [GitHub Issues](https://github.com/AikidoSec/safe-chain/issues)
2. Include:
* Operating system and version
* Shell type and version
* `safe-chain --version` output
* Output from verification commands
* Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument)

View file

@ -2,7 +2,7 @@
## Overview ## Overview
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `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. The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `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 ## Supported Shells
@ -28,7 +28,7 @@ This command:
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`) - Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
- Detects all supported shells on your system - 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`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` - Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx`
- Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name - 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. ❗ 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: 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 - 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`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist - Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-rush`, `aikido-rushx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist
- Check that these commands are in your system's PATH - Check that these commands are in your system's PATH
### Manual Verification ### Manual Verification
@ -121,7 +121,7 @@ npm() {
} }
``` ```
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `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: To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions:

View file

@ -4,49 +4,38 @@ This guide helps you diagnose and resolve common issues with Aikido Safe Chain.
## Verification & Diagnostics ## Verification & Diagnostics
### Check Installation **Check Installation**
```bash ```bash
# Check version # Check version
safe-chain --version safe-chain --version
``` ```
### Verify Shell Integration **Verify Shell Integration**
Run the verification command for your package manager: Run the verification command for your package manager:
```bash ```bash
npm safe-chain-verify npm safe-chain-verify
pnpm safe-chain-verify pnpm safe-chain-verify
pip safe-chain-verify
uv safe-chain-verify
# Any other supported package manager: {packagemanager} safe-chain-verify
``` ```
```
Expected output: `OK: Safe-chain works!` Expected output: `OK: Safe-chain works!`
```
### Test Malware Blocking **Test Malware Blocking**
Verify that malware detection is working: Verify that malware detection is working:
**For JavaScript/Node.js:**
```bash
npm install safe-chain-test
``` ```
npm install safe-chain-test
**For Python:**
```bash
pip3 install safe-chain-pi-test
``` ```
These test packages are flagged as malware and should be blocked by Safe Chain. These test packages are flagged as malware and should be blocked by Safe Chain.
**If the test package installs successfully instead of being blocked**, see [Malware Not Being Blocked](#malware-not-being-blocked) below. **If the test package installs successfully instead of being blocked**, see Malware Not Being Blocked below.
### Logging Options ## Logging Options
Use logging flags or environment variables to get more information: Use logging flags or environment variables to get more information:
@ -74,9 +63,9 @@ Safe-chain blocks malicious packages by intercepting network requests to package
When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy. When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy.
**Resolution Steps:** **Resolution Steps**
1. **Clear your package manager's cache:** 1) Clear your package manager's cache
```bash ```bash
# For npm # For npm
@ -95,16 +84,14 @@ When a package is already cached locally, the package manager skips downloading
bun pm cache rm bun pm cache rm
``` ```
> **⚠️ Warning:** Cache clearing is safe but will remove all cached packages. Subsequent installations will need to re-download packages. In CI/CD environments or monorepos, this may affect build times. 2) Clean local installation artifacts:
2. **Clean local installation artifacts:**
```bash ```bash
# Remove node_modules if you want a completely fresh install # Remove node_modules if you want a completely fresh install
rm -rf node_modules rm -rf node_modules
``` ```
3. **Re-test malware blocking:** 3) Re-test malware blocking:
```bash ```bash
npm install safe-chain-test # Should be blocked npm install safe-chain-test # Should be blocked
@ -128,10 +115,10 @@ Should show: `npm is a function`
Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`: Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`:
- Bash: `~/.bashrc` * Bash: `~/.bashrc`
- Zsh: `~/.zshrc` * Zsh: `~/.zshrc`
- Fish: `~/.config/fish/config.fish` * Fish: `~/.config/fish/config.fish`
- PowerShell: `$PROFILE` * PowerShell: `$PROFILE`
### "Command Not Found: safe-chain" ### "Command Not Found: safe-chain"
@ -162,9 +149,9 @@ FullyQualifiedErrorId : UnauthorizedAccess
**Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile. **Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile.
**Resolution:** **Resolution**
1. **Set the execution policy to allow local scripts:** 1) Set the execution policy to allow local scripts
Open PowerShell as Administrator and run: Open PowerShell as Administrator and run:
@ -173,26 +160,28 @@ FullyQualifiedErrorId : UnauthorizedAccess
``` ```
This allows: This allows:
- Local scripts (like safe-chain's) to run without signing
- Downloaded scripts to run only if signed by a trusted publisher
2. **Restart PowerShell** and verify the error is resolved. * Local scripts (like safe-chain's) to run without signing
* Downloaded scripts to run only if signed by a trusted publisher
> **Note:** `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability. 2) Restart PowerShell and verify the error is resolved.
> [!IMPORTANT]
> `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability.
### Shell Aliases Persist After Uninstallation ### Shell Aliases Persist After Uninstallation
**Symptom:** safe-chain commands still active after running uninstall script **Symptom:** safe-chain commands still active after running uninstall script
**Steps:** **Steps**
1. Run `safe-chain teardown` (if binary still exists) 1. Run `safe-chain teardown` (if binary still exists)
2. Restart your terminal 2. Restart your terminal
3. If still present, manually edit shell config files: 3. If still present, manually edit shell config files:
- Bash: `~/.bashrc` * Bash: `~/.bashrc`
- Zsh: `~/.zshrc` * Zsh: `~/.zshrc`
- Fish: `~/.config/fish/config.fish` * Fish: `~/.config/fish/config.fish`
- PowerShell: `$PROFILE` * PowerShell: `$PROFILE`
4. Remove lines that source scripts from `~/.safe-chain/scripts/` 4. Remove lines that source scripts from `~/.safe-chain/scripts/`
5. Restart terminal again 5. Restart terminal again
@ -217,10 +206,10 @@ type pip
**Expected `which` output:** **Expected `which` output:**
- Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users/<username>/.safe-chain/bin/safe-chain` * Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users/<username>/.safe-chain/bin/safe-chain`
- npm global (outdated): path containing `node_modules` or nvm version paths * npm global (outdated): path containing `node_modules` or nvm version paths
If `which` shows an npm installation, see [Check for Conflicting Installations](#check-for-conflicting-installations). If `which` shows an npm installation, see Check for Conflicting Installations.
### Check Shell Integration ### Check Shell Integration
@ -259,23 +248,23 @@ for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do
done done
``` ```
## Manual Cleanup ### Manual Cleanup
> **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails. > **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails.
### Remove npm Global Installation #### Remove npm Global Installation
```bash ```bash
npm uninstall -g @aikidosec/safe-chain npm uninstall -g @aikidosec/safe-chain
``` ```
### Remove Volta Installation #### Remove Volta Installation
```bash ```bash
volta uninstall @aikidosec/safe-chain volta uninstall @aikidosec/safe-chain
``` ```
### Remove nvm Installations (All Versions) #### Remove nvm Installations (All Versions)
```bash ```bash
# Automated approach # Automated approach
@ -288,34 +277,22 @@ nvm use <version>
npm uninstall -g @aikidosec/safe-chain npm uninstall -g @aikidosec/safe-chain
``` ```
### Clean Shell Configuration Files #### Clean Shell Configuration Files
Manually remove safe-chain entries from: Manually remove safe-chain entries from:
- Bash: `~/.bashrc` * Bash: `~/.bashrc`
- Zsh: `~/.zshrc` * Zsh: `~/.zshrc`
- Fish: `~/.config/fish/config.fish` * Fish: `~/.config/fish/config.fish`
- PowerShell: `$PROFILE` * PowerShell: `$PROFILE`
Look for and remove: Look for and remove:
- Lines sourcing from `~/.safe-chain/scripts/` * Lines sourcing from `~/.safe-chain/scripts/`
- Any safe-chain related function definitions * Any safe-chain related function definitions
### Remove Installation Directory #### Remove Installation Directory
```bash ```bash
rm -rf ~/.safe-chain rm -rf ~/.safe-chain
``` ```
### Report Issues
If you encounter problems:
1. Visit [GitHub Issues](https://github.com/AikidoSec/safe-chain/issues)
2. Include:
- Operating system and version
- Shell type and version
- `safe-chain --version` output
- Output from verification commands
- Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument)

View file

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

View file

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

View file

@ -52,6 +52,20 @@ $SafeChainBase = $installDirValidation.Normalized
$InstallDir = Join-Path $SafeChainBase "bin" $InstallDir = Join-Path $SafeChainBase "bin"
$RepoUrl = "https://github.com/AikidoSec/safe-chain" $RepoUrl = "https://github.com/AikidoSec/safe-chain"
# SHA256 checksums for release binaries.
# Empty in source; populated by the release pipeline.
# When empty (running from main), checksum verification is skipped.
# Non-Windows hashes are unused today (PS script is Windows-only) but baked in
# for future cross-platform support.
$SHA256_MACOS_X64 = ""
$SHA256_MACOS_ARM64 = ""
$SHA256_LINUX_X64 = ""
$SHA256_LINUX_ARM64 = ""
$SHA256_LINUXSTATIC_X64 = ""
$SHA256_LINUXSTATIC_ARM64 = ""
$SHA256_WIN_X64 = ""
$SHA256_WIN_ARM64 = ""
# Ensure TLS 1.2 is enabled for downloads # Ensure TLS 1.2 is enabled for downloads
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
@ -166,6 +180,38 @@ function Get-BinaryName {
return "safe-chain-win-$Architecture.exe" return "safe-chain-win-$Architecture.exe"
} }
# Returns the expected SHA256 for the given OS+arch, or empty if not baked in.
function Get-ExpectedSha256 {
param([string]$Os, [string]$Architecture)
switch ("$Os-$Architecture") {
"macos-x64" { return $SHA256_MACOS_X64 }
"macos-arm64" { return $SHA256_MACOS_ARM64 }
"linux-x64" { return $SHA256_LINUX_X64 }
"linux-arm64" { return $SHA256_LINUX_ARM64 }
"linuxstatic-x64" { return $SHA256_LINUXSTATIC_X64 }
"linuxstatic-arm64" { return $SHA256_LINUXSTATIC_ARM64 }
"win-x64" { return $SHA256_WIN_X64 }
"win-arm64" { return $SHA256_WIN_ARM64 }
default { return "" }
}
}
function Test-Checksum {
param([string]$File, [string]$Expected)
if ([string]::IsNullOrWhiteSpace($Expected)) { return }
$actual = (Get-FileHash -Path $File -Algorithm SHA256).Hash.ToLowerInvariant()
$expectedLower = $Expected.ToLowerInvariant()
if ($actual -ne $expectedLower) {
Remove-Item -Path $File -Force -ErrorAction SilentlyContinue
Write-Error-Custom "Checksum verification failed. Expected: $expectedLower, Got: $actual"
}
Write-Info "Checksum verified."
}
# Runs safe-chain setup or setup-ci after the binary is installed. # Runs safe-chain setup or setup-ci after the binary is installed.
# Temporarily appends the install directory to PATH and downgrades setup failures to warnings. # Temporarily appends the install directory to PATH and downgrades setup failures to warnings.
function Invoke-SafeChainSetup { function Invoke-SafeChainSetup {
@ -305,6 +351,9 @@ function Install-SafeChain {
Write-Error-Custom "Failed to download from $downloadUrl : $_" Write-Error-Custom "Failed to download from $downloadUrl : $_"
} }
$expectedSha = Get-ExpectedSha256 -Os "win" -Architecture $arch
Test-Checksum -File $tempFile -Expected $expectedSha
# Rename to final location # Rename to final location
$finalFile = Join-Path $InstallDir "safe-chain.exe" $finalFile = Join-Path $InstallDir "safe-chain.exe"
try { try {

View file

@ -55,6 +55,18 @@ SAFE_CHAIN_BASE="${HOME}/.safe-chain"
INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" INSTALL_DIR="${SAFE_CHAIN_BASE}/bin"
REPO_URL="https://github.com/AikidoSec/safe-chain" REPO_URL="https://github.com/AikidoSec/safe-chain"
# SHA256 checksums for release binaries.
# Empty in source; populated by the release pipeline via sed.
# When empty (running from main), checksum verification is skipped.
SHA256_MACOS_X64=""
SHA256_MACOS_ARM64=""
SHA256_LINUX_X64=""
SHA256_LINUX_ARM64=""
SHA256_LINUXSTATIC_X64=""
SHA256_LINUXSTATIC_ARM64=""
SHA256_WIN_X64=""
SHA256_WIN_ARM64=""
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
@ -156,6 +168,57 @@ fetch_latest_version() {
echo "$latest_version" echo "$latest_version"
} }
# Returns the expected SHA256 for the detected platform, or empty if the
# release pipeline has not baked one in (i.e. running the source from main).
get_expected_sha256() {
os="$1"; arch="$2"
case "${os}-${arch}" in
macos-x64) echo "$SHA256_MACOS_X64" ;;
macos-arm64) echo "$SHA256_MACOS_ARM64" ;;
linux-x64) echo "$SHA256_LINUX_X64" ;;
linux-arm64) echo "$SHA256_LINUX_ARM64" ;;
linuxstatic-x64) echo "$SHA256_LINUXSTATIC_X64" ;;
linuxstatic-arm64) echo "$SHA256_LINUXSTATIC_ARM64" ;;
win-x64) echo "$SHA256_WIN_X64" ;;
win-arm64) echo "$SHA256_WIN_ARM64" ;;
*) echo "" ;;
esac
}
compute_sha256() {
file="$1"
if command_exists sha256sum; then
sha256sum "$file" | awk '{print $1}'
elif command_exists shasum; then
shasum -a 256 "$file" | awk '{print $1}'
else
echo ""
fi
}
# Verifies the downloaded binary against the expected hash baked in by the release pipeline.
# No-op when no expected hash is set (running the script from main).
verify_checksum() {
file="$1"; expected="$2"
if [ -z "$expected" ]; then
return
fi
actual=$(compute_sha256 "$file")
if [ -z "$actual" ]; then
rm -f "$file"
error "Cannot verify checksum: neither sha256sum nor shasum is available. Install one and re-run."
fi
if [ "$actual" != "$expected" ]; then
rm -f "$file"
error "Checksum verification failed. Expected: $expected, Got: $actual"
fi
info "Checksum verified."
}
# Download file # Download file
download() { download() {
url="$1" url="$1"
@ -428,6 +491,9 @@ main() {
info "Downloading from: $DOWNLOAD_URL" info "Downloading from: $DOWNLOAD_URL"
download "$DOWNLOAD_URL" "$TEMP_FILE" download "$DOWNLOAD_URL" "$TEMP_FILE"
EXPECTED_SHA256=$(get_expected_sha256 "$OS" "$ARCH")
verify_checksum "$TEMP_FILE" "$EXPECTED_SHA256"
# Rename and make executable # Rename and make executable
FINAL_FILE=$(get_final_binary_path "$OS") FINAL_FILE=$(get_final_binary_path "$OS")
mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE" mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE"

View file

@ -7,7 +7,7 @@
set -e # Exit on error set -e # Exit on error
# Configuration # Configuration
UNINSTALL_SCRIPT="/Library/Application Support/AikidoSecurity/EndpointProtection/scripts/uninstall" UNINSTALL_SCRIPT="/Applications/Aikido Endpoint Protection.app/Contents/Resources/scripts/uninstall"
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'

3
npm-shrinkwrap.json generated
View file

@ -2417,7 +2417,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -3139,6 +3138,8 @@
"aikido-poetry": "bin/aikido-poetry.js", "aikido-poetry": "bin/aikido-poetry.js",
"aikido-python": "bin/aikido-python.js", "aikido-python": "bin/aikido-python.js",
"aikido-python3": "bin/aikido-python3.js", "aikido-python3": "bin/aikido-python3.js",
"aikido-rush": "bin/aikido-rush.js",
"aikido-rushx": "bin/aikido-rushx.js",
"aikido-uv": "bin/aikido-uv.js", "aikido-uv": "bin/aikido-uv.js",
"aikido-uvx": "bin/aikido-uvx.js", "aikido-uvx": "bin/aikido-uvx.js",
"aikido-yarn": "bin/aikido-yarn.js", "aikido-yarn": "bin/aikido-yarn.js",

View file

@ -0,0 +1,14 @@
#!/usr/bin/env node
import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
setEcoSystem(ECOSYSTEM_JS);
const packageManagerName = "rush";
initializePackageManager(packageManagerName);
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -0,0 +1,14 @@
#!/usr/bin/env node
import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
setEcoSystem(ECOSYSTEM_JS);
const packageManagerName = "rushx";
initializePackageManager(packageManagerName);
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -108,7 +108,7 @@ function writeHelp() {
ui.writeInformation( ui.writeInformation(
`- ${chalk.cyan( `- ${chalk.cyan(
"safe-chain setup", "safe-chain setup",
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`, )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip and pip3.`,
); );
ui.writeInformation( ui.writeInformation(
`- ${chalk.cyan( `- ${chalk.cyan(

View file

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

View file

@ -14,6 +14,8 @@ import { createUvPackageManager } from "./uv/createUvPackageManager.js";
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js"; import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js"; import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js";
import { createPdmPackageManager } from "./pdm/createPdmPackageManager.js"; import { createPdmPackageManager } from "./pdm/createPdmPackageManager.js";
import { createRushPackageManager } from "./rush/createRushPackageManager.js";
import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js";
import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js"; import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js";
/** /**
@ -70,6 +72,10 @@ export function initializePackageManager(packageManagerName, context) {
state.packageManagerName = createPipXPackageManager(); state.packageManagerName = createPipXPackageManager();
} else if (packageManagerName === "pdm") { } else if (packageManagerName === "pdm") {
state.packageManagerName = createPdmPackageManager(); state.packageManagerName = createPdmPackageManager();
} else if (packageManagerName === "rush") {
state.packageManagerName = createRushPackageManager();
} else if (packageManagerName === "rushx") {
state.packageManagerName = createRushxPackageManager();
} else { } else {
throw new Error("Unsupported package manager: " + packageManagerName); throw new Error("Unsupported package manager: " + packageManagerName);
} }

View file

@ -0,0 +1,64 @@
import { runRushCommand } from "./runRushCommand.js";
import { resolvePackageVersion } from "../../api/npmApi.js";
import { parsePackagesFromRushAddArgs } from "./parsing/parsePackagesFromRushAddArgs.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createRushPackageManager() {
return {
runCommand: (args) => runRushCommand("rush", args),
// We pre-scan rush add commands and rely on MITM for install/update flows.
isSupportedCommand: (args) => getRushCommand(args) === "add",
getDependencyUpdatesForCommand: scanRushAddCommand,
};
}
/**
* @param {string[]} args
* @returns {Promise<import("../currentPackageManager.js").GetDependencyUpdatesResult[]>}
*/
async function scanRushAddCommand(args) {
if (getRushCommand(args) !== "add") {
return [];
}
const parsedSpecs = parsePackagesFromRushAddArgs(args.slice(1));
const resolvedVersions = await Promise.all(
parsedSpecs.map(async (parsed) => {
const exactVersion = await resolvePackageVersion(parsed.name, parsed.version);
return {
parsed,
exactVersion,
};
}),
);
const changes = [];
for (const resolved of resolvedVersions) {
if (!resolved.exactVersion) {
continue;
}
changes.push({
name: resolved.parsed.name,
version: resolved.exactVersion,
type: "add",
});
}
return changes;
}
/**
* @param {string[]} args
* @returns {string | undefined}
*/
function getRushCommand(args) {
if (!args || args.length === 0) {
return undefined;
}
return args[0]?.toLowerCase();
}

View file

@ -0,0 +1,66 @@
import { test, mock } from "node:test";
import assert from "node:assert";
test("createRushPackageManager", async (t) => {
mock.module("../../api/npmApi.js", {
namedExports: {
resolvePackageVersion: async (name, version) => {
if (name === "safe-chain-test") {
return "0.0.1-security";
}
if (name === "@scope/tool") {
return version || "2.0.0";
}
return null;
},
},
});
try {
const { createRushPackageManager } = await import("./createRushPackageManager.js");
await t.test("should create package manager with required interface", () => {
const pm = createRushPackageManager();
assert.ok(pm);
assert.strictEqual(typeof pm.runCommand, "function");
assert.strictEqual(typeof pm.isSupportedCommand, "function");
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
});
await t.test("should scan rush add commands", () => {
const pm = createRushPackageManager();
assert.strictEqual(pm.isSupportedCommand(["add", "--package", "safe-chain-test"]), true);
assert.strictEqual(pm.isSupportedCommand(["install"]), false);
});
await t.test("should parse rush add package specs and resolve versions", async () => {
const pm = createRushPackageManager();
const changes = await pm.getDependencyUpdatesForCommand([
"add",
"--package",
"safe-chain-test",
"--package=@scope/tool@1.2.3",
]);
assert.deepStrictEqual(changes, [
{ name: "safe-chain-test", version: "0.0.1-security", type: "add" },
{ name: "@scope/tool", version: "1.2.3", type: "add" },
]);
});
await t.test("should return no changes for non-add commands", async () => {
const pm = createRushPackageManager();
const changes = await pm.getDependencyUpdatesForCommand(["install"]);
assert.deepStrictEqual(changes, []);
});
} finally {
mock.reset();
}
});

View file

@ -0,0 +1,71 @@
/**
* @param {string[]} args
* @returns {{name: string, version: string | null}[]}
*/
export function parsePackagesFromRushAddArgs(args) {
const packageSpecs = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg) {
continue;
}
if (arg === "--package" || arg === "-p") {
const next = args[i + 1];
if (next && !next.startsWith("-")) {
packageSpecs.push(next);
i += 1;
}
continue;
}
if (arg.startsWith("--package=")) {
const value = arg.slice("--package=".length);
if (value) {
packageSpecs.push(value);
}
}
}
return packageSpecs
.map((spec) => parsePackageSpec(spec))
.filter((spec) => spec !== null);
}
/**
* @param {string} spec
* @returns {{name: string, version: string | null} | null}
*/
function parsePackageSpec(spec) {
const value = removeAlias(spec.trim());
if (!value) {
return null;
}
const lastAtIndex = value.lastIndexOf("@");
if (lastAtIndex > 0) {
return {
name: value.slice(0, lastAtIndex),
version: value.slice(lastAtIndex + 1),
};
}
return {
name: value,
version: null,
};
}
/**
* @param {string} spec
* @returns {string}
*/
function removeAlias(spec) {
const aliasIndex = spec.indexOf("@npm:");
if (aliasIndex !== -1) {
return spec.slice(aliasIndex + 5);
}
return spec;
}

View file

@ -0,0 +1,49 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { parsePackagesFromRushAddArgs } from "./parsePackagesFromRushAddArgs.js";
describe("parsePackagesFromRushAddArgs", () => {
it("returns an empty array when no packages are provided", () => {
const result = parsePackagesFromRushAddArgs([]);
assert.deepEqual(result, []);
});
it("parses packages from --package arguments", () => {
const result = parsePackagesFromRushAddArgs([
"--package",
"axios@1.9.0",
"--package",
"@scope/tool@2.0.0",
]);
assert.deepEqual(result, [
{ name: "axios", version: "1.9.0" },
{ name: "@scope/tool", version: "2.0.0" },
]);
});
it("parses packages from -p arguments", () => {
const result = parsePackagesFromRushAddArgs(["-p", "axios"]);
assert.deepEqual(result, [{ name: "axios", version: null }]);
});
it("parses packages from --package=value arguments", () => {
const result = parsePackagesFromRushAddArgs(["--package=axios@^1.9.0"]);
assert.deepEqual(result, [{ name: "axios", version: "^1.9.0" }]);
});
it("ignores positional packages because rush add requires --package", () => {
const result = parsePackagesFromRushAddArgs(["axios", "--dev"]);
assert.deepEqual(result, []);
});
it("parses aliases", () => {
const result = parsePackagesFromRushAddArgs(["--package", "server@npm:axios@1.9.0"]);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
});

View file

@ -0,0 +1,21 @@
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @param {"rush" | "rushx"} executableName
* @param {string[]} args
* @returns {Promise<{status: number}>}
*/
export async function runRushCommand(executableName, args) {
try {
const result = await safeSpawn(executableName, args, {
stdio: "inherit",
env: mergeSafeChainProxyEnvironmentVariables(process.env),
});
return { status: result.status };
} catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, executableName);
}
}

View file

@ -0,0 +1,109 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("runRushCommand", () => {
let runRushCommand;
let safeSpawnMock;
let mergeCalls;
let mergeResultEnv;
let nextSpawnStatus;
let nextSpawnError;
beforeEach(async () => {
mergeCalls = [];
mergeResultEnv = null;
nextSpawnStatus = 0;
nextSpawnError = null;
safeSpawnMock = mock.fn(async () => {
if (nextSpawnError) {
const error = nextSpawnError;
nextSpawnError = null;
throw error;
}
return { status: nextSpawnStatus };
});
mock.module("../../utils/safeSpawn.js", {
namedExports: {
safeSpawn: safeSpawnMock,
},
});
mock.module("../../registryProxy/registryProxy.js", {
namedExports: {
mergeSafeChainProxyEnvironmentVariables: (env) => {
mergeCalls.push(env);
if (mergeResultEnv) {
return mergeResultEnv;
}
return {
...env,
HTTPS_PROXY: "http://localhost:8080",
};
},
},
});
// commandErrors reports through ui on failures, so provide a no-op mock
mock.module("../../environment/userInteraction.js", {
namedExports: {
ui: {
writeError: () => {},
},
},
});
const mod = await import("./runRushCommand.js");
runRushCommand = mod.runRushCommand;
});
afterEach(() => {
mock.reset();
});
it("spawns rush with merged proxy env", async () => {
const res = await runRushCommand("rush", ["install"]);
assert.strictEqual(res.status, 0);
assert.strictEqual(safeSpawnMock.mock.calls.length, 1);
const [command, args, options] = safeSpawnMock.mock.calls[0].arguments;
assert.strictEqual(command, "rush");
assert.deepStrictEqual(args, ["install"]);
assert.strictEqual(options.stdio, "inherit");
assert.strictEqual(options.env.HTTPS_PROXY, "http://localhost:8080");
assert.ok(mergeCalls.length >= 1, "proxy env merge should be called");
});
it("returns spawn result status", async () => {
nextSpawnStatus = 7;
const res = await runRushCommand("rush", ["update"]);
assert.strictEqual(res.status, 7);
});
it("reports failures with rush target", async () => {
nextSpawnError = Object.assign(new Error("spawn failed"), {
code: "ENOENT",
});
const res = await runRushCommand("rush", ["install"]);
assert.strictEqual(res.status, 1);
});
it("does not mutate merged env object", async () => {
mergeResultEnv = {
HTTPS_PROXY: "http://localhost:8080",
};
await runRushCommand("rush", ["install"]);
assert.deepStrictEqual(mergeResultEnv, {
HTTPS_PROXY: "http://localhost:8080",
});
});
});

View file

@ -0,0 +1,18 @@
import { runRushCommand } from "../rush/runRushCommand.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createRushxPackageManager() {
return {
/**
* @param {string[]} args
*/
runCommand: (args) => {
return runRushCommand("rushx", args);
},
// For rushx, rely solely on MITM.
isSupportedCommand: () => false,
getDependencyUpdatesForCommand: () => [],
};
}

View file

@ -0,0 +1,14 @@
import { test } from "node:test";
import assert from "node:assert";
import { createRushxPackageManager } from "./createRushxPackageManager.js";
test("createRushxPackageManager returns valid package manager interface", () => {
const pm = createRushxPackageManager();
assert.ok(pm);
assert.strictEqual(typeof pm.runCommand, "function");
assert.strictEqual(typeof pm.isSupportedCommand, "function");
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
assert.strictEqual(pm.isSupportedCommand(), false);
assert.deepStrictEqual(pm.getDependencyUpdatesForCommand(), []);
});

View file

@ -42,7 +42,7 @@ function getSafeChainProxyEnvironmentVariables() {
return {}; return {};
} }
const proxyUrl = `http://localhost:${state.port}`; const proxyUrl = `http://127.0.0.1:${state.port}`;
const caCertPath = getCombinedCaBundlePath(); const caCertPath = getCombinedCaBundlePath();
return { return {
@ -95,8 +95,11 @@ function createProxyServer() {
*/ */
function startServer(server) { function startServer(server) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Passing port 0 makes the OS assign an available port // Bind to loopback only. Without an explicit host, Node listens on every
server.listen(0, () => { // interface, turning the proxy into an unauthenticated forward proxy that
// anyone reachable on the network can use to hit the victim's localhost,
// intranet, or cloud metadata endpoints. Port 0 lets the OS pick a port.
server.listen(0, "127.0.0.1", () => {
const address = server.address(); const address = server.address();
if (address && typeof address === "object") { if (address && typeof address === "object") {
state.port = address.port; state.port = address.port;

View file

@ -0,0 +1,67 @@
import { before, after, describe, it } from "node:test";
import assert from "node:assert";
import net from "node:net";
import os from "node:os";
import {
createSafeChainProxy,
mergeSafeChainProxyEnvironmentVariables,
} from "./registryProxy.js";
describe("registryProxy loopback binding", () => {
let proxy, proxyPort;
before(async () => {
proxy = createSafeChainProxy();
await proxy.startServer();
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
proxyPort = parseInt(new URL(envVars.HTTPS_PROXY).port, 10);
});
after(async () => {
await proxy.stopServer();
});
it("advertises a loopback HTTPS_PROXY URL", () => {
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
const hostname = new URL(envVars.HTTPS_PROXY).hostname;
assert.ok(
hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost",
`expected loopback hostname, got ${hostname}`
);
});
it("refuses connections on non-loopback interfaces", async () => {
const externalAddrs = Object.values(os.networkInterfaces())
.flat()
.filter((iface) => iface && iface.family === "IPv4" && !iface.internal)
.map((iface) => iface.address);
if (externalAddrs.length === 0) {
// No non-loopback interface available (e.g. locked-down CI) - skip.
return;
}
for (const addr of externalAddrs) {
await new Promise((resolve, reject) => {
const sock = net.createConnection({ host: addr, port: proxyPort });
const timer = setTimeout(() => {
sock.destroy();
resolve(); // Filtered / dropped is also fine - we just don't want success.
}, 500);
sock.once("connect", () => {
clearTimeout(timer);
sock.destroy();
reject(
new Error(
`proxy accepted a connection on non-loopback ${addr}:${proxyPort}`
)
);
});
sock.once("error", () => {
clearTimeout(timer);
resolve();
});
});
}
});
});

View file

@ -15,8 +15,12 @@ import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js";
* @property {function(string, string): boolean} isMalware * @property {function(string, string): boolean} isMalware
*/ */
/** @type {MalwareDatabase | null} */ // Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved
let cachedMalwareDatabase = null; // value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields
// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all
// concurrent callers see it immediately and share a single fetch.
/** @type {Promise<MalwareDatabase> | null} */
let cachedMalwareDatabasePromise = null;
/** /**
* Normalize package name for comparison. * Normalize package name for comparison.
@ -34,13 +38,9 @@ function normalizePackageName(name) {
return name; return name;
} }
export async function openMalwareDatabase() { export function openMalwareDatabase() {
if (cachedMalwareDatabase) { if (!cachedMalwareDatabasePromise) {
return cachedMalwareDatabase; cachedMalwareDatabasePromise = getMalwareDatabase().then((malwareDatabase) => {
}
const malwareDatabase = await getMalwareDatabase();
/** /**
* @param {string} name * @param {string} name
* @param {string} version * @param {string} version
@ -63,16 +63,19 @@ export async function openMalwareDatabase() {
return packageData.reason; return packageData.reason;
} }
// This implicitly caches the malware database return {
// that's closed over by the getPackageStatus function
cachedMalwareDatabase = {
getPackageStatus, getPackageStatus,
isMalware: (name, version) => { isMalware: (/** @type {string} */ name, /** @type {string} */ version) => {
const status = getPackageStatus(name, version); const status = getPackageStatus(name, version);
return isMalwareStatus(status); return isMalwareStatus(status);
}, },
}; };
return cachedMalwareDatabase; }).catch((error) => {
cachedMalwareDatabasePromise = null;
throw error;
});
}
return cachedMalwareDatabasePromise;
} }
/** /**

View file

@ -16,30 +16,27 @@ import { warnOnceAboutUnavailableDatabase } from "./newPackagesDatabaseWarnings.
*/ */
// Shared per-process cache to avoid rebuilding the same feed-backed database on each request. // Shared per-process cache to avoid rebuilding the same feed-backed database on each request.
/** @type {NewPackagesDatabase | null} */ // Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved
let cachedNewPackagesDatabase = null; // value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields
// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all
// concurrent callers see it immediately and share a single fetch.
/** @type {Promise<NewPackagesDatabase> | null} */
let cachedNewPackagesDatabasePromise = null;
/** /**
* @returns {Promise<NewPackagesDatabase>} * @returns {Promise<NewPackagesDatabase>}
*/ */
export async function openNewPackagesDatabase() { export function openNewPackagesDatabase() {
if (cachedNewPackagesDatabase) { if (!cachedNewPackagesDatabasePromise) {
return cachedNewPackagesDatabase; cachedNewPackagesDatabasePromise = getNewPackagesList()
} .then((newPackagesList) => buildNewPackagesDatabase(newPackagesList))
.catch((/** @type {any} */ error) => {
/** @type {import("../api/aikido.js").NewPackageEntry[]} */
let newPackagesList;
try {
newPackagesList = await getNewPackagesList();
} catch (/** @type {any} */ error) {
warnOnceAboutUnavailableDatabase(error); warnOnceAboutUnavailableDatabase(error);
cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false }; cachedNewPackagesDatabasePromise = null;
return cachedNewPackagesDatabase; return { isNewlyReleasedPackage: () => false };
});
} }
return cachedNewPackagesDatabasePromise;
cachedNewPackagesDatabase = buildNewPackagesDatabase(newPackagesList);
return cachedNewPackagesDatabase;
} }
/** /**

View file

@ -48,6 +48,18 @@ export const knownAikidoTools = [
ecoSystem: ECOSYSTEM_JS, ecoSystem: ECOSYSTEM_JS,
internalPackageManagerName: "pnpx", internalPackageManagerName: "pnpx",
}, },
{
tool: "rush",
aikidoCommand: "aikido-rush",
ecoSystem: ECOSYSTEM_JS,
internalPackageManagerName: "rush",
},
{
tool: "rushx",
aikidoCommand: "aikido-rushx",
ecoSystem: ECOSYSTEM_JS,
internalPackageManagerName: "rushx",
},
{ {
tool: "bun", tool: "bun",
aikidoCommand: "aikido-bun", aikidoCommand: "aikido-bun",

View file

@ -19,6 +19,14 @@ function pnpx
wrapSafeChainCommand "pnpx" $argv wrapSafeChainCommand "pnpx" $argv
end end
function rush
wrapSafeChainCommand "rush" $argv
end
function rushx
wrapSafeChainCommand "rushx" $argv
end
function bun function bun
wrapSafeChainCommand "bun" $argv wrapSafeChainCommand "bun" $argv
end end

View file

@ -28,6 +28,14 @@ function pnpx() {
wrapSafeChainCommand "pnpx" "$@" wrapSafeChainCommand "pnpx" "$@"
} }
function rush() {
wrapSafeChainCommand "rush" "$@"
}
function rushx() {
wrapSafeChainCommand "rushx" "$@"
}
function bun() { function bun() {
wrapSafeChainCommand "bun" "$@" wrapSafeChainCommand "bun" "$@"
} }

View file

@ -22,6 +22,14 @@ function pnpx {
Invoke-WrappedCommand "pnpx" $args $MyInvocation.Line $MyInvocation.OffsetInLine Invoke-WrappedCommand "pnpx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
} }
function rush {
Invoke-WrappedCommand "rush" $args $MyInvocation.Line $MyInvocation.OffsetInLine
}
function rushx {
Invoke-WrappedCommand "rushx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
}
function bun { function bun {
Invoke-WrappedCommand "bun" $args $MyInvocation.Line $MyInvocation.OffsetInLine Invoke-WrappedCommand "bun" $args $MyInvocation.Line $MyInvocation.OffsetInLine
} }

View file

@ -58,12 +58,21 @@ export class DockerTestContainer {
`docker run -d --name ${this.containerName} ${imageName} sleep infinity`, `docker run -d --name ${this.containerName} ${imageName} sleep infinity`,
{ stdio: "ignore" } { stdio: "ignore" }
); );
await this.startMalwareMirror();
this.isRunning = true; this.isRunning = true;
} catch (error) { } catch (error) {
throw new Error(`Failed to start container: ${error.message}`); throw new Error(`Failed to start container: ${error.message}`);
} }
} }
async startMalwareMirror() {
const shell = await this.openShell("zsh");
await shell.runCommand("node /utils/malwarelistmirror.mjs &");
await shell.runCommand("until curl -sf http://127.0.0.1:5555/ready; do sleep 0.2; done");
}
dockerExec(command, daemon = false) { dockerExec(command, daemon = false) {
if (!this.isRunning) { if (!this.isRunning) {
throw new Error("Container is not running"); throw new Error("Container is not running");
@ -125,7 +134,7 @@ export class DockerTestContainer {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
// Fallback in case the command doesn't finish in a reasonable time // Fallback in case the command doesn't finish in a reasonable time
// oxlint-disable-next-line no-console - having this log in CI helps diagnose issues // oxlint-disable-next-line no-console - having this log in CI helps diagnose issues
console.log("Command timeout reached"); console.log(`Command timeout reached for "${command}"`);
resolve({ allData, output: parseShellOutput(allData), command }); resolve({ allData, output: parseShellOutput(allData), command });
ptyProcess.removeListener("data", handleInput); ptyProcess.removeListener("data", handleInput);
}, 15000); }, 15000);

View file

@ -25,6 +25,7 @@ ARG NODE_VERSION=latest
ARG NPM_VERSION=latest ARG NPM_VERSION=latest
ARG YARN_VERSION=latest ARG YARN_VERSION=latest
ARG PNPM_VERSION=latest ARG PNPM_VERSION=latest
ARG RUSH_VERSION=latest
ARG PYTHON_VERSION=3 ARG PYTHON_VERSION=3
SHELL ["/bin/bash", "-c"] SHELL ["/bin/bash", "-c"]
@ -46,6 +47,7 @@ RUN volta install node@${NODE_VERSION}
RUN volta install npm@${NPM_VERSION} RUN volta install npm@${NPM_VERSION}
RUN volta install yarn@${YARN_VERSION} RUN volta install yarn@${YARN_VERSION}
RUN volta install pnpm@${PNPM_VERSION} RUN volta install pnpm@${PNPM_VERSION}
RUN volta install @microsoft/rush@${RUSH_VERSION}
# Install Bun # Install Bun
RUN curl -fsSL https://bun.sh/install | bash RUN curl -fsSL https://bun.sh/install | bash
@ -88,3 +90,5 @@ RUN npm install -g /pkgs/*.tgz
WORKDIR /testapp WORKDIR /testapp
RUN npm init -y RUN npm init -y
COPY test/e2e/utils/malwarelistmirror.mjs /utils/malwarelistmirror.mjs
ENV SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:5555

View file

@ -128,7 +128,7 @@ describe("E2E: pip coverage", () => {
it(`safe-chain blocks installation of malicious Python packages`, async () => { it(`safe-chain blocks installation of malicious Python packages`, async () => {
const shell = await container.openShell("zsh"); const shell = await container.openShell("zsh");
const result = await shell.runCommand( const result = await shell.runCommand(
"pip3 install --break-system-packages safe-chain-pi-test" "pip3 install --break-system-packages numpy==2.4.4 --safe-chain-logging=verbose"
); );
assert.ok( assert.ok(
@ -136,7 +136,7 @@ describe("E2E: pip coverage", () => {
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(
result.output.includes("safe_chain_pi_test@0.0.1"), result.output.includes("numpy@2.4.4"),
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(
@ -146,7 +146,7 @@ describe("E2E: pip coverage", () => {
const listResult = await shell.runCommand("pip3 list"); const listResult = await shell.runCommand("pip3 list");
assert.ok( assert.ok(
!listResult.output.includes("safe-chain-pi-test"), !listResult.output.includes("numpy"),
`Malicious package was installed despite safe-chain protection. Output of 'pip3 list' was:\n${listResult.output}` `Malicious package was installed despite safe-chain protection. Output of 'pip3 list' was:\n${listResult.output}`
); );
}); });

View file

@ -41,7 +41,7 @@ describe("E2E: pipx coverage", () => {
const shell = await container.openShell("zsh"); const shell = await container.openShell("zsh");
const result = await shell.runCommand( const result = await shell.runCommand(
"pipx install safe-chain-pi-test" "pipx install numpy==2.4.4"
); );
assert.ok( assert.ok(
@ -86,7 +86,7 @@ describe("E2E: pipx coverage", () => {
const shell = await container.openShell("zsh"); const shell = await container.openShell("zsh");
const result = await shell.runCommand( const result = await shell.runCommand(
"pipx run safe-chain-pi-test --version" "pipx run numpy==2.4.4 --version"
); );
assert.ok( assert.ok(
@ -122,7 +122,7 @@ describe("E2E: pipx coverage", () => {
await shell.runCommand("pipx install ruff"); await shell.runCommand("pipx install ruff");
const result = await shell.runCommand( const result = await shell.runCommand(
"pipx runpip ruff install safe-chain-pi-test" "pipx runpip ruff install numpy==2.4.4"
); );
assert.ok( assert.ok(
@ -185,7 +185,7 @@ describe("E2E: pipx coverage", () => {
await shell.runCommand("pipx install ruff --safe-chain-logging=verbose"); await shell.runCommand("pipx install ruff --safe-chain-logging=verbose");
const result = await shell.runCommand( const result = await shell.runCommand(
"pipx inject ruff safe-chain-pi-test --safe-chain-logging=verbose" "pipx inject ruff numpy==2.4.4 --safe-chain-logging=verbose"
); );
assert.ok( assert.ok(

View file

@ -70,7 +70,7 @@ describe("E2E: poetry coverage", () => {
await shell.runCommand("cd /tmp/test-poetry-malware && poetry init --no-interaction"); await shell.runCommand("cd /tmp/test-poetry-malware && poetry init --no-interaction");
const result = await shell.runCommand( const result = await shell.runCommand(
"cd /tmp/test-poetry-malware && poetry add safe-chain-pi-test" "cd /tmp/test-poetry-malware && poetry add numpy==2.4.4"
); );
assert.ok( assert.ok(
@ -300,7 +300,7 @@ describe("E2E: poetry coverage", () => {
// Add malware package - this will create lock file and attempt download // Add malware package - this will create lock file and attempt download
const result = await shell.runCommand( const result = await shell.runCommand(
"cd /tmp/test-poetry-install-malware && poetry add safe-chain-pi-test 2>&1" "cd /tmp/test-poetry-install-malware && poetry add numpy==2.4.4 2>&1"
); );
assert.ok( assert.ok(
@ -324,7 +324,7 @@ describe("E2E: poetry coverage", () => {
// Now try to add malware via add command // Now try to add malware via add command
const result = await shell.runCommand( const result = await shell.runCommand(
"cd /tmp/test-poetry-update-add && poetry add safe-chain-pi-test 2>&1" "cd /tmp/test-poetry-update-add && poetry add numpy==2.4.4 2>&1"
); );
assert.ok( assert.ok(
@ -345,7 +345,7 @@ describe("E2E: poetry coverage", () => {
// Try to add malware directly - this is the primary vector // Try to add malware directly - this is the primary vector
const result = await shell.runCommand( const result = await shell.runCommand(
"cd /tmp/test-poetry-req-malware && poetry add safe-chain-pi-test requests 2>&1" "cd /tmp/test-poetry-req-malware && poetry add numpy==2.4.4 requests 2>&1"
); );
assert.ok( assert.ok(

148
test/e2e/rush.e2e.spec.js Normal file
View file

@ -0,0 +1,148 @@
import { describe, it, before, beforeEach, afterEach } from "node:test";
import { DockerTestContainer } from "./DockerTestContainer.js";
import {
buildRushConfig,
resolveRushVersions,
writeTextFile,
} from "./utils/rushtestutils.mjs";
import assert from "node:assert";
// These tests cover safe-chain's Rush wrapper: pre-scanning `rush add` and
// blocking malicious packages downloaded during `rush update` via the MITM
// proxy. They use a single Rush-internal package manager (pnpm) — see
// `utils/rushtestutils.mjs` for why this suite isn't parameterised over the
// CI matrix's NPM_VERSION/PNPM_VERSION/YARN_VERSION values.
describe("E2E: rush coverage", () => {
let container;
/** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */
let resolvedVersions;
before(async () => {
DockerTestContainer.buildImage();
});
beforeEach(async () => {
container = new DockerTestContainer();
await container.start();
const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup");
if (!resolvedVersions) {
resolvedVersions = await resolveRushVersions(installationShell);
}
await setupRushWorkspace(installationShell, { resolvedVersions });
});
afterEach(async () => {
if (container) {
await container.stop();
container = null;
}
});
it("safe-chain successfully adds safe packages", async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"cd /testapp/apps/test-app && rush add --package axios@1.13.0 --exact --skip-update --safe-chain-logging=verbose"
);
assert.ok(
result.output.includes("no malware found."),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it("safe-chain blocks rush add of malicious packages", async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"cd /testapp/apps/test-app && rush add --package safe-chain-test --skip-update"
);
assert.ok(
result.output.includes("Malicious changes detected:"),
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("- safe-chain-test"),
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("Exiting without installing malicious packages."),
`Output did not include expected text. Output was:\n${result.output}`
);
const packageJson = await shell.runCommand(
"cat /testapp/apps/test-app/package.json"
);
assert.ok(
!packageJson.output.includes("safe-chain-test"),
`Malicious package was added despite safe-chain protection. Output was:\n${packageJson.output}`
);
});
it("safe-chain proxy blocks malicious package downloads during rush update", async () => {
const shell = await container.openShell("zsh");
await setupRushWorkspace(shell, {
resolvedVersions,
packageJson: `{
"name": "test-app",
"version": "1.0.0",
"dependencies": {
"safe-chain-test": "0.0.1-security"
}
}`,
});
// `--safe-chain-skip-minimum-package-age` is needed because Rush's
// internal pnpm bootstrap (`npm install pnpm@<resolvedVersion>`) goes
// through the safe-chain proxy. When the CI matrix selects pnpm
// `latest`, the just-released version can be below the minimum age
// threshold and Rush's install would otherwise be blocked before our
// malicious-download assertion is reached.
const result = await shell.runCommand(
"cd /testapp/apps/test-app && rush update --safe-chain-skip-minimum-package-age"
);
assert.match(
result.output,
/blocked \d+ malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("- safe-chain-test"),
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("Exiting without installing malicious packages."),
`Output did not include expected text. Output was:\n${result.output}`
);
});
});
async function setupRushWorkspace(shell, { resolvedVersions, packageJson }) {
const rushConfig = buildRushConfig({
rushVersion: resolvedVersions.rushVersion,
pnpmVersion: resolvedVersions.pnpmVersion,
});
await shell.runCommand("rm -rf /testapp/common /testapp/apps/test-app");
await shell.runCommand("mkdir -p /testapp/apps/test-app");
await writeTextFile(
shell,
"/testapp/rush.json",
JSON.stringify(rushConfig, null, 2)
);
await writeTextFile(
shell,
"/testapp/apps/test-app/package.json",
packageJson ??
`{
"name": "test-app",
"version": "1.0.0"
}`
);
}

100
test/e2e/rushx.e2e.spec.js Normal file
View file

@ -0,0 +1,100 @@
import { describe, it, before, beforeEach, afterEach } from "node:test";
import { DockerTestContainer } from "./DockerTestContainer.js";
import {
buildRushConfig,
resolveRushVersions,
writeTextFile,
} from "./utils/rushtestutils.mjs";
import assert from "node:assert";
describe("E2E: rushx coverage", () => {
let container;
/** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */
let resolvedVersions;
before(async () => {
DockerTestContainer.buildImage();
});
beforeEach(async () => {
container = new DockerTestContainer();
await container.start();
const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup");
if (!resolvedVersions) {
resolvedVersions = await resolveRushVersions(installationShell);
}
await setupRushWorkspace(installationShell, { resolvedVersions });
});
afterEach(async () => {
if (container) {
await container.stop();
container = null;
}
});
it("safe-chain successfully scans safe package downloads from rushx scripts", async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"cd /testapp/apps/test-app && rushx install-safe --safe-chain-logging=verbose"
);
assert.ok(
result.output.includes("no malware found."),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it("safe-chain blocks malicious package downloads from rushx scripts", async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"cd /testapp/apps/test-app && rushx install-malicious"
);
assert.match(
result.output,
/blocked \d+ malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("- safe-chain-test"),
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("Exiting without installing malicious packages."),
`Output did not include expected text. Output was:\n${result.output}`
);
});
});
async function setupRushWorkspace(shell, { resolvedVersions }) {
const rushConfig = buildRushConfig({
rushVersion: resolvedVersions.rushVersion,
pnpmVersion: resolvedVersions.pnpmVersion,
});
await shell.runCommand(
"mkdir -p /testapp/common/config/rush /testapp/apps/test-app"
);
await writeTextFile(
shell,
"/testapp/rush.json",
JSON.stringify(rushConfig, null, 2)
);
await writeTextFile(
shell,
"/testapp/apps/test-app/package.json",
`{
"name": "test-app",
"version": "1.0.0",
"scripts": {
"install-safe": "npm install axios@1.13.0",
"install-malicious": "npm install safe-chain-test@0.0.1-security"
}
}`
);
}

View file

@ -97,7 +97,7 @@ describe("E2E: safe-chain CLI python/pip support", () => {
await shell.runCommand("pip3 cache purge"); await shell.runCommand("pip3 cache purge");
const result = await shell.runCommand( const result = await shell.runCommand(
"safe-chain pip3 install --break-system-packages safe-chain-pi-test" "safe-chain pip3 install --break-system-packages numpy==2.4.4"
); );
assert.ok( assert.ok(

View file

@ -0,0 +1,79 @@
// Test-only mirror of the malware list. Injects known-safe packages as malicious
// to simulate blocking behavior in e2e tests without affecting real data.
import * as http from "node:http";
const lists = await downloadLists();
const server = http.createServer(handleRequest);
server.listen(5555, "127.0.0.1");
console.log("listening on http://127.0.0.1:5555");
function handleRequest(req, res) {
if (req.method !== "GET" || !req.url) {
res.writeHead(404);
res.end();
return;
}
if (req.url.startsWith("/ready")) {
res.writeHead(200);
res.end();
return;
}
for (const list of lists) {
if (req.url.startsWith(list.path)) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(list.data));
return;
}
}
res.writeHead(404);
res.end();
}
async function downloadLists() {
const lists = [
{
"path": "/malware_predictions.json",
"patchFunc": (data) => data,
},
{
"path": "/malware_pypi.json",
"patchFunc": patchPypi,
},
{
"path": "/releases/npm.json",
"patchFunc": (data) => data,
},
{
"path": "/releases/pypi.json",
"patchFunc": (data) => data,
},
]
for (const list of lists) {
list.data = list.patchFunc(await downloadList(list.path));
}
return lists;
}
async function downloadList(path) {
const baseUrl = "https://malware-list.aikido.dev";
const url = `${baseUrl}${path}`;
const response = await fetch(url);
return await response.json();
}
function patchPypi(data) {
data.push({
"package_name": "numpy",
"version": "2.4.4",
"reason": "MALWARE"
});
return data;
}

View file

@ -0,0 +1,69 @@
// Helpers for the Rush E2E suites.
//
// What these suites actually test: that safe-chain's shim intercepts `rush`
// and `rushx` invocations correctly. The contents of `rush.json` are just
// fixture noise needed to make Rush run at all — Rush's schema requires
// exact semver for `rushVersion`/`pnpmVersion` and refuses dist-tags like
// "latest", so we read both back from the binaries baked into the image.
//
// * `rushVersion` ← `rush --version` (image installs
// `@microsoft/rush@${RUSH_VERSION:-latest}`).
// * `pnpmVersion` ← `pnpm --version` (image installs
// `pnpm@${PNPM_VERSION:-latest}`). Rush downloads its own copy of this
// into `~/.rush/...`; using the same exact version as the system pnpm
// just keeps the fixture in lockstep with whatever the CI matrix picks.
/** Resolves the versions to put into `rush.json`. */
export async function resolveRushVersions(shell) {
// Sequential: the helper drives a single PTY shell.
const rushVersion = await getInstalledVersion(shell, "rush");
const pnpmVersion = await getInstalledVersion(shell, "pnpm");
return { rushVersion, pnpmVersion };
}
/** Builds the standard `rush.json` body for the e2e fixtures. */
export function buildRushConfig({ rushVersion, pnpmVersion, projects }) {
return {
$schema:
"https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json",
rushVersion,
pnpmVersion,
nodeSupportedVersionRange: ">=18.0.0",
projectFolderMinDepth: 1,
projectFolderMaxDepth: 2,
gitPolicy: {},
repository: {
url: "https://example.com/testapp.git",
defaultBranch: "main",
},
eventHooks: {
preRushInstall: [],
postRushInstall: [],
preRushBuild: [],
postRushBuild: [],
},
projects: projects ?? [
{ packageName: "test-app", projectFolder: "apps/test-app" },
],
};
}
/**
* Writes a UTF-8 text file inside the container, base64-encoding the payload
* to avoid shell escaping issues for arbitrary content.
*/
export async function writeTextFile(shell, filePath, content) {
const encoded = Buffer.from(content).toString("base64");
await shell.runCommand(`printf '%s' '${encoded}' | base64 -d > ${filePath}`);
}
async function getInstalledVersion(shell, command) {
const { output } = await shell.runCommand(`${command} --version`);
const match = output.match(/\b(\d+\.\d+\.\d+)\b/);
if (!match) {
throw new Error(
`Could not determine installed ${command} version. Output was:\n${output}`
);
}
return match[1];
}

View file

@ -126,7 +126,7 @@ describe("E2E: uv coverage", () => {
const shell = await container.openShell("zsh"); const shell = await container.openShell("zsh");
const result = await shell.runCommand( const result = await shell.runCommand(
"uv pip install --system --break-system-packages safe-chain-pi-test" "uv pip install --system --break-system-packages numpy==2.4.4"
); );
assert.ok( assert.ok(
@ -134,7 +134,7 @@ describe("E2E: uv coverage", () => {
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(
result.output.includes("safe_chain_pi_test@0.0.1"), result.output.includes("numpy@2.4.4"),
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(
@ -144,7 +144,7 @@ describe("E2E: uv coverage", () => {
const listResult = await shell.runCommand("uv pip list --system"); const listResult = await shell.runCommand("uv pip list --system");
assert.ok( assert.ok(
!listResult.output.includes("safe-chain-pi-test"), !listResult.output.includes("numpy"),
`Malicious package was installed despite safe-chain protection. Output of 'uv pip list' was:\n${listResult.output}` `Malicious package was installed despite safe-chain protection. Output of 'uv pip list' was:\n${listResult.output}`
); );
}); });
@ -413,7 +413,7 @@ describe("E2E: uv coverage", () => {
await shell.runCommand("uv init test-project-malware"); await shell.runCommand("uv init test-project-malware");
const result = await shell.runCommand( const result = await shell.runCommand(
"cd test-project-malware && uv add safe-chain-pi-test" "cd test-project-malware && uv add numpy==2.4.4"
); );
assert.ok( assert.ok(
@ -421,7 +421,7 @@ describe("E2E: uv coverage", () => {
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(
result.output.includes("safe_chain_pi_test@0.0.1"), result.output.includes("numpy@2.4.4"),
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(
@ -445,14 +445,14 @@ describe("E2E: uv coverage", () => {
it(`safe-chain blocks malicious packages via uv tool install`, async () => { it(`safe-chain blocks malicious packages via uv tool install`, async () => {
const shell = await container.openShell("zsh"); const shell = await container.openShell("zsh");
const result = await shell.runCommand("uv tool install safe-chain-pi-test"); const result = await shell.runCommand("uv tool install numpy==2.4.4");
assert.ok( assert.ok(
result.output.includes("blocked 1 malicious package downloads:"), result.output.includes("blocked 1 malicious package downloads:"),
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
assert.ok( assert.ok(
result.output.includes("safe_chain_pi_test@0.0.1"), result.output.includes("numpy@2.4.4"),
`Output did not include expected text. Output was:\n${result.output}` `Output did not include expected text. Output was:\n${result.output}`
); );
}); });
@ -482,7 +482,7 @@ describe("E2E: uv coverage", () => {
await shell.runCommand("echo 'print(\"test\")' > test_script2.py"); await shell.runCommand("echo 'print(\"test\")' > test_script2.py");
const result = await shell.runCommand( const result = await shell.runCommand(
"uv run --with safe-chain-pi-test test_script2.py" "uv run --with numpy==2.4.4 test_script2.py"
); );
assert.ok( assert.ok(

View file

@ -44,7 +44,7 @@ describe("E2E: uvx coverage", () => {
const shell = await container.openShell("zsh"); const shell = await container.openShell("zsh");
const result = await shell.runCommand( const result = await shell.runCommand(
"uvx safe-chain-pi-test" "uvx numpy==2.4.4"
); );
assert.ok( assert.ok(
@ -74,7 +74,7 @@ describe("E2E: uvx coverage", () => {
const shell = await container.openShell("zsh"); const shell = await container.openShell("zsh");
const result = await shell.runCommand( const result = await shell.runCommand(
"uvx --from safe-chain-pi-test some-command" "uvx --from numpy==2.4.4 some-command"
); );
assert.ok( assert.ok(
@ -117,7 +117,7 @@ describe("E2E: uvx coverage", () => {
const shell = await container.openShell("zsh"); const shell = await container.openShell("zsh");
const result = await shell.runCommand( const result = await shell.runCommand(
"uvx --with safe-chain-pi-test ruff --version" "uvx --with numpy==2.4.4 ruff --version"
); );
assert.ok( assert.ok(