mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Compare commits
73 commits
0.0.1-sha2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9453c8c0c9 | ||
|
|
2621f6f974 | ||
|
|
fd01d9f31b | ||
|
|
0414a79982 | ||
|
|
70b5e4d012 | ||
|
|
aed0aebdae | ||
|
|
6aec1bc474 | ||
|
|
f6145d5c20 | ||
|
|
ab058367f1 | ||
|
|
f2cce7b7e9 | ||
|
|
0b46c5408b | ||
|
|
07b8571758 | ||
|
|
3f0837c65a | ||
|
|
47e9ed0f6c | ||
|
|
cbbbe703d3 | ||
|
|
9d44eca1d1 | ||
|
|
b38aba43dd | ||
|
|
93c264ef84 | ||
|
|
34898980d7 | ||
|
|
a5c29d9e49 | ||
|
|
bf2d37d114 | ||
|
|
65a8075b0e | ||
|
|
a1b89a55f8 | ||
|
|
8ab5cebd4f | ||
|
|
ffe7f8de1f | ||
|
|
54db058ac7 | ||
|
|
8453012f7b | ||
|
|
e0e06431d1 | ||
|
|
6cdad3df98 | ||
|
|
d9b7aefd34 | ||
|
|
0c8de1e606 | ||
|
|
fde0003a0a | ||
|
|
c93f1920fb | ||
|
|
d812231b2f | ||
|
|
e5cd9eed91 | ||
|
|
25d966bfa9 | ||
|
|
5f0ad7ecfd | ||
|
|
6667e5d7b4 | ||
|
|
e891d1a992 | ||
|
|
26f1dfb81a | ||
|
|
7ce44b4c62 | ||
|
|
28132ba3fc | ||
|
|
55f2123f5c | ||
|
|
5f56114185 | ||
|
|
08ae1ef732 | ||
|
|
2eb32d4297 | ||
|
|
fbe094802e | ||
|
|
bd876275b3 | ||
|
|
cd5040c3be | ||
|
|
7b39239b81 | ||
|
|
369a94948a | ||
|
|
98a1ba7d10 | ||
|
|
5cf2ffe201 | ||
|
|
cb8db6c7a2 | ||
|
|
f4aa444cd8 | ||
|
|
da419a7785 | ||
|
|
00be33aa10 | ||
|
|
a0f0372e15 | ||
|
|
19d2dee5c9 | ||
|
|
cbf830a637 | ||
|
|
c8e25f3c21 | ||
|
|
fe161ba8a4 | ||
|
|
8571fc6996 | ||
|
|
f3fd003303 | ||
|
|
84346fdea7 | ||
|
|
abbe0480b6 | ||
|
|
178b8a4423 | ||
|
|
42102eb067 | ||
|
|
ced5e26420 | ||
|
|
f26cdab1f6 | ||
|
|
1eb4fe05fd | ||
|
|
6f976f6a2b | ||
|
|
5690e55d99 |
45 changed files with 1604 additions and 142 deletions
2
.github/workflows/build-and-release.yml
vendored
2
.github/workflows/build-and-release.yml
vendored
|
|
@ -144,8 +144,6 @@ jobs:
|
|||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org/"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
||||
|
||||
- name: Setup safe-chain
|
||||
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
|
|
|
|||
82
.github/workflows/bump-endpoint.yml
vendored
Normal file
82
.github/workflows/bump-endpoint.yml
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
name: Bump Device Protection Automatically
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
bump-endpoint:
|
||||
runs-on: open-source-releaser
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get latest safechain-internals release
|
||||
id: latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=$(gh api repos/AikidoSec/safechain-internals/releases/latest --jq '.tag_name')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get current version from install script
|
||||
id: current
|
||||
run: |
|
||||
CURRENT=$(grep -oP '(?<=releases/download/)[^/]+(?=/EndpointProtection\.pkg)' install-scripts/install-endpoint-mac.sh)
|
||||
echo "version=$CURRENT" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download assets and compute checksums
|
||||
if: steps.latest.outputs.version != steps.current.outputs.version
|
||||
id: checksums
|
||||
run: |
|
||||
VERSION="${{ steps.latest.outputs.version }}"
|
||||
BASE="https://github.com/AikidoSec/safechain-internals/releases/download/${VERSION}"
|
||||
curl -fsSL "${BASE}/EndpointProtection.pkg" -o /tmp/EndpointProtection.pkg
|
||||
curl -fsSL "${BASE}/EndpointProtection.msi" -o /tmp/EndpointProtection.msi
|
||||
echo "mac=$(sha256sum /tmp/EndpointProtection.pkg | cut -d' ' -f1)" >> $GITHUB_OUTPUT
|
||||
echo "win=$(sha256sum /tmp/EndpointProtection.msi | cut -d' ' -f1)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update install scripts
|
||||
if: steps.latest.outputs.version != steps.current.outputs.version
|
||||
run: |
|
||||
NEW="${{ steps.latest.outputs.version }}"
|
||||
OLD="${{ steps.current.outputs.version }}"
|
||||
MAC_SHA="${{ steps.checksums.outputs.mac }}"
|
||||
WIN_SHA="${{ steps.checksums.outputs.win }}"
|
||||
|
||||
sed -i "s|${OLD}/EndpointProtection.pkg|${NEW}/EndpointProtection.pkg|" install-scripts/install-endpoint-mac.sh
|
||||
sed -i "s|^DOWNLOAD_SHA256=\"[^\"]*\"|DOWNLOAD_SHA256=\"${MAC_SHA}\"|" install-scripts/install-endpoint-mac.sh
|
||||
|
||||
sed -i "s|${OLD}/EndpointProtection.msi|${NEW}/EndpointProtection.msi|" install-scripts/install-endpoint-windows.ps1
|
||||
sed -i 's|^\$DownloadSha256 = "[^"]*"|\$DownloadSha256 = "'"${WIN_SHA}"'"|' install-scripts/install-endpoint-windows.ps1
|
||||
|
||||
- name: Open PR
|
||||
if: steps.latest.outputs.version != steps.current.outputs.version
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
run: |
|
||||
NEW="${{ steps.latest.outputs.version }}"
|
||||
OLD="${{ steps.current.outputs.version }}"
|
||||
BRANCH="bump/endpoint-${NEW}"
|
||||
|
||||
if git ls-remote --exit-code --heads origin "$BRANCH" &>/dev/null; then
|
||||
echo "Branch $BRANCH already exists, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$BRANCH"
|
||||
git add install-scripts/install-endpoint-mac.sh install-scripts/install-endpoint-windows.ps1
|
||||
git commit -m "Bump Endpoint to ${NEW}"
|
||||
git push origin "$BRANCH"
|
||||
PR_URL="https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1"
|
||||
|
||||
curl -s -X POST "$SLACK_WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"text\": \"update to ${NEW} - ${PR_URL}\"}"
|
||||
4
.github/workflows/test-on-pr.yml
vendored
4
.github/workflows/test-on-pr.yml
vendored
|
|
@ -77,7 +77,7 @@ jobs:
|
|||
- node_version: "20"
|
||||
npm_version: "9.0.0"
|
||||
yarn_version: "latest"
|
||||
pnpm_version: "latest"
|
||||
pnpm_version: "10.0.0"
|
||||
# Version pinning scenario
|
||||
- node_version: "22"
|
||||
npm_version: "10.2.0"
|
||||
|
|
@ -87,7 +87,7 @@ jobs:
|
|||
- node_version: "18"
|
||||
npm_version: "latest"
|
||||
yarn_version: "latest"
|
||||
pnpm_version: "latest"
|
||||
pnpm_version: "10.0.0"
|
||||
# Future compatibility (becomes LTS October 2025)
|
||||
- node_version: "24"
|
||||
npm_version: "latest"
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -25,6 +25,8 @@ Aikido Safe Chain supports the following package managers:
|
|||
- 📦 **yarn**
|
||||
- 📦 **pnpm**
|
||||
- 📦 **pnpx**
|
||||
- 📦 **rush**
|
||||
- 📦 **rushx**
|
||||
- 📦 **bun**
|
||||
- 📦 **bunx**
|
||||
- 📦 **pip**
|
||||
|
|
@ -33,6 +35,7 @@ Aikido Safe Chain supports the following package managers:
|
|||
- 📦 **poetry**
|
||||
- 📦 **uvx**
|
||||
- 📦 **pipx**
|
||||
- 📦 **pdm**
|
||||
|
||||
# Usage
|
||||
|
||||
|
|
@ -75,7 +78,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
|
|||
### 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, pip, pip3, poetry, uv, uvx and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available.
|
||||
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, poetry, uv, uvx, pipx and pdm are loaded correctly. If you do not restart your terminal, the aliases will not be available.
|
||||
|
||||
2. **Verify the installation** by running the verification command:
|
||||
|
||||
|
|
@ -106,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.
|
||||
|
||||
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
|
||||
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx` and `pdm` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
|
||||
|
||||
You can check the installed version by running:
|
||||
|
||||
|
|
@ -118,7 +121,7 @@ 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, 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
|
||||
|
||||
|
|
@ -137,7 +140,7 @@ By default, the minimum package age is 48 hours. This provides an additional sec
|
|||
|
||||
### 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**
|
||||
- ✅ **Zsh**
|
||||
|
|
@ -290,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
|
||||
|
||||
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.
|
||||
|
|
@ -542,4 +551,16 @@ npm-ci:
|
|||
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 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
|
||||
|
||||
|
|
@ -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`, `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
|
||||
|
||||
❗ 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`, `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
|
||||
|
||||
### 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -4,49 +4,38 @@ This guide helps you diagnose and resolve common issues with Aikido Safe Chain.
|
|||
|
||||
## Verification & Diagnostics
|
||||
|
||||
### Check Installation
|
||||
**Check Installation**
|
||||
|
||||
```bash
|
||||
# Check version
|
||||
safe-chain --version
|
||||
```
|
||||
|
||||
### Verify Shell Integration
|
||||
**Verify Shell Integration**
|
||||
|
||||
Run the verification command for your package manager:
|
||||
|
||||
```bash
|
||||
npm 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!`
|
||||
```
|
||||
|
||||
### Test Malware Blocking
|
||||
**Test Malware Blocking**
|
||||
|
||||
Verify that malware detection is working:
|
||||
|
||||
**For JavaScript/Node.js:**
|
||||
|
||||
```bash
|
||||
npm install safe-chain-test
|
||||
```
|
||||
|
||||
**For Python:**
|
||||
|
||||
```bash
|
||||
pip3 install safe-chain-pi-test
|
||||
npm install safe-chain-test
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
|
|
@ -74,41 +63,39 @@ 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.
|
||||
|
||||
**Resolution Steps:**
|
||||
**Resolution Steps**
|
||||
|
||||
1. **Clear your package manager's cache:**
|
||||
1) Clear your package manager's cache
|
||||
|
||||
```bash
|
||||
# For npm
|
||||
npm cache clean --force
|
||||
```bash
|
||||
# For npm
|
||||
npm cache clean --force
|
||||
|
||||
# For pnpm
|
||||
pnpm store prune
|
||||
# For pnpm
|
||||
pnpm store prune
|
||||
|
||||
# For yarn (classic)
|
||||
yarn cache clean
|
||||
# For yarn (classic)
|
||||
yarn cache clean
|
||||
|
||||
# For yarn (berry/v2+)
|
||||
yarn cache clean --all
|
||||
# For yarn (berry/v2+)
|
||||
yarn cache clean --all
|
||||
|
||||
# For bun
|
||||
bun pm cache rm
|
||||
```
|
||||
# For bun
|
||||
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
|
||||
# Remove node_modules if you want a completely fresh install
|
||||
rm -rf node_modules
|
||||
```
|
||||
|
||||
```bash
|
||||
# Remove node_modules if you want a completely fresh install
|
||||
rm -rf node_modules
|
||||
```
|
||||
3) Re-test malware blocking:
|
||||
|
||||
3. **Re-test malware blocking:**
|
||||
|
||||
```bash
|
||||
npm install safe-chain-test # Should be blocked
|
||||
```
|
||||
```bash
|
||||
npm install safe-chain-test # Should be blocked
|
||||
```
|
||||
|
||||
### Shell Aliases Not Working After Installation
|
||||
|
||||
|
|
@ -128,10 +115,10 @@ Should show: `npm is a function`
|
|||
|
||||
Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`:
|
||||
|
||||
- Bash: `~/.bashrc`
|
||||
- Zsh: `~/.zshrc`
|
||||
- Fish: `~/.config/fish/config.fish`
|
||||
- PowerShell: `$PROFILE`
|
||||
* Bash: `~/.bashrc`
|
||||
* Zsh: `~/.zshrc`
|
||||
* Fish: `~/.config/fish/config.fish`
|
||||
* PowerShell: `$PROFILE`
|
||||
|
||||
### "Command Not Found: safe-chain"
|
||||
|
||||
|
|
@ -162,37 +149,39 @@ 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.
|
||||
|
||||
**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:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned
|
||||
```
|
||||
```powershell
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned
|
||||
```
|
||||
|
||||
This allows:
|
||||
- Local scripts (like safe-chain's) to run without signing
|
||||
- Downloaded scripts to run only if signed by a trusted publisher
|
||||
This allows:
|
||||
|
||||
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
|
||||
|
||||
**Symptom:** safe-chain commands still active after running uninstall script
|
||||
|
||||
**Steps:**
|
||||
**Steps**
|
||||
|
||||
1. Run `safe-chain teardown` (if binary still exists)
|
||||
2. Restart your terminal
|
||||
3. If still present, manually edit shell config files:
|
||||
- Bash: `~/.bashrc`
|
||||
- Zsh: `~/.zshrc`
|
||||
- Fish: `~/.config/fish/config.fish`
|
||||
- PowerShell: `$PROFILE`
|
||||
* Bash: `~/.bashrc`
|
||||
* Zsh: `~/.zshrc`
|
||||
* Fish: `~/.config/fish/config.fish`
|
||||
* PowerShell: `$PROFILE`
|
||||
4. Remove lines that source scripts from `~/.safe-chain/scripts/`
|
||||
5. Restart terminal again
|
||||
|
||||
|
|
@ -217,10 +206,10 @@ type pip
|
|||
|
||||
**Expected `which` output:**
|
||||
|
||||
- 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
|
||||
* 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
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -259,23 +248,23 @@ for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do
|
|||
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.
|
||||
|
||||
### Remove npm Global Installation
|
||||
#### Remove npm Global Installation
|
||||
|
||||
```bash
|
||||
npm uninstall -g @aikidosec/safe-chain
|
||||
```
|
||||
|
||||
### Remove Volta Installation
|
||||
#### Remove Volta Installation
|
||||
|
||||
```bash
|
||||
volta uninstall @aikidosec/safe-chain
|
||||
```
|
||||
|
||||
### Remove nvm Installations (All Versions)
|
||||
#### Remove nvm Installations (All Versions)
|
||||
|
||||
```bash
|
||||
# Automated approach
|
||||
|
|
@ -288,34 +277,22 @@ nvm use <version>
|
|||
npm uninstall -g @aikidosec/safe-chain
|
||||
```
|
||||
|
||||
### Clean Shell Configuration Files
|
||||
#### Clean Shell Configuration Files
|
||||
|
||||
Manually remove safe-chain entries from:
|
||||
|
||||
- Bash: `~/.bashrc`
|
||||
- Zsh: `~/.zshrc`
|
||||
- Fish: `~/.config/fish/config.fish`
|
||||
- PowerShell: `$PROFILE`
|
||||
* Bash: `~/.bashrc`
|
||||
* Zsh: `~/.zshrc`
|
||||
* Fish: `~/.config/fish/config.fish`
|
||||
* PowerShell: `$PROFILE`
|
||||
|
||||
Look for and remove:
|
||||
|
||||
- Lines sourcing from `~/.safe-chain/scripts/`
|
||||
- Any safe-chain related function definitions
|
||||
* Lines sourcing from `~/.safe-chain/scripts/`
|
||||
* Any safe-chain related function definitions
|
||||
|
||||
### Remove Installation Directory
|
||||
#### Remove Installation Directory
|
||||
|
||||
```bash
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
set -e # Exit on error
|
||||
|
||||
# Configuration
|
||||
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.23/EndpointProtection.pkg"
|
||||
DOWNLOAD_SHA256="9af1e0f72e53516c888ade1753ed03f087c1def89244eb0afb60e1f11e8e87e2"
|
||||
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.pkg"
|
||||
DOWNLOAD_SHA256="ad800f9e476b0a75bf32b1c079f060ecb98bc16972a4e8cca29cf165388ea9fe"
|
||||
TOKEN_FILE="/tmp/aikido_endpoint_token.txt"
|
||||
|
||||
# Colors for output
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ param(
|
|||
)
|
||||
|
||||
# Configuration
|
||||
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.23/EndpointProtection.msi"
|
||||
$DownloadSha256 = "3327d35db6654d12dbd7c5ccec0645edb0277f71dcd993ba9733e266bbd235f8"
|
||||
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.msi"
|
||||
$DownloadSha256 = "e2750c59124f53456a8f9cdb9e81fd9ce2f2491869f68f01602444ad519be5be"
|
||||
|
||||
# Ensure TLS 1.2 is enabled for downloads
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
|
|
|||
4
npm-shrinkwrap.json
generated
4
npm-shrinkwrap.json
generated
|
|
@ -2417,7 +2417,6 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -3130,6 +3129,7 @@
|
|||
"aikido-bunx": "bin/aikido-bunx.js",
|
||||
"aikido-npm": "bin/aikido-npm.js",
|
||||
"aikido-npx": "bin/aikido-npx.js",
|
||||
"aikido-pdm": "bin/aikido-pdm.js",
|
||||
"aikido-pip": "bin/aikido-pip.js",
|
||||
"aikido-pip3": "bin/aikido-pip3.js",
|
||||
"aikido-pipx": "bin/aikido-pipx.js",
|
||||
|
|
@ -3138,6 +3138,8 @@
|
|||
"aikido-poetry": "bin/aikido-poetry.js",
|
||||
"aikido-python": "bin/aikido-python.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-uvx": "bin/aikido-uvx.js",
|
||||
"aikido-yarn": "bin/aikido-yarn.js",
|
||||
|
|
|
|||
13
packages/safe-chain/bin/aikido-pdm.js
Executable file
13
packages/safe-chain/bin/aikido-pdm.js
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
|
||||
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
initializePackageManager("pdm");
|
||||
|
||||
(async () => {
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
process.exit(exitCode);
|
||||
})();
|
||||
14
packages/safe-chain/bin/aikido-rush.js
Executable file
14
packages/safe-chain/bin/aikido-rush.js
Executable 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);
|
||||
})();
|
||||
14
packages/safe-chain/bin/aikido-rushx.js
Executable file
14
packages/safe-chain/bin/aikido-rushx.js
Executable 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);
|
||||
})();
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Strip PKG_EXECPATH from the environment so any child process safe-chain
|
||||
// spawns (npm, uv, pip, …) doesn't inherit it. If it leaks into a subsequent
|
||||
// safe-chain invocation (e.g. via a shim) the yao-pkg bootstrap would treat
|
||||
// argv[1] as a script path and fail with MODULE_NOT_FOUND.
|
||||
delete process.env.PKG_EXECPATH;
|
||||
|
||||
import chalk from "chalk";
|
||||
import { ui } from "../src/environment/userInteraction.js";
|
||||
import { setup } from "../src/shell-integration/setup.js";
|
||||
|
|
@ -15,7 +21,7 @@ import { main } from "../src/main.js";
|
|||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import fs from "fs";
|
||||
import { knownAikidoTools } from "../src/shell-integration/helpers.js";
|
||||
import { knownAikidoTools, getPackageManagerList } from "../src/shell-integration/helpers.js";
|
||||
import { getInstalledSafeChainDir } from "../src/installLocation.js";
|
||||
|
||||
/** @type {string} */
|
||||
|
|
@ -108,7 +114,7 @@ function writeHelp() {
|
|||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
"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 ${getPackageManagerList()}.`,
|
||||
);
|
||||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
"aikido-yarn": "bin/aikido-yarn.js",
|
||||
"aikido-pnpm": "bin/aikido-pnpm.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-bunx": "bin/aikido-bunx.js",
|
||||
"aikido-uv": "bin/aikido-uv.js",
|
||||
|
|
@ -23,6 +25,7 @@
|
|||
"aikido-python3": "bin/aikido-python3.js",
|
||||
"aikido-poetry": "bin/aikido-poetry.js",
|
||||
"aikido-pipx": "bin/aikido-pipx.js",
|
||||
"aikido-pdm": "bin/aikido-pdm.js",
|
||||
"safe-chain": "bin/safe-chain.js"
|
||||
},
|
||||
"type": "module",
|
||||
|
|
@ -37,7 +40,7 @@
|
|||
"keywords": [],
|
||||
"author": "Aikido Security",
|
||||
"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), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, uvx, or pip/pip3 from downloading or running the malware.",
|
||||
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [rush](https://rushjs.io/), [rushx](https://rushjs.io/pages/commands/rushx/), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), [pip](https://pip.pypa.io/), and [pdm](https://pdm-project.org/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, uv, uvx, pip/pip3, or pdm from downloading or running the malware.",
|
||||
"dependencies": {
|
||||
"certifi": "14.5.15",
|
||||
"chalk": "5.4.1",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ import { createPipPackageManager } from "./pip/createPackageManager.js";
|
|||
import { createUvPackageManager } from "./uv/createUvPackageManager.js";
|
||||
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
|
||||
import { createPipXPackageManager } from "./pipx/createPipXPackageManager.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";
|
||||
|
||||
/**
|
||||
|
|
@ -67,6 +70,12 @@ export function initializePackageManager(packageManagerName, context) {
|
|||
state.packageManagerName = createPoetryPackageManager();
|
||||
} else if (packageManagerName === "pipx") {
|
||||
state.packageManagerName = createPipXPackageManager();
|
||||
} else if (packageManagerName === "pdm") {
|
||||
state.packageManagerName = createPdmPackageManager();
|
||||
} else if (packageManagerName === "rush") {
|
||||
state.packageManagerName = createRushPackageManager();
|
||||
} else if (packageManagerName === "rushx") {
|
||||
state.packageManagerName = createRushxPackageManager();
|
||||
} else {
|
||||
throw new Error("Unsupported package manager: " + packageManagerName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
|
||||
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
||||
|
||||
/**
|
||||
* @returns {import("../currentPackageManager.js").PackageManager}
|
||||
*/
|
||||
export function createPdmPackageManager() {
|
||||
return {
|
||||
runCommand: (args) => runPdmCommand(args),
|
||||
|
||||
// MITM only approach for PDM
|
||||
isSupportedCommand: () => false,
|
||||
getDependencyUpdatesForCommand: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets CA bundle environment variables used by PDM and Python libraries.
|
||||
* PDM uses httpx (via unearth) which respects SSL_CERT_FILE through Python's ssl module.
|
||||
*
|
||||
* @param {NodeJS.ProcessEnv} env - Environment object to modify
|
||||
* @param {string} combinedCaPath - Path to the combined CA bundle
|
||||
*/
|
||||
function setPdmCaBundleEnvironmentVariables(env, combinedCaPath) {
|
||||
// SSL_CERT_FILE: Used by Python SSL libraries and httpx (which PDM uses)
|
||||
if (env.SSL_CERT_FILE) {
|
||||
ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.");
|
||||
}
|
||||
env.SSL_CERT_FILE = combinedCaPath;
|
||||
|
||||
// REQUESTS_CA_BUNDLE: Used by the requests library (PDM plugins may use it)
|
||||
if (env.REQUESTS_CA_BUNDLE) {
|
||||
ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.");
|
||||
}
|
||||
env.REQUESTS_CA_BUNDLE = combinedCaPath;
|
||||
|
||||
// PIP_CERT: PDM may use pip internally
|
||||
if (env.PIP_CERT) {
|
||||
ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.");
|
||||
}
|
||||
env.PIP_CERT = combinedCaPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a pdm command with safe-chain's certificate bundle and proxy configuration.
|
||||
*
|
||||
* PDM respects standard HTTP_PROXY/HTTPS_PROXY environment variables through
|
||||
* httpx which it uses for package downloads.
|
||||
*
|
||||
* @param {string[]} args - Command line arguments to pass to pdm
|
||||
* @returns {Promise<{status: number}>} Exit status of the pdm command
|
||||
*/
|
||||
async function runPdmCommand(args) {
|
||||
try {
|
||||
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
|
||||
|
||||
const combinedCaPath = getCombinedCaBundlePath();
|
||||
setPdmCaBundleEnvironmentVariables(env, combinedCaPath);
|
||||
|
||||
const result = await safeSpawn("pdm", args, {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
});
|
||||
|
||||
return { status: result.status };
|
||||
} catch (/** @type any */ error) {
|
||||
return reportCommandExecutionFailure(error, "pdm");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { createPdmPackageManager } from "./createPdmPackageManager.js";
|
||||
|
||||
test("createPdmPackageManager", async (t) => {
|
||||
await t.test("should create package manager with required interface", () => {
|
||||
const pm = createPdmPackageManager();
|
||||
|
||||
assert.ok(pm);
|
||||
assert.strictEqual(typeof pm.runCommand, "function");
|
||||
assert.strictEqual(typeof pm.isSupportedCommand, "function");
|
||||
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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: () => [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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(), []);
|
||||
});
|
||||
|
|
@ -42,7 +42,7 @@ function getSafeChainProxyEnvironmentVariables() {
|
|||
return {};
|
||||
}
|
||||
|
||||
const proxyUrl = `http://localhost:${state.port}`;
|
||||
const proxyUrl = `http://127.0.0.1:${state.port}`;
|
||||
const caCertPath = getCombinedCaBundlePath();
|
||||
|
||||
return {
|
||||
|
|
@ -95,8 +95,11 @@ function createProxyServer() {
|
|||
*/
|
||||
function startServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Passing port 0 makes the OS assign an available port
|
||||
server.listen(0, () => {
|
||||
// Bind to loopback only. Without an explicit host, Node listens on every
|
||||
// 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();
|
||||
if (address && typeof address === "object") {
|
||||
state.port = address.port;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -48,6 +48,18 @@ export const knownAikidoTools = [
|
|||
ecoSystem: ECOSYSTEM_JS,
|
||||
internalPackageManagerName: "pnpx",
|
||||
},
|
||||
{
|
||||
tool: "rush",
|
||||
aikidoCommand: "aikido-rush",
|
||||
ecoSystem: ECOSYSTEM_JS,
|
||||
internalPackageManagerName: "rush",
|
||||
},
|
||||
{
|
||||
tool: "rushx",
|
||||
aikidoCommand: "aikido-rushx",
|
||||
ecoSystem: ECOSYSTEM_JS,
|
||||
internalPackageManagerName: "rushx",
|
||||
},
|
||||
{
|
||||
tool: "bun",
|
||||
aikidoCommand: "aikido-bun",
|
||||
|
|
@ -108,6 +120,12 @@ export const knownAikidoTools = [
|
|||
ecoSystem: ECOSYSTEM_PY,
|
||||
internalPackageManagerName: "pipx",
|
||||
},
|
||||
{
|
||||
tool: "pdm",
|
||||
aikidoCommand: "aikido-pdm",
|
||||
ecoSystem: ECOSYSTEM_PY,
|
||||
internalPackageManagerName: "pdm",
|
||||
},
|
||||
// When adding a new tool here, also update the documentation for the new tool in the README.md
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ remove_shim_from_path() {
|
|||
}
|
||||
|
||||
if command -v safe-chain >/dev/null 2>&1; then
|
||||
# Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops
|
||||
# Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops.
|
||||
# Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't
|
||||
# mistake argv[1] for a script path and try to resolve "{{PACKAGE_MANAGER}}" against cwd.
|
||||
unset PKG_EXECPATH
|
||||
PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@"
|
||||
else
|
||||
# safe-chain is not reachable — warn the user so they know protection is inactive
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, "..", "..");
|
||||
|
||||
describe("PKG_EXECPATH cleanup", () => {
|
||||
it("unix shim template unsets PKG_EXECPATH before invoking safe-chain", () => {
|
||||
const file = path.join(
|
||||
repoRoot,
|
||||
"src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh",
|
||||
);
|
||||
const content = fs.readFileSync(file, "utf-8");
|
||||
assert.match(
|
||||
content,
|
||||
/unset PKG_EXECPATH[\s\S]*exec safe-chain/,
|
||||
"unix-wrapper.template.sh must `unset PKG_EXECPATH` before `exec safe-chain`",
|
||||
);
|
||||
});
|
||||
|
||||
it("posix shell function unsets PKG_EXECPATH before invoking safe-chain", () => {
|
||||
const file = path.join(
|
||||
repoRoot,
|
||||
"src/shell-integration/startup-scripts/init-posix.sh",
|
||||
);
|
||||
const content = fs.readFileSync(file, "utf-8");
|
||||
// Scoped subshell so we don't mutate the user's interactive env.
|
||||
assert.match(
|
||||
content,
|
||||
/\(unset PKG_EXECPATH;\s*safe-chain "\$@"\)/,
|
||||
"init-posix.sh must invoke safe-chain in a subshell that unsets PKG_EXECPATH",
|
||||
);
|
||||
});
|
||||
|
||||
it("fish shell function unsets PKG_EXECPATH before invoking safe-chain", () => {
|
||||
const file = path.join(
|
||||
repoRoot,
|
||||
"src/shell-integration/startup-scripts/init-fish.fish",
|
||||
);
|
||||
const content = fs.readFileSync(file, "utf-8");
|
||||
assert.match(
|
||||
content,
|
||||
/env -u PKG_EXECPATH safe-chain/,
|
||||
"init-fish.fish must invoke safe-chain via `env -u PKG_EXECPATH`",
|
||||
);
|
||||
});
|
||||
|
||||
it("safe-chain entry point deletes PKG_EXECPATH from process.env", () => {
|
||||
const file = path.join(repoRoot, "bin/safe-chain.js");
|
||||
const content = fs.readFileSync(file, "utf-8");
|
||||
assert.match(
|
||||
content,
|
||||
/delete process\.env\.PKG_EXECPATH/,
|
||||
"bin/safe-chain.js must delete process.env.PKG_EXECPATH so spawned children don't inherit it",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,6 +19,14 @@ function pnpx
|
|||
wrapSafeChainCommand "pnpx" $argv
|
||||
end
|
||||
|
||||
function rush
|
||||
wrapSafeChainCommand "rush" $argv
|
||||
end
|
||||
|
||||
function rushx
|
||||
wrapSafeChainCommand "rushx" $argv
|
||||
end
|
||||
|
||||
function bun
|
||||
wrapSafeChainCommand "bun" $argv
|
||||
end
|
||||
|
|
@ -76,6 +84,10 @@ function pipx
|
|||
wrapSafeChainCommand "pipx" $argv
|
||||
end
|
||||
|
||||
function pdm
|
||||
wrapSafeChainCommand "pdm" $argv
|
||||
end
|
||||
|
||||
function printSafeChainWarning
|
||||
set original_cmd $argv[1]
|
||||
|
||||
|
|
@ -112,8 +124,10 @@ function wrapSafeChainCommand
|
|||
end
|
||||
|
||||
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
|
||||
# If the safe-chain command is available, just run it with the provided arguments.
|
||||
# Unset PKG_EXECPATH for this invocation so the yao-pkg bootstrap inside the
|
||||
# safe-chain binary doesn't mistake argv[1] for a script path to resolve against cwd.
|
||||
env -u PKG_EXECPATH safe-chain $original_cmd $cmd_args
|
||||
else
|
||||
# If the safe-chain command is not available, print a warning and run the original command
|
||||
printSafeChainWarning $original_cmd
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ function pnpx() {
|
|||
wrapSafeChainCommand "pnpx" "$@"
|
||||
}
|
||||
|
||||
function rush() {
|
||||
wrapSafeChainCommand "rush" "$@"
|
||||
}
|
||||
|
||||
function rushx() {
|
||||
wrapSafeChainCommand "rushx" "$@"
|
||||
}
|
||||
|
||||
function bun() {
|
||||
wrapSafeChainCommand "bun" "$@"
|
||||
}
|
||||
|
|
@ -81,6 +89,10 @@ function pipx() {
|
|||
wrapSafeChainCommand "pipx" "$@"
|
||||
}
|
||||
|
||||
function pdm() {
|
||||
wrapSafeChainCommand "pdm" "$@"
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -101,8 +113,10 @@ function wrapSafeChainCommand() {
|
|||
fi
|
||||
|
||||
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 "$@"
|
||||
# If the aikido command is available, just run it with the provided arguments.
|
||||
# Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't
|
||||
# mistake argv[1] for a script path and try to resolve it against cwd.
|
||||
(unset PKG_EXECPATH; safe-chain "$@")
|
||||
else
|
||||
# If the aikido command is not available, print a warning and run the original command
|
||||
printSafeChainWarning "$original_cmd"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ function pnpx {
|
|||
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 {
|
||||
Invoke-WrappedCommand "bun" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||
}
|
||||
|
|
@ -75,6 +83,10 @@ function pipx {
|
|||
Invoke-WrappedCommand "pipx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||
}
|
||||
|
||||
function pdm {
|
||||
Invoke-WrappedCommand "pdm" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||
}
|
||||
|
||||
function Write-SafeChainWarning {
|
||||
param([string]$Command)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ ARG NODE_VERSION=latest
|
|||
ARG NPM_VERSION=latest
|
||||
ARG YARN_VERSION=latest
|
||||
ARG PNPM_VERSION=latest
|
||||
ARG RUSH_VERSION=latest
|
||||
ARG PYTHON_VERSION=3
|
||||
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
|
@ -46,6 +47,7 @@ RUN volta install node@${NODE_VERSION}
|
|||
RUN volta install npm@${NPM_VERSION}
|
||||
RUN volta install yarn@${YARN_VERSION}
|
||||
RUN volta install pnpm@${PNPM_VERSION}
|
||||
RUN volta install @microsoft/rush@${RUSH_VERSION}
|
||||
|
||||
# Install Bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
|
|
@ -77,6 +79,10 @@ RUN apt-get update && apt-get install -y pipx && \
|
|||
pipx install poetry && \
|
||||
ln -sf /root/.local/bin/poetry /usr/local/bin/poetry
|
||||
|
||||
# Install PDM
|
||||
RUN pipx install pdm && \
|
||||
ln -sf /root/.local/bin/pdm /usr/local/bin/pdm
|
||||
|
||||
# Copy and install Safe chain
|
||||
COPY --from=builder /app/*.tgz /pkgs/
|
||||
RUN npm install -g /pkgs/*.tgz
|
||||
|
|
|
|||
|
|
@ -46,8 +46,9 @@ describe("E2E: bun coverage", () => {
|
|||
|
||||
var result = await shell.runCommand("bun install");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked 1 malicious package downloads"),
|
||||
assert.match(
|
||||
result.output,
|
||||
/blocked [1-9]\d* malicious package downloads/,
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
|
|
@ -65,8 +66,9 @@ describe("E2E: bun coverage", () => {
|
|||
|
||||
const result = await shell.runCommand("bunx safe-chain-test");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked 1 malicious package downloads"),
|
||||
assert.match(
|
||||
result.output,
|
||||
/blocked [1-9]\d* malicious package downloads/,
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
|
|
|
|||
|
|
@ -70,8 +70,9 @@ describe("E2E: npm coverage", () => {
|
|||
|
||||
var result = await shell.runCommand("npm install");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked 1 malicious package downloads"),
|
||||
assert.match(
|
||||
result.output,
|
||||
/blocked [1-9]\d* malicious package downloads/,
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
|
|
|
|||
300
test/e2e/pdm.e2e.spec.js
Normal file
300
test/e2e/pdm.e2e.spec.js
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("E2E: pdm coverage", () => {
|
||||
let container;
|
||||
|
||||
before(async () => {
|
||||
DockerTestContainer.buildImage();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Run a new Docker container for each test
|
||||
container = new DockerTestContainer();
|
||||
await container.start();
|
||||
|
||||
const installationShell = await container.openShell("zsh");
|
||||
await installationShell.runCommand("safe-chain setup");
|
||||
|
||||
// Clear pdm cache
|
||||
await installationShell.runCommand("command pdm cache clear");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Stop and clean up the container after each test
|
||||
if (container) {
|
||||
await container.stop();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
it(`successfully installs known safe packages with pdm add`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Initialize a new pdm project
|
||||
await shell.runCommand("mkdir /tmp/test-pdm-project && cd /tmp/test-pdm-project");
|
||||
await shell.runCommand("cd /tmp/test-pdm-project && pdm init --non-interactive");
|
||||
|
||||
// Add a safe package
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-pdm-project && pdm add requests"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found.") || result.output.includes("Installing"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pdm add with specific version`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("mkdir /tmp/test-pdm-version && cd /tmp/test-pdm-version");
|
||||
await shell.runCommand("cd /tmp/test-pdm-version && pdm init --non-interactive");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-pdm-version && pdm add requests==2.32.3"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found.") || result.output.includes("Installing"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`safe-chain blocks installation of malicious Python packages via pdm`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("mkdir /tmp/test-pdm-malware && cd /tmp/test-pdm-malware");
|
||||
await shell.runCommand("cd /tmp/test-pdm-malware && pdm init --non-interactive");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-pdm-malware && pdm add numpy==2.4.4"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked") && result.output.includes("malicious package downloads"),
|
||||
`Expected malware to be blocked. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output.includes("Exiting without installing malicious packages."),
|
||||
`Expected exit message. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pdm install installs dependencies from pyproject.toml`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("mkdir /tmp/test-pdm-install && cd /tmp/test-pdm-install");
|
||||
await shell.runCommand("cd /tmp/test-pdm-install && pdm init --non-interactive");
|
||||
await shell.runCommand("cd /tmp/test-pdm-install && pdm add requests");
|
||||
|
||||
// Now remove the virtualenv and run install
|
||||
await shell.runCommand("cd /tmp/test-pdm-install && rm -rf .venv");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-pdm-install && pdm install"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found.") || result.output.includes("Installing"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pdm update with specific packages`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("mkdir /tmp/test-pdm-update-specific && cd /tmp/test-pdm-update-specific");
|
||||
await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm init --non-interactive");
|
||||
await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm add requests certifi");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-pdm-update-specific && pdm update requests"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found.") || result.output.includes("Updating"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pdm add with multiple packages`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("mkdir /tmp/test-pdm-multi && cd /tmp/test-pdm-multi");
|
||||
await shell.runCommand("cd /tmp/test-pdm-multi && pdm init --non-interactive");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-pdm-multi && pdm add requests certifi"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found.") || result.output.includes("Installing"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pdm add with extras`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("mkdir /tmp/test-pdm-extras && cd /tmp/test-pdm-extras");
|
||||
await shell.runCommand("cd /tmp/test-pdm-extras && pdm init --non-interactive");
|
||||
|
||||
// Use quotes to prevent shell expansion of square brackets
|
||||
const result = await shell.runCommand(
|
||||
'cd /tmp/test-pdm-extras && pdm add "requests[security]"'
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found.") || result.output.includes("Installing"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pdm add with development group`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("mkdir /tmp/test-pdm-dev && cd /tmp/test-pdm-dev");
|
||||
await shell.runCommand("cd /tmp/test-pdm-dev && pdm init --non-interactive");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-pdm-dev && pdm add -dG dev pytest"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found.") || result.output.includes("Installing"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pdm lock creates/updates lock file`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("mkdir /tmp/test-pdm-lock && cd /tmp/test-pdm-lock");
|
||||
await shell.runCommand("cd /tmp/test-pdm-lock && pdm init --non-interactive");
|
||||
await shell.runCommand("cd /tmp/test-pdm-lock && pdm add requests");
|
||||
await shell.runCommand("cd /tmp/test-pdm-lock && rm pdm.lock");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-pdm-lock && pdm lock"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found.") || result.output.includes("Resolving") || result.output.includes("lock file"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pdm remove does not download packages`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("mkdir /tmp/test-pdm-remove && cd /tmp/test-pdm-remove");
|
||||
await shell.runCommand("cd /tmp/test-pdm-remove && pdm init --non-interactive");
|
||||
await shell.runCommand("cd /tmp/test-pdm-remove && pdm add requests");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-pdm-remove && pdm remove requests"
|
||||
);
|
||||
|
||||
// Remove should succeed - it doesn't download packages, just modifies pyproject.toml
|
||||
assert.ok(
|
||||
!result.output.includes("blocked"),
|
||||
`Remove command should not trigger downloads. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`blocks malware during pdm install`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Create a project with malware in dependencies
|
||||
await shell.runCommand("mkdir /tmp/test-pdm-install-malware && cd /tmp/test-pdm-install-malware");
|
||||
await shell.runCommand("cd /tmp/test-pdm-install-malware && pdm init --non-interactive");
|
||||
|
||||
// Add malware package - this will create lock file and attempt download
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-pdm-install-malware && pdm add numpy==2.4.4 2>&1"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked") && result.output.includes("malicious package downloads"),
|
||||
`Expected malware to be blocked during add (which triggers install). Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output.includes("Exiting without installing malicious packages."),
|
||||
`Expected exit message. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`blocks malware when adding malicious dependency alongside safe one`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("mkdir /tmp/test-pdm-batch && cd /tmp/test-pdm-batch");
|
||||
await shell.runCommand("cd /tmp/test-pdm-batch && pdm init --non-interactive");
|
||||
|
||||
// Try to add malware alongside safe package
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-pdm-batch && pdm add numpy==2.4.4 requests 2>&1"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked") && result.output.includes("malicious package downloads"),
|
||||
`Expected malware to be blocked. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output.includes("Exiting without installing malicious packages."),
|
||||
`Expected exit message. Output was:\n${result.output}`
|
||||
);
|
||||
|
||||
// Verify safe package was also not installed due to malware in batch
|
||||
const listResult = await shell.runCommand("cd /tmp/test-pdm-batch && pdm list");
|
||||
assert.ok(
|
||||
!listResult.output.includes("requests"),
|
||||
`Safe package should not be installed when batch includes malware. Output was:\n${listResult.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pdm non-network commands work correctly`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("mkdir /tmp/test-pdm-nonnetwork && cd /tmp/test-pdm-nonnetwork");
|
||||
await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm init --non-interactive");
|
||||
await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm add requests");
|
||||
|
||||
// Test pdm --version
|
||||
const versionResult = await shell.runCommand("pdm --version");
|
||||
assert.ok(
|
||||
versionResult.output.includes("PDM") || versionResult.output.includes("pdm"),
|
||||
`Expected version output. Output was:\n${versionResult.output}`
|
||||
);
|
||||
|
||||
// Test pdm list (list installed packages)
|
||||
const listResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm list");
|
||||
assert.ok(
|
||||
listResult.output.includes("requests"),
|
||||
`Expected to see installed package. Output was:\n${listResult.output}`
|
||||
);
|
||||
|
||||
// Test pdm info (show project info)
|
||||
const infoResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm info");
|
||||
assert.ok(
|
||||
infoResult.output.includes("PDM") || infoResult.output.includes("Python") || infoResult.output.includes("Project"),
|
||||
`Expected project info. Output was:\n${infoResult.output}`
|
||||
);
|
||||
|
||||
// Test pdm config (show configuration)
|
||||
const configResult = await shell.runCommand("pdm config");
|
||||
assert.ok(
|
||||
configResult.output.length > 0,
|
||||
`Expected configuration output. Output was:\n${configResult.output}`
|
||||
);
|
||||
|
||||
// Test pdm run (execute command in virtualenv) - non-network command
|
||||
const runResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm run python --version");
|
||||
assert.ok(
|
||||
runResult.output.includes("Python"),
|
||||
`Expected Python version output. Output was:\n${runResult.output}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -131,8 +131,9 @@ describe("E2E: pip coverage", () => {
|
|||
"pip3 install --break-system-packages numpy==2.4.4 --safe-chain-logging=verbose"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked 1 malicious package downloads:"),
|
||||
assert.match(
|
||||
result.output,
|
||||
/blocked [1-9]\d* malicious package downloads:/,
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
|
|
|
|||
|
|
@ -70,8 +70,9 @@ describe("E2E: pnpm coverage", () => {
|
|||
|
||||
var result = await shell.runCommand("pnpm install");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked 1 malicious package downloads"),
|
||||
assert.match(
|
||||
result.output,
|
||||
/blocked [1-9]\d* malicious package downloads/,
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
|
|
|
|||
148
test/e2e/rush.e2e.spec.js
Normal file
148
test/e2e/rush.e2e.spec.js
Normal 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 [1-9]\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
100
test/e2e/rushx.e2e.spec.js
Normal 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 [1-9]\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"
|
||||
}
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
|
@ -100,8 +100,9 @@ describe("E2E: safe-chain CLI python/pip support", () => {
|
|||
"safe-chain pip3 install --break-system-packages numpy==2.4.4"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked 1 malicious package downloads"),
|
||||
assert.match(
|
||||
result.output,
|
||||
/blocked [1-9]\d* malicious package downloads/,
|
||||
`Should have blocked malware. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
|
|
|||
69
test/e2e/utils/rushtestutils.mjs
Normal file
69
test/e2e/utils/rushtestutils.mjs
Normal 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];
|
||||
}
|
||||
|
|
@ -129,8 +129,9 @@ describe("E2E: uv coverage", () => {
|
|||
"uv pip install --system --break-system-packages numpy==2.4.4"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked 1 malicious package downloads:"),
|
||||
assert.match(
|
||||
result.output,
|
||||
/blocked [1-9]\d* malicious package downloads:/,
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
|
|
@ -416,8 +417,9 @@ describe("E2E: uv coverage", () => {
|
|||
"cd test-project-malware && uv add numpy==2.4.4"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked 1 malicious package downloads:"),
|
||||
assert.match(
|
||||
result.output,
|
||||
/blocked [1-9]\d* malicious package downloads:/,
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
|
|
@ -447,8 +449,9 @@ describe("E2E: uv coverage", () => {
|
|||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand("uv tool install numpy==2.4.4");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked 1 malicious package downloads:"),
|
||||
assert.match(
|
||||
result.output,
|
||||
/blocked [1-9]\d* malicious package downloads:/,
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
|
|
@ -485,8 +488,9 @@ describe("E2E: uv coverage", () => {
|
|||
"uv run --with numpy==2.4.4 test_script2.py"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked 1 malicious package downloads:"),
|
||||
assert.match(
|
||||
result.output,
|
||||
/blocked [1-9]\d* malicious package downloads:/,
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,8 +70,9 @@ describe("E2E: yarn coverage", () => {
|
|||
|
||||
var result = await shell.runCommand("yarn");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked 1 malicious package downloads"),
|
||||
assert.match(
|
||||
result.output,
|
||||
/blocked [1-9]\d* malicious package downloads/,
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue