mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge remote-tracking branch 'origin/main' into pip-custom-registries
This commit is contained in:
commit
39e2001d97
58 changed files with 2760 additions and 702 deletions
42
.github/workflows/build-and-release.yml
vendored
42
.github/workflows/build-and-release.yml
vendored
|
|
@ -44,9 +44,7 @@ jobs:
|
|||
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
||||
|
||||
- name: Setup safe-chain
|
||||
run: |
|
||||
npm i -g @aikidosec/safe-chain
|
||||
safe-chain setup-ci
|
||||
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
|
||||
- name: Set the version in safe-chain package
|
||||
run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain
|
||||
|
|
@ -77,21 +75,35 @@ jobs:
|
|||
|
||||
- name: Rename binaries to include platform and architecture
|
||||
run: |
|
||||
mv binaries/safe-chain-macos-x64/safe-chain binaries/safe-chain-macos-x64/safe-chain-macos-x64
|
||||
mv binaries/safe-chain-macos-arm64/safe-chain binaries/safe-chain-macos-arm64/safe-chain-macos-arm64
|
||||
mv binaries/safe-chain-linux-x64/safe-chain binaries/safe-chain-linux-x64/safe-chain-linux-x64
|
||||
mv binaries/safe-chain-linux-arm64/safe-chain binaries/safe-chain-linux-arm64/safe-chain-linux-arm64
|
||||
mv binaries/safe-chain-win-x64/safe-chain.exe binaries/safe-chain-win-x64/safe-chain-win-x64.exe
|
||||
mv binaries/safe-chain-win-arm64/safe-chain.exe binaries/safe-chain-win-arm64/safe-chain-win-arm64.exe
|
||||
mkdir release-artifacts
|
||||
mv binaries/safe-chain-macos-x64/safe-chain release-artifacts/safe-chain-macos-x64
|
||||
mv binaries/safe-chain-macos-arm64/safe-chain release-artifacts/safe-chain-macos-arm64
|
||||
mv binaries/safe-chain-linux-x64/safe-chain release-artifacts/safe-chain-linux-x64
|
||||
mv binaries/safe-chain-linux-arm64/safe-chain release-artifacts/safe-chain-linux-arm64
|
||||
mv binaries/safe-chain-win-x64/safe-chain.exe release-artifacts/safe-chain-win-x64.exe
|
||||
mv binaries/safe-chain-win-arm64/safe-chain.exe release-artifacts/safe-chain-win-arm64.exe
|
||||
|
||||
- name: Move install scripts and hard-code version
|
||||
env:
|
||||
VERSION: ${{ needs.set-version.outputs.version }}
|
||||
run: |
|
||||
sed "s/\$(fetch_latest_version)/${VERSION}/" install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh
|
||||
sed "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1
|
||||
cp install-scripts/uninstall-safe-chain.sh release-artifacts/uninstall-safe-chain.sh
|
||||
cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1
|
||||
|
||||
- name: Upload binaries to existing GitHub Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload ${{ needs.set-version.outputs.version }} \
|
||||
binaries/safe-chain-macos-x64/* \
|
||||
binaries/safe-chain-macos-arm64/* \
|
||||
binaries/safe-chain-linux-x64/* \
|
||||
binaries/safe-chain-linux-arm64/* \
|
||||
binaries/safe-chain-win-x64/* \
|
||||
binaries/safe-chain-win-arm64/*
|
||||
release-artifacts/safe-chain-macos-x64 \
|
||||
release-artifacts/safe-chain-macos-arm64 \
|
||||
release-artifacts/safe-chain-linux-x64 \
|
||||
release-artifacts/safe-chain-linux-arm64 \
|
||||
release-artifacts/safe-chain-win-x64.exe \
|
||||
release-artifacts/safe-chain-win-arm64.exe \
|
||||
release-artifacts/install-safe-chain.sh \
|
||||
release-artifacts/install-safe-chain.ps1 \
|
||||
release-artifacts/uninstall-safe-chain.sh \
|
||||
release-artifacts/uninstall-safe-chain.ps1
|
||||
|
|
|
|||
20
.github/workflows/create-artifact.yml
vendored
20
.github/workflows/create-artifact.yml
vendored
|
|
@ -5,7 +5,7 @@ on:
|
|||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to set in package.json'
|
||||
description: "Version to set in package.json"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
|
|
@ -59,18 +59,22 @@ jobs:
|
|||
with:
|
||||
node-version: "20.x"
|
||||
|
||||
- name: Setup safe-chain
|
||||
run: |
|
||||
npm i -g @aikidosec/safe-chain
|
||||
safe-chain setup-ci
|
||||
- name: Setup safe-chain (Mac/Linux)
|
||||
if: runner.os != 'Windows'
|
||||
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
|
||||
- name: Set the version in safe-chain package
|
||||
if: inputs.version != ''
|
||||
run: npm --no-git-tag-version version ${{ inputs.version }} --workspace=packages/safe-chain
|
||||
- name: Setup safe-chain (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Set the version in safe-chain package
|
||||
if: inputs.version != ''
|
||||
run: npm --no-git-tag-version version ${{ inputs.version }} --workspace=packages/safe-chain --ignore-scripts
|
||||
|
||||
- name: Create binary
|
||||
run: |
|
||||
node build.js ${{ matrix.target }}
|
||||
|
|
|
|||
16
.github/workflows/test-on-pr.yml
vendored
16
.github/workflows/test-on-pr.yml
vendored
|
|
@ -22,10 +22,14 @@ jobs:
|
|||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Setup safe-chain
|
||||
run: |
|
||||
npm i -g @aikidosec/safe-chain
|
||||
safe-chain setup-ci
|
||||
- name: Setup safe-chain (Mac/Linux)
|
||||
if: runner.os != 'Windows'
|
||||
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
|
||||
- name: Setup safe-chain (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
|
@ -110,9 +114,7 @@ jobs:
|
|||
node-version: "lts/*"
|
||||
|
||||
- name: Setup safe-chain
|
||||
run: |
|
||||
npm i -g @aikidosec/safe-chain@1.0.24
|
||||
safe-chain setup-ci
|
||||
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
|
||||
- name: Install dependencies (root)
|
||||
run: npm ci
|
||||
|
|
|
|||
146
README.md
146
README.md
|
|
@ -19,13 +19,16 @@ Aikido Safe Chain supports the following package managers:
|
|||
- 📦 **pnpx**
|
||||
- 📦 **bun**
|
||||
- 📦 **bunx**
|
||||
- 📦 **pip** (beta)
|
||||
- 📦 **pip3** (beta)
|
||||
- 📦 **uv** (beta)
|
||||
- 📦 **poetry** (beta)
|
||||
- 📦 **pip**
|
||||
- 📦 **pip3**
|
||||
- 📦 **uv**
|
||||
- 📦 **poetry**
|
||||
- 📦 **pipx**
|
||||
|
||||
# Usage
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
Installing the Aikido Safe Chain is easy with our one-line installer.
|
||||
|
|
@ -34,37 +37,39 @@ Installing the Aikido Safe Chain is easy with our one-line installer.
|
|||
|
||||
### Unix/Linux/macOS
|
||||
|
||||
**Default installation (JavaScript packages only):**
|
||||
|
||||
```shell
|
||||
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh
|
||||
```
|
||||
|
||||
**Include Python support (pip/pip3/uv):**
|
||||
|
||||
```shell
|
||||
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --include-python
|
||||
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
**Default installation (JavaScript packages only):**
|
||||
|
||||
```powershell
|
||||
iex (iwr "https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1" -UseBasicParsing)
|
||||
iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1" -UseBasicParsing)
|
||||
```
|
||||
|
||||
**Include Python support (pip/pip3/uv):**
|
||||
### Pinning to a specific version
|
||||
|
||||
To install a specific version instead of the latest, replace `latest` with the version number in the URL (available from version 1.3.2 onwards):
|
||||
|
||||
**Unix/Linux/macOS:**
|
||||
|
||||
```shell
|
||||
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.sh | sh
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
|
||||
```powershell
|
||||
iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -includepython"
|
||||
iex (iwr "https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.ps1" -UseBasicParsing)
|
||||
```
|
||||
|
||||
You can find all available versions on the [releases page](https://github.com/AikidoSec/safe-chain/releases).
|
||||
|
||||
### Verify the installation
|
||||
|
||||
1. **❗Restart your terminal** to start using the Aikido Safe Chain.
|
||||
|
||||
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available.
|
||||
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available.
|
||||
|
||||
2. **Verify the installation** by running one of the following commands:
|
||||
|
||||
|
|
@ -74,7 +79,7 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
|
|||
npm install safe-chain-test
|
||||
```
|
||||
|
||||
For Python (if you enabled Python support):
|
||||
For Python:
|
||||
|
||||
```shell
|
||||
pip3 install safe-chain-pi-test
|
||||
|
|
@ -82,7 +87,7 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
|
|||
|
||||
- The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware.
|
||||
|
||||
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `uv`, `pip`, `pip3` or `poetry` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
|
||||
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
|
||||
|
||||
You can check the installed version by running:
|
||||
|
||||
|
|
@ -94,17 +99,17 @@ safe-chain --version
|
|||
|
||||
### Malware Blocking
|
||||
|
||||
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, pip, pip3 or poetry commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
|
||||
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, uv, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
|
||||
|
||||
### Minimum package age (npm only)
|
||||
|
||||
For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below.
|
||||
|
||||
⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3, poetry).
|
||||
⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3, poetry, pipx).
|
||||
|
||||
### Shell Integration
|
||||
|
||||
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (uv, pip). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
|
||||
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (pip, uv, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
|
||||
|
||||
- ✅ **Bash**
|
||||
- ✅ **Zsh**
|
||||
|
|
@ -116,17 +121,21 @@ More information about the shell integration can be found in the [shell integrat
|
|||
|
||||
## Uninstallation
|
||||
|
||||
To uninstall the Aikido Safe Chain, you can run the following command:
|
||||
To uninstall the Aikido Safe Chain, use our one-line uninstaller:
|
||||
|
||||
1. **Remove all aliases from your shell** by running:
|
||||
```shell
|
||||
safe-chain teardown
|
||||
```
|
||||
2. **Uninstall the Aikido Safe Chain package** using npm:
|
||||
```shell
|
||||
npm uninstall -g @aikidosec/safe-chain
|
||||
```
|
||||
3. **❗Restart your terminal** to remove the aliases.
|
||||
### Unix/Linux/macOS
|
||||
|
||||
```shell
|
||||
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/uninstall-safe-chain.sh | sh
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/uninstall-safe-chain.ps1" -UseBasicParsing)
|
||||
```
|
||||
|
||||
**❗Restart your terminal** after uninstalling to ensure all aliases are removed.
|
||||
|
||||
# Configuration
|
||||
|
||||
|
|
@ -179,24 +188,29 @@ You can set the minimum package age through multiple sources (in order of priori
|
|||
}
|
||||
```
|
||||
|
||||
## Custom Registries
|
||||
## Custom NPM Registries
|
||||
|
||||
By default, Safe Chain monitors downloads from the official package registries (npm registry, PyPI, etc.). If you use a private or custom package registry, you can configure Safe Chain to also monitor downloads from those registries.
|
||||
|
||||
⚠️ This feature **currently only applies to Python package managers** (pip, pip3, uv, poetry) and does not apply to npm-based package managers.
|
||||
Configure Safe Chain to scan packages from custom or private npm registries.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
You can set custom registries through the following source:
|
||||
You can set custom registries through environment variable or config file. Both sources are merged together.
|
||||
|
||||
1. **Environment Variable**:
|
||||
1. **Environment Variable** (comma-separated):
|
||||
|
||||
```shell
|
||||
export SAFE_CHAIN_PIP_CUSTOM_REGISTRIES=my-custom-registry.example.com,private-pypi.internal.com
|
||||
pip install mypackage
|
||||
export SAFE_CHAIN_NPM_CUSTOM_REGISTRIES="npm.company.com,registry.internal.net"
|
||||
```
|
||||
|
||||
Use a comma-separated list of registry hostnames to monitor multiple custom registries.
|
||||
2. **Config File** (`~/.aikido/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"npm": {
|
||||
"customRegistries": ["npm.company.com", "registry.internal.net"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# Usage in CI/CD
|
||||
|
||||
|
|
@ -208,36 +222,21 @@ Use the `--ci` flag to automatically configure Aikido Safe Chain for CI/CD envir
|
|||
|
||||
### Unix/Linux/macOS (GitHub Actions, Azure Pipelines, etc.)
|
||||
|
||||
**JavaScript only:**
|
||||
|
||||
```shell
|
||||
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci
|
||||
```
|
||||
|
||||
**With Python support:**
|
||||
|
||||
```shell
|
||||
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci --include-python
|
||||
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
```
|
||||
|
||||
### Windows (Azure Pipelines, etc.)
|
||||
|
||||
**JavaScript only:**
|
||||
|
||||
```powershell
|
||||
iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -ci"
|
||||
```
|
||||
|
||||
**With Python support:**
|
||||
|
||||
```powershell
|
||||
iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -ci -includepython"
|
||||
iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci"
|
||||
```
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
- ✅ **GitHub Actions**
|
||||
- ✅ **Azure Pipelines**
|
||||
- ✅ **CircleCI**
|
||||
|
||||
## GitHub Actions Example
|
||||
|
||||
|
|
@ -249,14 +248,12 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
|
|||
cache: "npm"
|
||||
|
||||
- name: Install safe-chain
|
||||
run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci --include-python
|
||||
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
```
|
||||
|
||||
> **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv/poetry) support.
|
||||
|
||||
## Azure DevOps Example
|
||||
|
||||
```yaml
|
||||
|
|
@ -265,13 +262,30 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
|
|||
versionSpec: "22.x"
|
||||
displayName: "Install Node.js"
|
||||
|
||||
- script: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci --include-python
|
||||
- script: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
displayName: "Install safe-chain"
|
||||
|
||||
- script: npm ci
|
||||
displayName: "Install dependencies"
|
||||
```
|
||||
|
||||
> **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv/poetry) support.
|
||||
## CircleCI Example
|
||||
|
||||
```yaml
|
||||
version: 2.1
|
||||
jobs:
|
||||
build:
|
||||
docker:
|
||||
- image: cimg/node:lts
|
||||
steps:
|
||||
- checkout
|
||||
- run: |
|
||||
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci
|
||||
- run: npm ci
|
||||
workflows:
|
||||
build_and_test:
|
||||
jobs:
|
||||
- build
|
||||
```
|
||||
|
||||
After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection.
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Overview
|
||||
|
||||
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
|
||||
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
|
||||
|
||||
## Supported Shells
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ This command:
|
|||
|
||||
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
|
||||
- Detects all supported shells on your system
|
||||
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3`
|
||||
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx`
|
||||
- Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name
|
||||
|
||||
❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
|
||||
|
|
@ -78,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts:
|
|||
This means the shell functions are working but the Aikido commands aren't installed or available in your PATH:
|
||||
|
||||
- Make sure Aikido Safe Chain is properly installed on your system
|
||||
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, and `aikido-pip3` commands exist
|
||||
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-poetry` and `aikido-pipx` commands exist
|
||||
- Check that these commands are in your system's PATH
|
||||
|
||||
### Manual Verification
|
||||
|
|
@ -121,7 +121,7 @@ npm() {
|
|||
}
|
||||
```
|
||||
|
||||
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
|
||||
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
|
||||
|
||||
To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions:
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,46 @@ function Write-Error-Custom {
|
|||
exit 1
|
||||
}
|
||||
|
||||
# Get currently installed version of safe-chain
|
||||
function Get-InstalledVersion {
|
||||
# Check if safe-chain command exists
|
||||
if (-not (Get-Command safe-chain -ErrorAction SilentlyContinue)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
# Execute safe-chain -v and capture output
|
||||
$output = & safe-chain -v 2>&1
|
||||
|
||||
# Extract version from "Current safe-chain version: X.Y.Z" output
|
||||
if ($output -match "Current safe-chain version:\s*(.+)") {
|
||||
return $matches[1].Trim()
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Check if the requested version is already installed
|
||||
function Test-VersionInstalled {
|
||||
param([string]$RequestedVersion)
|
||||
|
||||
$installedVersion = Get-InstalledVersion
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($installedVersion)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
# Strip leading 'v' from versions if present for comparison
|
||||
$requestedClean = $RequestedVersion -replace '^v', ''
|
||||
$installedClean = $installedVersion -replace '^v', ''
|
||||
|
||||
return $requestedClean -eq $installedClean
|
||||
}
|
||||
|
||||
# Fetch latest release version tag from GitHub
|
||||
function Get-LatestVersion {
|
||||
try {
|
||||
|
|
@ -115,14 +155,20 @@ function Install-SafeChain {
|
|||
$Version = Get-LatestVersion
|
||||
}
|
||||
|
||||
# Check if the requested version is already installed
|
||||
if (Test-VersionInstalled -RequestedVersion $Version) {
|
||||
Write-Info "safe-chain $Version is already installed"
|
||||
return
|
||||
}
|
||||
|
||||
# Build installation message
|
||||
$installMsg = "Installing safe-chain $Version"
|
||||
if ($includepython) {
|
||||
$installMsg += " with python"
|
||||
}
|
||||
if ($ci) {
|
||||
$installMsg += " in ci"
|
||||
}
|
||||
if ($includepython) {
|
||||
Write-Warn "-includepython is deprecated and ignored. Python ecosystem is now included by default."
|
||||
}
|
||||
|
||||
Write-Info $installMsg
|
||||
|
||||
|
|
@ -181,9 +227,6 @@ function Install-SafeChain {
|
|||
# Build setup command based on parameters
|
||||
$setupCmd = if ($ci) { "setup-ci" } else { "setup" }
|
||||
$setupArgs = @()
|
||||
if ($includepython) {
|
||||
$setupArgs += "--include-python"
|
||||
}
|
||||
|
||||
# Execute safe-chain setup
|
||||
Write-Info "Running safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })..."
|
||||
|
|
|
|||
|
|
@ -54,6 +54,38 @@ command_exists() {
|
|||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Get currently installed version of safe-chain
|
||||
get_installed_version() {
|
||||
if ! command_exists safe-chain; then
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
|
||||
# Extract version from "Current safe-chain version: X.Y.Z" output
|
||||
installed_version=$(safe-chain -v 2>/dev/null | grep "Current safe-chain version:" | sed -E 's/.*: (.*)/\1/')
|
||||
echo "$installed_version"
|
||||
}
|
||||
|
||||
# Check if the requested version is already installed
|
||||
is_version_installed() {
|
||||
requested_version="$1"
|
||||
installed_version=$(get_installed_version)
|
||||
|
||||
if [ -z "$installed_version" ]; then
|
||||
return 1 # Not installed
|
||||
fi
|
||||
|
||||
# Strip leading 'v' from versions if present for comparison
|
||||
requested_clean=$(echo "$requested_version" | sed 's/^v//')
|
||||
installed_clean=$(echo "$installed_version" | sed 's/^v//')
|
||||
|
||||
if [ "$requested_clean" = "$installed_clean" ]; then
|
||||
return 0 # Same version installed
|
||||
else
|
||||
return 1 # Different version installed
|
||||
fi
|
||||
}
|
||||
|
||||
# Fetch latest release version tag from GitHub
|
||||
fetch_latest_version() {
|
||||
# Try using GitHub API to get the latest release tag
|
||||
|
|
@ -135,7 +167,7 @@ parse_arguments() {
|
|||
USE_CI_SETUP=true
|
||||
;;
|
||||
--include-python)
|
||||
INCLUDE_PYTHON=true
|
||||
warn "--include-python is deprecated and ignored. Python ecosystem is now included by default."
|
||||
;;
|
||||
*)
|
||||
error "Unknown argument: $arg"
|
||||
|
|
@ -148,7 +180,6 @@ parse_arguments() {
|
|||
main() {
|
||||
# Initialize argument flags
|
||||
USE_CI_SETUP=false
|
||||
INCLUDE_PYTHON=false
|
||||
|
||||
# Parse command-line arguments
|
||||
parse_arguments "$@"
|
||||
|
|
@ -159,11 +190,14 @@ main() {
|
|||
VERSION=$(fetch_latest_version)
|
||||
fi
|
||||
|
||||
# Check if the requested version is already installed
|
||||
if is_version_installed "$VERSION"; then
|
||||
info "safe-chain ${VERSION} is already installed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Build installation message
|
||||
INSTALL_MSG="Installing safe-chain ${VERSION}"
|
||||
if [ "$INCLUDE_PYTHON" = "true" ]; then
|
||||
INSTALL_MSG="${INSTALL_MSG} with python"
|
||||
fi
|
||||
if [ "$USE_CI_SETUP" = "true" ]; then
|
||||
INSTALL_MSG="${INSTALL_MSG} in ci"
|
||||
fi
|
||||
|
|
@ -209,10 +243,6 @@ main() {
|
|||
SETUP_CMD="setup-ci"
|
||||
fi
|
||||
|
||||
if [ "$INCLUDE_PYTHON" = "true" ]; then
|
||||
SETUP_ARGS="--include-python"
|
||||
fi
|
||||
|
||||
# Execute safe-chain setup
|
||||
info "Running safe-chain $SETUP_CMD $SETUP_ARGS..."
|
||||
if ! "$FINAL_FILE" $SETUP_CMD $SETUP_ARGS; then
|
||||
|
|
|
|||
165
install-scripts/uninstall-safe-chain.ps1
Normal file
165
install-scripts/uninstall-safe-chain.ps1
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# Uninstalls safe-chain from Windows
|
||||
#
|
||||
# Usage with "iex (iwr {url} -UseBasicParsing)" --> See README.md
|
||||
|
||||
# Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform)
|
||||
$HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
|
||||
$InstallDir = Join-Path $HomeDir ".safe-chain/bin"
|
||||
|
||||
# Helper functions
|
||||
function Write-Info {
|
||||
param([string]$Message)
|
||||
Write-Host "[INFO] $Message" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Write-Warn {
|
||||
param([string]$Message)
|
||||
Write-Host "[WARN] $Message" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
function Write-Error-Custom {
|
||||
param([string]$Message)
|
||||
Write-Host "[ERROR] $Message" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check and uninstall npm global package if present
|
||||
function Remove-NpmInstallation {
|
||||
# Check if npm is available
|
||||
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
|
||||
return
|
||||
}
|
||||
|
||||
# Check if safe-chain is installed as an npm global package
|
||||
npm list -g @aikidosec/safe-chain 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Info "Detected npm global installation of @aikidosec/safe-chain"
|
||||
Write-Info "Uninstalling npm version before installing binary version..."
|
||||
|
||||
npm uninstall -g @aikidosec/safe-chain 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Info "Successfully uninstalled npm version"
|
||||
}
|
||||
else {
|
||||
Write-Warn "Failed to uninstall npm version automatically"
|
||||
Write-Warn "Please run: npm uninstall -g @aikidosec/safe-chain"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Check and uninstall Volta-managed package if present
|
||||
function Remove-VoltaInstallation {
|
||||
# Check if Volta is available
|
||||
if (-not (Get-Command volta -ErrorAction SilentlyContinue)) {
|
||||
return
|
||||
}
|
||||
|
||||
# Volta manages global packages in its own directory
|
||||
# Check if safe-chain is installed via Volta
|
||||
volta list safe-chain 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Info "Detected Volta installation of @aikidosec/safe-chain"
|
||||
Write-Info "Uninstalling Volta version before installing binary version..."
|
||||
|
||||
volta uninstall @aikidosec/safe-chain 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Info "Successfully uninstalled Volta version"
|
||||
}
|
||||
else {
|
||||
Write-Warn "Failed to uninstall Volta version automatically"
|
||||
Write-Warn "Please run: volta uninstall @aikidosec/safe-chain"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Main uninstallation
|
||||
function Uninstall-SafeChain {
|
||||
Write-Info "Uninstalling safe-chain..."
|
||||
|
||||
# Run teardown if safe-chain is available
|
||||
# Check for both safe-chain.exe (Windows) and safe-chain (Unix) since PowerShell Core runs on all platforms
|
||||
$safeChainExe = Join-Path $InstallDir "safe-chain.exe"
|
||||
$safeChainBin = Join-Path $InstallDir "safe-chain"
|
||||
|
||||
$safeChainPath = $null
|
||||
if (Test-Path $safeChainExe) {
|
||||
$safeChainPath = $safeChainExe
|
||||
}
|
||||
elseif (Test-Path $safeChainBin) {
|
||||
$safeChainPath = $safeChainBin
|
||||
}
|
||||
|
||||
if ($safeChainPath) {
|
||||
Write-Info "Running safe-chain teardown..."
|
||||
try {
|
||||
& $safeChainPath teardown
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warn "safe-chain teardown encountered issues: $_"
|
||||
Write-Warn "Continuing with uninstallation..."
|
||||
}
|
||||
}
|
||||
elseif (Get-Command safe-chain -ErrorAction SilentlyContinue) {
|
||||
Write-Info "Running safe-chain teardown..."
|
||||
try {
|
||||
safe-chain teardown
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warn "safe-chain teardown encountered issues: $_"
|
||||
Write-Warn "Continuing with uninstallation..."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warn "safe-chain command not found. Proceeding with uninstallation."
|
||||
}
|
||||
|
||||
# Remove npm and Volta installations
|
||||
Remove-NpmInstallation
|
||||
Remove-VoltaInstallation
|
||||
|
||||
# Remove installation directory
|
||||
if (Test-Path $InstallDir) {
|
||||
Write-Info "Removing installation directory: $InstallDir"
|
||||
try {
|
||||
Remove-Item -Path $InstallDir -Recurse -Force
|
||||
Write-Info "Successfully removed installation directory"
|
||||
}
|
||||
catch {
|
||||
Write-Error-Custom "Failed to remove $InstallDir : $_"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Info "Installation directory $InstallDir does not exist. Nothing to remove."
|
||||
}
|
||||
|
||||
# Also try to remove the parent .safe-chain directory if it's empty
|
||||
$parentDir = Split-Path $InstallDir -Parent
|
||||
if (Test-Path $parentDir) {
|
||||
$items = Get-ChildItem -Path $parentDir -Force
|
||||
if ($items.Count -eq 0) {
|
||||
Write-Info "Removing empty parent directory: $parentDir"
|
||||
try {
|
||||
Remove-Item -Path $parentDir -Force
|
||||
}
|
||||
catch {
|
||||
Write-Warn "Could not remove empty parent directory: $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Info "safe-chain has been uninstalled successfully!"
|
||||
}
|
||||
|
||||
# Run uninstallation
|
||||
try {
|
||||
Uninstall-SafeChain
|
||||
}
|
||||
catch {
|
||||
Write-Error-Custom "Uninstallation failed: $_"
|
||||
}
|
||||
104
install-scripts/uninstall-safe-chain.sh
Executable file
104
install-scripts/uninstall-safe-chain.sh
Executable file
|
|
@ -0,0 +1,104 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Downloads and installs safe-chain, depending on the operating system and architecture
|
||||
#
|
||||
# Usage with "curl -fsSL {url} | sh" --> See README.md
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Configuration
|
||||
INSTALL_DIR="${HOME}/.safe-chain/bin"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Helper functions
|
||||
info() {
|
||||
printf "${GREEN}[INFO]${NC} %s\n" "$1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf "${YELLOW}[WARN]${NC} %s\n" "$1"
|
||||
}
|
||||
|
||||
error() {
|
||||
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if command exists
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Check and uninstall npm global package if present
|
||||
remove_npm_installation() {
|
||||
if ! command_exists npm; then
|
||||
return
|
||||
fi
|
||||
|
||||
# Check if safe-chain is installed as an npm global package
|
||||
if npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then
|
||||
info "Detected npm global installation of @aikidosec/safe-chain"
|
||||
info "Uninstalling npm version before installing binary version..."
|
||||
|
||||
if npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then
|
||||
info "Successfully uninstalled npm version"
|
||||
else
|
||||
warn "Failed to uninstall npm version automatically"
|
||||
warn "Please run: npm uninstall -g @aikidosec/safe-chain"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Check and uninstall Volta-managed package if present
|
||||
remove_volta_installation() {
|
||||
if ! command_exists volta; then
|
||||
return
|
||||
fi
|
||||
|
||||
# Volta manages global packages in its own directory
|
||||
# Check if safe-chain is installed via Volta
|
||||
if volta list safe-chain >/dev/null 2>&1; then
|
||||
info "Detected Volta installation of @aikidosec/safe-chain"
|
||||
info "Uninstalling Volta version before installing binary version..."
|
||||
|
||||
if volta uninstall @aikidosec/safe-chain >/dev/null 2>&1; then
|
||||
info "Successfully uninstalled Volta version"
|
||||
else
|
||||
warn "Failed to uninstall Volta version automatically"
|
||||
warn "Please run: volta uninstall @aikidosec/safe-chain"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Main uninstallation
|
||||
main() {
|
||||
SAFE_CHAIN_LOCATION="$INSTALL_DIR/safe-chain"
|
||||
|
||||
if [ -x "$SAFE_CHAIN_LOCATION" ]; then
|
||||
info "Running safe-chain teardown..."
|
||||
"$SAFE_CHAIN_LOCATION" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
||||
elif command_exists safe-chain; then
|
||||
info "Running safe-chain teardown..."
|
||||
safe-chain teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
||||
else
|
||||
warn "safe-chain command not found. Proceeding with uninstallation."
|
||||
fi
|
||||
|
||||
remove_npm_installation
|
||||
remove_volta_installation
|
||||
|
||||
# Remove install dir recursively if it exists
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
info "Removing installation directory $INSTALL_DIR"
|
||||
rm -rf "$INSTALL_DIR" || error "Failed to remove $INSTALL_DIR"
|
||||
else
|
||||
info "Installation directory $INSTALL_DIR does not exist. Nothing to remove."
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -3131,6 +3131,7 @@
|
|||
"aikido-npx": "bin/aikido-npx.js",
|
||||
"aikido-pip": "bin/aikido-pip.js",
|
||||
"aikido-pip3": "bin/aikido-pip3.js",
|
||||
"aikido-pipx": "bin/aikido-pipx.js",
|
||||
"aikido-pnpm": "bin/aikido-pnpm.js",
|
||||
"aikido-pnpx": "bin/aikido-pnpx.js",
|
||||
"aikido-poetry": "bin/aikido-poetry.js",
|
||||
|
|
|
|||
16
packages/safe-chain/bin/aikido-pipx.js
Executable file
16
packages/safe-chain/bin/aikido-pipx.js
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
|
||||
|
||||
// Set eco system
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
|
||||
initializePackageManager("pipx");
|
||||
|
||||
(async () => {
|
||||
// Pass through only user-supplied pipx args
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
process.exit(exitCode);
|
||||
})();
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import chalk from "chalk";
|
||||
import { ui } from "../src/environment/userInteraction.js";
|
||||
import { setup } from "../src/shell-integration/setup.js";
|
||||
import { teardown } from "../src/shell-integration/teardown.js";
|
||||
import { teardown, teardownDirectories } from "../src/shell-integration/teardown.js";
|
||||
import { setupCi } from "../src/shell-integration/setup-ci.js";
|
||||
import { initializeCliArguments } from "../src/config/cliArguments.js";
|
||||
import { setEcoSystem } from "../src/config/settings.js";
|
||||
|
|
@ -60,6 +60,7 @@ if (tool) {
|
|||
} else if (command === "setup") {
|
||||
setup();
|
||||
} else if (command === "teardown") {
|
||||
teardownDirectories();
|
||||
teardown();
|
||||
} else if (command === "setup-ci") {
|
||||
setupCi();
|
||||
|
|
@ -94,11 +95,6 @@ function writeHelp() {
|
|||
"safe-chain setup"
|
||||
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`
|
||||
);
|
||||
ui.writeInformation(
|
||||
` ${chalk.yellow(
|
||||
"--include-python"
|
||||
)}: Experimental: include Python package managers (pip, pip3) in the setup.`
|
||||
);
|
||||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
"safe-chain teardown"
|
||||
|
|
@ -109,11 +105,6 @@ function writeHelp() {
|
|||
"safe-chain setup-ci"
|
||||
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`
|
||||
);
|
||||
ui.writeInformation(
|
||||
` ${chalk.yellow(
|
||||
"--include-python"
|
||||
)}: Experimental: include Python package managers (pip, pip3) in the setup.`
|
||||
);
|
||||
ui.writeInformation(
|
||||
`- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan(
|
||||
"-v"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"aikido-python": "bin/aikido-python.js",
|
||||
"aikido-python3": "bin/aikido-python3.js",
|
||||
"aikido-poetry": "bin/aikido-poetry.js",
|
||||
"aikido-pipx": "bin/aikido-pipx.js",
|
||||
"safe-chain": "bin/safe-chain.js"
|
||||
},
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { ui } from "../environment/userInteraction.js";
|
||||
|
||||
/**
|
||||
* @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, includePython: boolean}}
|
||||
* @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined}}
|
||||
*/
|
||||
const state = {
|
||||
loggingLevel: undefined,
|
||||
skipMinimumPackageAge: undefined,
|
||||
minimumPackageAgeHours: undefined,
|
||||
includePython: false,
|
||||
};
|
||||
|
||||
const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
|
||||
|
|
@ -34,8 +35,7 @@ export function initializeCliArguments(args) {
|
|||
setLoggingLevel(safeChainArgs);
|
||||
setSkipMinimumPackageAge(safeChainArgs);
|
||||
setMinimumPackageAgeHours(safeChainArgs);
|
||||
setIncludePython(args);
|
||||
|
||||
checkDeprecatedPythonFlag(args);
|
||||
return remainingArgs;
|
||||
}
|
||||
|
||||
|
|
@ -109,20 +109,6 @@ export function getMinimumPackageAgeHours() {
|
|||
return state.minimumPackageAgeHours;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} args
|
||||
*/
|
||||
function setIncludePython(args) {
|
||||
// This flag doesn't have the --safe-chain- prefix because
|
||||
// it is only used for the safe-chain command itself and
|
||||
// not when wrapped around package manager commands.
|
||||
state.includePython = hasFlagArg(args, "--include-python");
|
||||
}
|
||||
|
||||
export function includePython() {
|
||||
return state.includePython;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} args
|
||||
* @param {string} flagName
|
||||
|
|
@ -136,3 +122,17 @@ function hasFlagArg(args, flagName) {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a deprecation warning for legacy --include-python flag
|
||||
*
|
||||
* @param {string[]} args
|
||||
* @returns {void}
|
||||
*/
|
||||
export function checkDeprecatedPythonFlag(args) {
|
||||
if (hasFlagArg(args, "--include-python")) {
|
||||
ui.writeWarning(
|
||||
"--include-python is deprecated and ignored. Python tooling is included by default."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getSkipMinimumPackageAge,
|
||||
getMinimumPackageAgeHours,
|
||||
} from "./cliArguments.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
|
||||
describe("initializeCliArguments", () => {
|
||||
it("should return all args when no safe-chain args are present", () => {
|
||||
|
|
@ -271,4 +272,40 @@ describe("initializeCliArguments", () => {
|
|||
|
||||
assert.strictEqual(getMinimumPackageAgeHours(), "-24");
|
||||
});
|
||||
|
||||
it("should warn on deprecated --include-python for setup", () => {
|
||||
const warnings = [];
|
||||
const originalWriteWarning = ui.writeWarning;
|
||||
ui.writeWarning = (msg, ..._rest) => {
|
||||
warnings.push(String(msg));
|
||||
};
|
||||
try {
|
||||
const argv = ["node", "safe-chain", "setup", "--include-python"];
|
||||
initializeCliArguments(argv);
|
||||
assert.ok(
|
||||
warnings.some((m) => m.includes("--include-python is deprecated")),
|
||||
"Expected a deprecation warning for --include-python in setup"
|
||||
);
|
||||
} finally {
|
||||
ui.writeWarning = originalWriteWarning;
|
||||
}
|
||||
});
|
||||
|
||||
it("should warn on deprecated --include-python for setup-ci", () => {
|
||||
const warnings = [];
|
||||
const originalWriteWarning = ui.writeWarning;
|
||||
ui.writeWarning = (msg, ..._rest) => {
|
||||
warnings.push(String(msg));
|
||||
};
|
||||
try {
|
||||
const argv = ["node", "safe-chain", "setup-ci", "--include-python"];
|
||||
initializeCliArguments(argv);
|
||||
assert.ok(
|
||||
warnings.some((m) => m.includes("--include-python is deprecated")),
|
||||
"Expected a deprecation warning for --include-python in setup-ci"
|
||||
);
|
||||
} finally {
|
||||
ui.writeWarning = originalWriteWarning;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,10 +7,14 @@ import { getEcoSystem } from "./settings.js";
|
|||
/**
|
||||
* @typedef {Object} SafeChainConfig
|
||||
*
|
||||
* This should be a number, but can be anything because it is user-input.
|
||||
* We cannot trust the input and should add the necessary validations
|
||||
* @property {unknown | Number} scanTimeout
|
||||
* @property {unknown | Number} minimumPackageAgeHours
|
||||
* @property {unknown | SafeChainRegistryConfiguration} npm
|
||||
*
|
||||
* @typedef {Object} SafeChainRegistryConfiguration
|
||||
* We cannot trust the input and should add the necessary validations.
|
||||
* @property {unknown} scanTimeout
|
||||
* @property {unknown} minimumPackageAgeHours
|
||||
* @property {unknown | string[]} customRegistries
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -67,7 +71,7 @@ function validateMinimumPackageAgeHours(value) {
|
|||
*/
|
||||
export function getMinimumPackageAgeHours() {
|
||||
const config = readConfigFile();
|
||||
if (config.minimumPackageAgeHours) {
|
||||
if (config.minimumPackageAgeHours !== undefined) {
|
||||
const validated = validateMinimumPackageAgeHours(
|
||||
config.minimumPackageAgeHours
|
||||
);
|
||||
|
|
@ -78,6 +82,28 @@ export function getMinimumPackageAgeHours() {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the custom npm registries from the config file (format parsing only, no validation)
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getNpmCustomRegistries() {
|
||||
const config = readConfigFile();
|
||||
|
||||
if (!config || !config.npm) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// TypeScript needs help understanding that config.npm exists and has customRegistries
|
||||
const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm);
|
||||
const customRegistries = npmConfig.customRegistries;
|
||||
|
||||
if (!Array.isArray(customRegistries)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return customRegistries.filter((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../api/aikido.js").MalwarePackage[]} data
|
||||
* @param {string | number} version
|
||||
|
|
@ -136,23 +162,26 @@ export function readDatabaseFromLocalCache() {
|
|||
* @returns {SafeChainConfig}
|
||||
*/
|
||||
function readConfigFile() {
|
||||
/** @type {SafeChainConfig} */
|
||||
const emptyConfig = {
|
||||
scanTimeout: undefined,
|
||||
minimumPackageAgeHours: undefined,
|
||||
npm: {
|
||||
customRegistries: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const configFilePath = getConfigFilePath();
|
||||
|
||||
if (!fs.existsSync(configFilePath)) {
|
||||
return {
|
||||
scanTimeout: undefined,
|
||||
minimumPackageAgeHours: undefined,
|
||||
};
|
||||
return emptyConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(configFilePath, "utf8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return {
|
||||
scanTimeout: undefined,
|
||||
minimumPackageAgeHours: undefined,
|
||||
};
|
||||
return emptyConfig;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,24 @@
|
|||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("getScanTimeout", () => {
|
||||
let configFileContent = undefined;
|
||||
mock.module("fs", {
|
||||
namedExports: {
|
||||
existsSync: () => configFileContent !== undefined,
|
||||
readFileSync: () => configFileContent,
|
||||
writeFileSync: (content) => (configFileContent = content),
|
||||
mkdirSync: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
describe("getScanTimeout", async () => {
|
||||
let originalEnv;
|
||||
let fsMock;
|
||||
let getScanTimeout;
|
||||
|
||||
const { getScanTimeout } = await import("./configFile.js");
|
||||
|
||||
beforeEach(async () => {
|
||||
// Save original environment
|
||||
originalEnv = process.env.AIKIDO_SCAN_TIMEOUT_MS;
|
||||
|
||||
// Mock fs module
|
||||
fsMock = {
|
||||
existsSync: mock.fn(() => false),
|
||||
readFileSync: mock.fn(() => "{}"),
|
||||
writeFileSync: mock.fn(),
|
||||
mkdirSync: mock.fn(),
|
||||
};
|
||||
|
||||
mock.module("fs", {
|
||||
namedExports: fsMock,
|
||||
});
|
||||
|
||||
// Re-import the module to get the mocked version
|
||||
const configFileModule = await import(
|
||||
`./configFile.js?update=${Date.now()}`
|
||||
);
|
||||
getScanTimeout = configFileModule.getScanTimeout;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -37,14 +29,12 @@ describe("getScanTimeout", () => {
|
|||
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// Reset all mocks
|
||||
mock.restoreAll();
|
||||
configFileContent = undefined;
|
||||
});
|
||||
|
||||
it("should return default timeout of 10000ms when no config or env var is set", () => {
|
||||
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
|
||||
// Mock: config file doesn't exist
|
||||
fsMock.existsSync.mock.mockImplementation(() => false);
|
||||
configFileContent = undefined;
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -53,11 +43,7 @@ describe("getScanTimeout", () => {
|
|||
|
||||
it("should return timeout from config file when set", () => {
|
||||
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
|
||||
// Mock: config file exists with scanTimeout: 5000
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ scanTimeout: 5000 })
|
||||
);
|
||||
configFileContent = JSON.stringify({ scanTimeout: 5000 });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -66,11 +52,7 @@ describe("getScanTimeout", () => {
|
|||
|
||||
it("should prioritize environment variable over config file", () => {
|
||||
process.env.AIKIDO_SCAN_TIMEOUT_MS = "20000";
|
||||
// Mock: config file exists with scanTimeout: 5000
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ scanTimeout: 5000 })
|
||||
);
|
||||
configFileContent = JSON.stringify({ scanTimeout: 5000 });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -79,11 +61,7 @@ describe("getScanTimeout", () => {
|
|||
|
||||
it("should handle invalid environment variable and fall back to config", () => {
|
||||
process.env.AIKIDO_SCAN_TIMEOUT_MS = "invalid";
|
||||
// Mock: config file exists with scanTimeout: 7000
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ scanTimeout: 7000 })
|
||||
);
|
||||
configFileContent = JSON.stringify({ scanTimeout: 7000 });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -91,8 +69,7 @@ describe("getScanTimeout", () => {
|
|||
});
|
||||
|
||||
it("should ignore zero and negative values and fall back to default", () => {
|
||||
// Mock: config file doesn't exist
|
||||
fsMock.existsSync.mock.mockImplementation(() => false);
|
||||
configFileContent = undefined;
|
||||
|
||||
process.env.AIKIDO_SCAN_TIMEOUT_MS = "0";
|
||||
|
||||
|
|
@ -107,11 +84,7 @@ describe("getScanTimeout", () => {
|
|||
|
||||
it("should ignore textual non-numeric values in environment variable and fall back to config", () => {
|
||||
process.env.AIKIDO_SCAN_TIMEOUT_MS = "fast";
|
||||
// Mock: config file exists with scanTimeout: 8000
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ scanTimeout: 8000 })
|
||||
);
|
||||
configFileContent = JSON.stringify({ scanTimeout: 8000 });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -120,11 +93,7 @@ describe("getScanTimeout", () => {
|
|||
|
||||
it("should ignore textual non-numeric values in config file and fall back to default", () => {
|
||||
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
|
||||
// Mock: config file exists with scanTimeout: "slow"
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ scanTimeout: "slow" })
|
||||
);
|
||||
configFileContent = JSON.stringify({ scanTimeout: "slow" });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -133,11 +102,7 @@ describe("getScanTimeout", () => {
|
|||
|
||||
it("should ignore textual non-numeric values in both env and config, fall back to default", () => {
|
||||
process.env.AIKIDO_SCAN_TIMEOUT_MS = "quick";
|
||||
// Mock: config file exists with scanTimeout: "medium"
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ scanTimeout: "medium" })
|
||||
);
|
||||
configFileContent = JSON.stringify({ scanTimeout: "medium" });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -146,11 +111,7 @@ describe("getScanTimeout", () => {
|
|||
|
||||
it("should ignore mixed alphanumeric strings in environment variable", () => {
|
||||
process.env.AIKIDO_SCAN_TIMEOUT_MS = "5000ms";
|
||||
// Mock: config file exists with scanTimeout: 6000
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ scanTimeout: 6000 })
|
||||
);
|
||||
configFileContent = JSON.stringify({ scanTimeout: 6000 });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -159,11 +120,7 @@ describe("getScanTimeout", () => {
|
|||
|
||||
it("should ignore mixed alphanumeric strings in config file", () => {
|
||||
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
|
||||
// Mock: config file exists with scanTimeout: "3000ms"
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ scanTimeout: "3000ms" })
|
||||
);
|
||||
configFileContent = JSON.stringify({ scanTimeout: "3000ms" });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -171,37 +128,15 @@ describe("getScanTimeout", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("getMinimumPackageAgeHours", () => {
|
||||
let fsMock;
|
||||
let getMinimumPackageAgeHours;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Mock fs module
|
||||
fsMock = {
|
||||
existsSync: mock.fn(() => false),
|
||||
readFileSync: mock.fn(() => "{}"),
|
||||
writeFileSync: mock.fn(),
|
||||
mkdirSync: mock.fn(),
|
||||
};
|
||||
|
||||
mock.module("fs", {
|
||||
namedExports: fsMock,
|
||||
});
|
||||
|
||||
// Re-import the module to get the mocked version
|
||||
const configFileModule = await import(
|
||||
`./configFile.js?update=${Date.now()}`
|
||||
);
|
||||
getMinimumPackageAgeHours = configFileModule.getMinimumPackageAgeHours;
|
||||
});
|
||||
describe("getMinimumPackageAgeHours", async () => {
|
||||
const { getMinimumPackageAgeHours } = await import("./configFile.js");
|
||||
|
||||
afterEach(() => {
|
||||
// Reset all mocks
|
||||
mock.restoreAll();
|
||||
configFileContent = undefined;
|
||||
});
|
||||
|
||||
it("should return null when config file doesn't exist", () => {
|
||||
fsMock.existsSync.mock.mockImplementation(() => false);
|
||||
configFileContent = undefined;
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -209,10 +144,7 @@ describe("getMinimumPackageAgeHours", () => {
|
|||
});
|
||||
|
||||
it("should return null when config file exists but minimumPackageAgeHours is not set", () => {
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ scanTimeout: 5000 })
|
||||
);
|
||||
configFileContent = JSON.stringify({ scanTimeout: 5000 });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -220,10 +152,7 @@ describe("getMinimumPackageAgeHours", () => {
|
|||
});
|
||||
|
||||
it("should return value from config file when set to valid number", () => {
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ minimumPackageAgeHours: 48 })
|
||||
);
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: 48 });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -231,10 +160,7 @@ describe("getMinimumPackageAgeHours", () => {
|
|||
});
|
||||
|
||||
it("should handle string numbers in config file", () => {
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ minimumPackageAgeHours: "72" })
|
||||
);
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: "72" });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -242,10 +168,7 @@ describe("getMinimumPackageAgeHours", () => {
|
|||
});
|
||||
|
||||
it("should handle decimal values", () => {
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ minimumPackageAgeHours: 1.5 })
|
||||
);
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: 1.5 });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -253,21 +176,15 @@ describe("getMinimumPackageAgeHours", () => {
|
|||
});
|
||||
|
||||
it("should return null for non-numeric strings", () => {
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ minimumPackageAgeHours: "invalid" })
|
||||
);
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: "invalid" });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
assert.strictEqual(hours, undefined);
|
||||
});
|
||||
|
||||
it("should return null for values with units suffix", () => {
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() =>
|
||||
JSON.stringify({ minimumPackageAgeHours: "48h" })
|
||||
);
|
||||
it("should return undefined for values with units suffix", () => {
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: "48h" });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -275,11 +192,131 @@ describe("getMinimumPackageAgeHours", () => {
|
|||
});
|
||||
|
||||
it("should handle malformed JSON and return null", () => {
|
||||
fsMock.existsSync.mock.mockImplementation(() => true);
|
||||
fsMock.readFileSync.mock.mockImplementation(() => "{ invalid json");
|
||||
configFileContent = "{ invalid json";
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
assert.strictEqual(hours, undefined);
|
||||
});
|
||||
|
||||
it("should return 0 when minimumPackageAgeHours is set to 0", () => {
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: 0 });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
assert.strictEqual(hours, 0);
|
||||
});
|
||||
|
||||
it("should return 0 when minimumPackageAgeHours is set to string '0'", () => {
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: "0" });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
assert.strictEqual(hours, 0);
|
||||
});
|
||||
|
||||
it("should handle negative numeric values", () => {
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: -24 });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
assert.strictEqual(hours, -24);
|
||||
});
|
||||
|
||||
it("should handle negative string values", () => {
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: "-48" });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
assert.strictEqual(hours, -48);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNpmCustomRegistries", async () => {
|
||||
const { getNpmCustomRegistries } = await import("./configFile.js");
|
||||
|
||||
afterEach(() => {
|
||||
configFileContent = undefined;
|
||||
});
|
||||
|
||||
it("should return empty array when config file doesn't exist", () => {
|
||||
configFileContent = undefined;
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, []);
|
||||
});
|
||||
|
||||
it("should return empty array when npm config is not set", () => {
|
||||
configFileContent = JSON.stringify({ scanTimeout: 5000 });
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, []);
|
||||
});
|
||||
|
||||
it("should return empty array when customRegistries is not an array", () => {
|
||||
configFileContent = JSON.stringify({
|
||||
npm: { customRegistries: "not-an-array" },
|
||||
});
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, []);
|
||||
});
|
||||
|
||||
it("should return array of custom registries when set", () => {
|
||||
configFileContent = JSON.stringify({
|
||||
npm: {
|
||||
customRegistries: ["npm.company.com", "registry.internal.net"],
|
||||
},
|
||||
});
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, [
|
||||
"npm.company.com",
|
||||
"registry.internal.net",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should filter out non-string values", () => {
|
||||
configFileContent = JSON.stringify({
|
||||
npm: {
|
||||
customRegistries: [
|
||||
"npm.company.com",
|
||||
123,
|
||||
null,
|
||||
"registry.internal.net",
|
||||
undefined,
|
||||
{},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, [
|
||||
"npm.company.com",
|
||||
"registry.internal.net",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty customRegistries array", () => {
|
||||
configFileContent = JSON.stringify({
|
||||
npm: { customRegistries: [] },
|
||||
});
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, []);
|
||||
});
|
||||
|
||||
it("should handle malformed JSON and return empty array", () => {
|
||||
configFileContent = "{ invalid json";
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, []);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,8 +6,20 @@ export function getMinimumPackageAgeHours() {
|
|||
return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the custom npm registries from environment variable
|
||||
* Expected format: comma-separated list of registry domains
|
||||
* Example: "npm.company.com,registry.internal.net"
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
export function getNpmCustomRegistries() {
|
||||
return process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the custom pip registries from environment variable
|
||||
* Expected format: comma-separated list of registry domains
|
||||
* Example: "pip.company.com,registry.internal.net"
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
export function getPipCustomRegistries() {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ function validateMinimumPackageAgeHours(value) {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
if (numericValue > 0) {
|
||||
if (numericValue >= 0) {
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
|
|
@ -99,29 +99,66 @@ export function skipMinimumPackageAge() {
|
|||
return defaultSkipMinimumPackageAge;
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
const defaultPipCustomRegistries = [];
|
||||
/** @returns {string[]} */
|
||||
export function getPipCustomRegistries() {
|
||||
// Priority 1: Environment variable
|
||||
const envValue = validatePipCustomRegistries(
|
||||
environmentVariables.getPipCustomRegistries()
|
||||
);
|
||||
if (envValue !== undefined) {
|
||||
return envValue;
|
||||
}
|
||||
|
||||
return defaultPipCustomRegistries;
|
||||
/**
|
||||
* Normalizes a registry URL by removing protocol if present
|
||||
* @param {string} registry
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeRegistry(registry) {
|
||||
// Remove protocol (http://, https://) if present
|
||||
return registry.replace(/^https?:\/\//, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} value
|
||||
* @returns {string[] | undefined}
|
||||
* Parses comma-separated registries from environment variable
|
||||
* @param {string | undefined} envValue
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function validatePipCustomRegistries(value) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
function parseRegistriesFromEnv(envValue) {
|
||||
if (!envValue || typeof envValue !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.split(",");
|
||||
// Split by comma and trim whitespace
|
||||
return envValue
|
||||
.split(",")
|
||||
.map((registry) => registry.trim())
|
||||
.filter((registry) => registry.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the custom npm registries from both environment variable and config file (merged)
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getNpmCustomRegistries() {
|
||||
const envRegistries = parseRegistriesFromEnv(
|
||||
environmentVariables.getNpmCustomRegistries()
|
||||
);
|
||||
const configRegistries = configFile.getNpmCustomRegistries();
|
||||
|
||||
// Merge both sources and remove duplicates
|
||||
const allRegistries = [...envRegistries, ...configRegistries];
|
||||
const uniqueRegistries = [...new Set(allRegistries)];
|
||||
|
||||
// Normalize each registry (remove protocol if any)
|
||||
return uniqueRegistries.map(normalizeRegistry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the custom npm registries from both environment variable and config file (merged)
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getPipCustomRegistries() {
|
||||
const envRegistries = parseRegistriesFromEnv(
|
||||
environmentVariables.getPipCustomRegistries()
|
||||
);
|
||||
// const configRegistries = configFile.getPipCustomRegistries();
|
||||
|
||||
// Merge both sources and remove duplicates
|
||||
// const allRegistries = [...envRegistries, ...configRegistries];
|
||||
const allRegistries = [...envRegistries];
|
||||
const uniqueRegistries = [...new Set(allRegistries)];
|
||||
|
||||
// Normalize each registry (remove protocol if any)
|
||||
return uniqueRegistries.map(normalizeRegistry);
|
||||
}
|
||||
|
|
|
|||
249
packages/safe-chain/src/config/settings.spec.js
Normal file
249
packages/safe-chain/src/config/settings.spec.js
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
let configFileContent = undefined;
|
||||
mock.module("fs", {
|
||||
namedExports: {
|
||||
existsSync: () => configFileContent !== undefined,
|
||||
readFileSync: () => configFileContent,
|
||||
writeFileSync: (content) => (configFileContent = content),
|
||||
mkdirSync: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
describe("getNpmCustomRegistries", async () => {
|
||||
let originalEnv;
|
||||
const { getNpmCustomRegistries } = await import("./settings.js");
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = originalEnv;
|
||||
} else {
|
||||
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
|
||||
}
|
||||
configFileContent = undefined;
|
||||
});
|
||||
|
||||
it("should return empty array when no registries configured", () => {
|
||||
configFileContent = undefined;
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, []);
|
||||
});
|
||||
|
||||
it("should return registries without protocol", () => {
|
||||
configFileContent = JSON.stringify({
|
||||
npm: {
|
||||
customRegistries: ["npm.company.com", "registry.internal.net"],
|
||||
},
|
||||
});
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, [
|
||||
"npm.company.com",
|
||||
"registry.internal.net",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should strip https:// protocol from registries", () => {
|
||||
configFileContent = JSON.stringify({
|
||||
npm: {
|
||||
customRegistries: [
|
||||
"https://npm.company.com",
|
||||
"https://registry.internal.net",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, [
|
||||
"npm.company.com",
|
||||
"registry.internal.net",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should strip http:// protocol from registries", () => {
|
||||
configFileContent = JSON.stringify({
|
||||
npm: {
|
||||
customRegistries: [
|
||||
"http://npm.company.com",
|
||||
"http://registry.internal.net",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, [
|
||||
"npm.company.com",
|
||||
"registry.internal.net",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle mixed protocols and no protocol", () => {
|
||||
configFileContent = JSON.stringify({
|
||||
npm: {
|
||||
customRegistries: [
|
||||
"https://npm.company.com",
|
||||
"registry.internal.net",
|
||||
"http://private.registry.io",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, [
|
||||
"npm.company.com",
|
||||
"registry.internal.net",
|
||||
"private.registry.io",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should preserve registry path after stripping protocol", () => {
|
||||
configFileContent = JSON.stringify({
|
||||
npm: {
|
||||
customRegistries: [
|
||||
"https://npm.company.com/custom/path",
|
||||
"registry.internal.net/npm",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, [
|
||||
"npm.company.com/custom/path",
|
||||
"registry.internal.net/npm",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should parse comma-separated registries from environment variable", () => {
|
||||
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
|
||||
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES =
|
||||
"env1.registry.com,env2.registry.net";
|
||||
configFileContent = undefined;
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, [
|
||||
"env1.registry.com",
|
||||
"env2.registry.net",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should trim whitespace from environment variable registries", () => {
|
||||
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
|
||||
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES =
|
||||
" env1.registry.com , env2.registry.net ";
|
||||
configFileContent = undefined;
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, [
|
||||
"env1.registry.com",
|
||||
"env2.registry.net",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should merge environment variable and config file registries", () => {
|
||||
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
|
||||
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "env1.registry.com";
|
||||
configFileContent = JSON.stringify({
|
||||
npm: {
|
||||
customRegistries: ["config1.registry.net"],
|
||||
},
|
||||
});
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, [
|
||||
"env1.registry.com",
|
||||
"config1.registry.net",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should remove duplicate registries when merging env and config", () => {
|
||||
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
|
||||
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES =
|
||||
"npm.company.com,env.registry.com";
|
||||
configFileContent = JSON.stringify({
|
||||
npm: {
|
||||
customRegistries: ["npm.company.com", "config.registry.net"],
|
||||
},
|
||||
});
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, [
|
||||
"npm.company.com",
|
||||
"env.registry.com",
|
||||
"config.registry.net",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should normalize protocols from environment variable registries", () => {
|
||||
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
|
||||
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES =
|
||||
"https://env1.registry.com,http://env2.registry.net";
|
||||
configFileContent = undefined;
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, [
|
||||
"env1.registry.com",
|
||||
"env2.registry.net",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle empty strings in comma-separated list", () => {
|
||||
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
|
||||
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES =
|
||||
"env1.registry.com,,env2.registry.net,";
|
||||
configFileContent = undefined;
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, [
|
||||
"env1.registry.com",
|
||||
"env2.registry.net",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle single registry in environment variable", () => {
|
||||
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
|
||||
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "single.registry.com";
|
||||
configFileContent = undefined;
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, ["single.registry.com"]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty environment variable", () => {
|
||||
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
|
||||
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "";
|
||||
configFileContent = undefined;
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, []);
|
||||
});
|
||||
|
||||
it("should return empty array for whitespace-only environment variable", () => {
|
||||
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
|
||||
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = " , , ";
|
||||
configFileContent = undefined;
|
||||
|
||||
const registries = getNpmCustomRegistries();
|
||||
|
||||
assert.deepStrictEqual(registries, []);
|
||||
});
|
||||
});
|
||||
|
|
@ -23,6 +23,7 @@ export async function main(args) {
|
|||
process.on("uncaughtException", (error) => {
|
||||
ui.writeError(`Safe-chain: Uncaught exception: ${error.message}`);
|
||||
ui.writeVerbose(`Stack trace: ${error.stack}`);
|
||||
ui.writeBufferedLogsAndStopBuffering();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ export async function main(args) {
|
|||
if (reason instanceof Error) {
|
||||
ui.writeVerbose(`Stack trace: ${reason.stack}`);
|
||||
}
|
||||
ui.writeBufferedLogsAndStopBuffering();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
|
@ -89,6 +91,7 @@ export async function main(args) {
|
|||
return packageManagerResult.status;
|
||||
} catch (/** @type any */ error) {
|
||||
ui.writeError("Failed to check for malicious packages:", error.message);
|
||||
ui.writeBufferedLogsAndStopBuffering();
|
||||
|
||||
// Returning the exit code back to the caller allows the promise
|
||||
// to be awaited in the bin files and return the correct exit code
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { createYarnPackageManager } from "./yarn/createPackageManager.js";
|
|||
import { createPipPackageManager } from "./pip/createPackageManager.js";
|
||||
import { createUvPackageManager } from "./uv/createUvPackageManager.js";
|
||||
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
|
||||
import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js";
|
||||
|
||||
/**
|
||||
* @type {{packageManagerName: PackageManager | null}}
|
||||
|
|
@ -61,6 +62,8 @@ export function initializePackageManager(packageManagerName, context) {
|
|||
state.packageManagerName = createUvPackageManager();
|
||||
} else if (packageManagerName === "poetry") {
|
||||
state.packageManagerName = createPoetryPackageManager();
|
||||
} else if (packageManagerName === "pipx") {
|
||||
state.packageManagerName = createPipXPackageManager();
|
||||
} else {
|
||||
throw new Error("Unsupported package manager: " + packageManagerName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import fsSync from "node:fs";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import ini from "ini";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
/**
|
||||
* Checks if this pip invocation should bypass safe-chain and spawn directly.
|
||||
|
|
@ -16,7 +17,7 @@ import ini from "ini";
|
|||
* @param {string[]} args - The arguments
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shouldBypassSafeChain(command, args) {
|
||||
export function shouldBypassSafeChain(command, args) {
|
||||
if (command === PYTHON_COMMAND || command === PYTHON3_COMMAND) {
|
||||
// Check if args start with -m pip
|
||||
if (args.length >= 2 && args[0] === "-m" && (args[1] === PIP_COMMAND || args[1] === PIP3_COMMAND)) {
|
||||
|
|
@ -77,14 +78,16 @@ export async function runPip(command, args) {
|
|||
if (shouldBypassSafeChain(command, args)) {
|
||||
ui.writeVerbose(`Safe-chain: Bypassing safe-chain for non-pip invocation: ${command} ${args.join(" ")}`);
|
||||
// Spawn the ORIGINAL command with ORIGINAL args
|
||||
const { spawn } = await import("child_process");
|
||||
return new Promise((_resolve) => {
|
||||
const proc = spawn(command, args, { stdio: "inherit" });
|
||||
proc.on("exit", (/** @type {number | null} */ code) => {
|
||||
ui.writeVerbose(`${command} ${args.join(" ")} exited with status ${code}`);
|
||||
ui.writeBufferedLogsAndStopBuffering();
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
proc.on("error", (/** @type {Error} */ err) => {
|
||||
ui.writeError(`Error executing command: ${err.message}`);
|
||||
ui.writeBufferedLogsAndStopBuffering();
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -93,7 +96,7 @@ export async function runPip(command, args) {
|
|||
try {
|
||||
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
|
||||
|
||||
// Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots)
|
||||
// Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots + user certs)
|
||||
// so that any network request made by pip, including those outside explicit CLI args,
|
||||
// validates correctly under both MITM'd and tunneled HTTPS.
|
||||
const combinedCaPath = getCombinedCaBundlePath();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import ini from "ini";
|
|||
|
||||
describe("runPipCommand environment variable handling", () => {
|
||||
let runPip;
|
||||
let shouldBypassSafeChain;
|
||||
let capturedArgs = null;
|
||||
let customEnv = null;
|
||||
let capturedConfigContent = null; // Capture config file content before cleanup
|
||||
|
|
@ -56,6 +57,7 @@ describe("runPipCommand environment variable handling", () => {
|
|||
|
||||
const mod = await import("./runPipCommand.js");
|
||||
runPip = mod.runPip;
|
||||
shouldBypassSafeChain = mod.shouldBypassSafeChain;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -66,14 +68,14 @@ describe("runPipCommand environment variable handling", () => {
|
|||
const res = await runPip("pip3", ["config", "set", "global.index-url", "https://test.pypi.org/simple"]);
|
||||
assert.strictEqual(res.status, 0);
|
||||
assert.ok(capturedArgs, "safeSpawn should have been called");
|
||||
|
||||
|
||||
// PIP_CONFIG_FILE should NOT be set for config commands
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.PIP_CONFIG_FILE,
|
||||
undefined,
|
||||
"PIP_CONFIG_FILE should NOT be set for pip config commands"
|
||||
);
|
||||
|
||||
|
||||
// But CA environment variables should still be set
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
||||
|
|
@ -96,7 +98,7 @@ describe("runPipCommand environment variable handling", () => {
|
|||
const res = await runPip("pip3", ["config", "get", "global.index-url"]);
|
||||
assert.strictEqual(res.status, 0);
|
||||
assert.ok(capturedArgs, "safeSpawn should have been called");
|
||||
|
||||
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.PIP_CONFIG_FILE,
|
||||
undefined,
|
||||
|
|
@ -108,7 +110,7 @@ describe("runPipCommand environment variable handling", () => {
|
|||
const res = await runPip("pip3", ["config", "list"]);
|
||||
assert.strictEqual(res.status, 0);
|
||||
assert.ok(capturedArgs, "safeSpawn should have been called");
|
||||
|
||||
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.PIP_CONFIG_FILE,
|
||||
undefined,
|
||||
|
|
@ -120,13 +122,13 @@ describe("runPipCommand environment variable handling", () => {
|
|||
const res = await runPip("pip3", ["cache", "dir"]);
|
||||
assert.strictEqual(res.status, 0);
|
||||
assert.ok(capturedArgs, "safeSpawn should have been called");
|
||||
|
||||
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.PIP_CONFIG_FILE,
|
||||
undefined,
|
||||
"PIP_CONFIG_FILE should NOT be set for pip cache commands"
|
||||
);
|
||||
|
||||
|
||||
// CA env vars should still be set
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.SSL_CERT_FILE,
|
||||
|
|
@ -139,7 +141,7 @@ describe("runPipCommand environment variable handling", () => {
|
|||
const res = await runPip("pip3", ["debug"]);
|
||||
assert.strictEqual(res.status, 0);
|
||||
assert.ok(capturedArgs, "safeSpawn should have been called");
|
||||
|
||||
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.PIP_CONFIG_FILE,
|
||||
undefined,
|
||||
|
|
@ -151,7 +153,7 @@ describe("runPipCommand environment variable handling", () => {
|
|||
const res = await runPip("pip3", ["completion", "--bash"]);
|
||||
assert.strictEqual(res.status, 0);
|
||||
assert.ok(capturedArgs, "safeSpawn should have been called");
|
||||
|
||||
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.PIP_CONFIG_FILE,
|
||||
undefined,
|
||||
|
|
@ -181,7 +183,7 @@ describe("runPipCommand environment variable handling", () => {
|
|||
assert.strictEqual(res.status, 0);
|
||||
|
||||
assert.ok(capturedArgs, "safeSpawn should have been called");
|
||||
|
||||
|
||||
// Check environment variables are set
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
||||
|
|
@ -218,7 +220,7 @@ describe("runPipCommand environment variable handling", () => {
|
|||
// For default PyPI, we still set env vars; pip CLI --cert takes precedence
|
||||
const res = await runPip("pip3", ["install", "requests"]);
|
||||
assert.strictEqual(res.status, 0);
|
||||
|
||||
|
||||
// Environment variables still set (pip CLI --cert takes precedence)
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
||||
|
|
@ -233,7 +235,7 @@ describe("runPipCommand environment variable handling", () => {
|
|||
it("should preserve HTTPS_PROXY from proxy merge", async () => {
|
||||
const res = await runPip("pip3", ["install", "requests"]);
|
||||
assert.strictEqual(res.status, 0);
|
||||
|
||||
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.HTTPS_PROXY,
|
||||
"http://localhost:8080",
|
||||
|
|
@ -380,7 +382,7 @@ describe("runPipCommand environment variable handling", () => {
|
|||
await fs.writeFile(cfgPath, initialIni, "utf-8");
|
||||
|
||||
customEnv = { PIP_CONFIG_FILE: cfgPath };
|
||||
|
||||
|
||||
// Capture stdout/stderr
|
||||
let output = "";
|
||||
const originalWrite = process.stdout.write;
|
||||
|
|
@ -397,4 +399,21 @@ describe("runPipCommand environment variable handling", () => {
|
|||
assert.ok(output.includes("proxy found in PIP_CONFIG_FILE"), "Should warn about proxy overwrite in output");
|
||||
customEnv = null;
|
||||
});
|
||||
|
||||
it("should bypass safe-chain for python correctly", async () => {
|
||||
assert.strictEqual(shouldBypassSafeChain("python", []), true);
|
||||
assert.strictEqual(shouldBypassSafeChain("python3", []), true);
|
||||
|
||||
assert.strictEqual(shouldBypassSafeChain("python", ["--version"]), true);
|
||||
assert.strictEqual(shouldBypassSafeChain("python3", ["--version"]), true);
|
||||
|
||||
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "http.server"]), true);
|
||||
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "http.server"]), true);
|
||||
|
||||
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip"]), false);
|
||||
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip"]), false);
|
||||
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip3"]), false);
|
||||
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip3"]), false);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { runPipX } from "./runPipXCommand.js";
|
||||
|
||||
/**
|
||||
* @returns {import("../currentPackageManager.js").PackageManager}
|
||||
*/
|
||||
export function createPipXPackageManager() {
|
||||
return {
|
||||
/**
|
||||
* @param {string[]} args
|
||||
*/
|
||||
runCommand: (args) => {
|
||||
return runPipX("pipx", args);
|
||||
},
|
||||
// MITM only
|
||||
isSupportedCommand: () => false,
|
||||
getDependencyUpdatesForCommand: () => [],
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { createPipXPackageManager } from "./createPipXPackageManager.js";
|
||||
|
||||
test("createPipXPackageManager", async (t) => {
|
||||
await t.test("should create package manager with required interface", () => {
|
||||
const pm = createPipXPackageManager();
|
||||
|
||||
assert.ok(pm);
|
||||
assert.strictEqual(typeof pm.runCommand, "function");
|
||||
assert.strictEqual(typeof pm.isSupportedCommand, "function");
|
||||
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
|
||||
|
||||
/**
|
||||
* Sets CA bundle environment variables used by Python libraries and pipx.
|
||||
*
|
||||
* @param {NodeJS.ProcessEnv} env - Env object
|
||||
* @param {string} combinedCaPath - Path to the combined CA bundle
|
||||
* @return {NodeJS.ProcessEnv} Modified environment object
|
||||
*/
|
||||
function getPipXCaBundleEnvironmentVariables(env, combinedCaPath) {
|
||||
let retVal = { ...env };
|
||||
|
||||
if (env.SSL_CERT_FILE) {
|
||||
ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.");
|
||||
}
|
||||
retVal.SSL_CERT_FILE = combinedCaPath;
|
||||
|
||||
if (env.REQUESTS_CA_BUNDLE) {
|
||||
ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.");
|
||||
}
|
||||
retVal.REQUESTS_CA_BUNDLE = combinedCaPath;
|
||||
|
||||
if (env.PIP_CERT) {
|
||||
ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.");
|
||||
}
|
||||
retVal.PIP_CERT = combinedCaPath;
|
||||
return retVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a pipx command with safe-chain's certificate bundle and proxy configuration.
|
||||
*
|
||||
* @param {string} command - The command to execute
|
||||
* @param {string[]} args - Command line arguments
|
||||
* @returns {Promise<{status: number}>} Exit status of the command
|
||||
*/
|
||||
export async function runPipX(command, args) {
|
||||
try {
|
||||
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
|
||||
|
||||
const combinedCaPath = getCombinedCaBundlePath();
|
||||
const modifiedEnv = getPipXCaBundleEnvironmentVariables(env, combinedCaPath);
|
||||
|
||||
// Note: pipx uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration
|
||||
// These are already set by mergeSafeChainProxyEnvironmentVariables
|
||||
|
||||
const result = await safeSpawn(command, args, {
|
||||
stdio: "inherit",
|
||||
env: modifiedEnv,
|
||||
});
|
||||
|
||||
return { status: result.status };
|
||||
} catch (/** @type any */ error) {
|
||||
if (error.status) {
|
||||
return { status: error.status };
|
||||
} else {
|
||||
ui.writeError(`Error executing command: ${error.message}`);
|
||||
ui.writeError(`Is '${command}' installed and available on your system?`);
|
||||
return { status: 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("runPipXCommand", () => {
|
||||
let runPipX;
|
||||
let safeSpawnMock;
|
||||
let warnMock;
|
||||
let errorMock;
|
||||
let mergeCalls;
|
||||
let mergedEnvReturn;
|
||||
|
||||
beforeEach(async () => {
|
||||
mergeCalls = [];
|
||||
mergedEnvReturn = {
|
||||
HTTPS_PROXY: "http://localhost:8080",
|
||||
HTTP_PROXY: "",
|
||||
};
|
||||
|
||||
safeSpawnMock = mock.fn(async () => ({ status: 0 }));
|
||||
warnMock = mock.fn();
|
||||
errorMock = mock.fn();
|
||||
|
||||
mock.module("../../environment/userInteraction.js", {
|
||||
namedExports: {
|
||||
ui: {
|
||||
writeWarning: warnMock,
|
||||
writeError: errorMock,
|
||||
writeInfo: () => {},
|
||||
writeVerbose: () => {},
|
||||
writeSuccess: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../registryProxy/registryProxy.js", {
|
||||
namedExports: {
|
||||
mergeSafeChainProxyEnvironmentVariables: (env) => {
|
||||
mergeCalls.push(env);
|
||||
return { ...env, ...mergedEnvReturn };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../registryProxy/certBundle.js", {
|
||||
namedExports: {
|
||||
getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem",
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../utils/safeSpawn.js", {
|
||||
namedExports: {
|
||||
safeSpawn: safeSpawnMock,
|
||||
},
|
||||
});
|
||||
|
||||
const mod = await import("./runPipXCommand.js");
|
||||
runPipX = mod.runPipX;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
it("sets CA env vars and proxies before spawning", async () => {
|
||||
const res = await runPipX("pipx", ["install", "ruff"]);
|
||||
|
||||
assert.strictEqual(res.status, 0);
|
||||
assert.strictEqual(safeSpawnMock.mock.calls.length, 1, "safeSpawn should be called once");
|
||||
|
||||
const [, , options] = safeSpawnMock.mock.calls[0].arguments;
|
||||
const env = options.env;
|
||||
|
||||
assert.strictEqual(env.SSL_CERT_FILE, "/tmp/test-combined-ca.pem");
|
||||
assert.strictEqual(env.REQUESTS_CA_BUNDLE, "/tmp/test-combined-ca.pem");
|
||||
assert.strictEqual(env.PIP_CERT, "/tmp/test-combined-ca.pem");
|
||||
assert.strictEqual(env.HTTPS_PROXY, "http://localhost:8080");
|
||||
assert.strictEqual(env.HTTP_PROXY, "");
|
||||
assert.ok(mergeCalls.length >= 1, "proxy merge should be invoked");
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ import certifi from "certifi";
|
|||
import tls from "node:tls";
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { getCaCertPath } from "./certUtils.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
|
||||
/**
|
||||
* Check if a PEM string contains only parsable cert blocks.
|
||||
|
|
@ -14,6 +15,7 @@ import { getCaCertPath } from "./certUtils.js";
|
|||
*/
|
||||
function isParsable(pem) {
|
||||
if (!pem || typeof pem !== "string") return false;
|
||||
pem = normalizeLineEndings(pem);
|
||||
const begin = "-----BEGIN CERTIFICATE-----";
|
||||
const end = "-----END CERTIFICATE-----";
|
||||
const blocks = [];
|
||||
|
|
@ -41,20 +43,17 @@ function isParsable(pem) {
|
|||
}
|
||||
}
|
||||
|
||||
/** @type {string | null} */
|
||||
let cachedPath = null;
|
||||
|
||||
/**
|
||||
* Build a combined CA bundle for Python and Node HTTPS flows.
|
||||
* - Includes Safe Chain CA (for MITM of known registries)
|
||||
* - Includes Mozilla roots via npm `certifi` (public HTTPS)
|
||||
* - Includes Node's built-in root certificates as a portable fallback
|
||||
* Build a combined CA bundle.
|
||||
* Automatically includes:
|
||||
* - Safe Chain CA (for MITM of known registries)
|
||||
* - Mozilla roots via certifi (for public HTTPS)
|
||||
* - Node's built-in root certificates (fallback)
|
||||
* - User's custom certificates (if NODE_EXTRA_CA_CERTS environment variable is set)
|
||||
*
|
||||
* @returns {string} Path to the combined CA bundle PEM file
|
||||
*/
|
||||
export function getCombinedCaBundlePath() {
|
||||
if (cachedPath && fs.existsSync(cachedPath)) return cachedPath;
|
||||
|
||||
// Concatenate PEM files
|
||||
const parts = [];
|
||||
|
||||
// 1) Safe Chain CA (for MITM'd registries)
|
||||
|
|
@ -87,9 +86,96 @@ export function getCombinedCaBundlePath() {
|
|||
// Ignore if unavailable
|
||||
}
|
||||
|
||||
// 4) User's NODE_EXTRA_CA_CERTS (if set)
|
||||
const userCertPath = process.env.NODE_EXTRA_CA_CERTS;
|
||||
if (userCertPath) {
|
||||
const userPem = readUserCertificateFile(userCertPath);
|
||||
if (userPem) {
|
||||
parts.push(userPem.trim());
|
||||
ui.writeVerbose(`Safe-chain: Merging user's NODE_EXTRA_CA_CERTS from ${userCertPath}`);
|
||||
} else {
|
||||
ui.writeWarning(`Safe-chain: Could not read or parse user's NODE_EXTRA_CA_CERTS from ${userCertPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const combined = parts.filter(Boolean).join("\n");
|
||||
const target = path.join(os.tmpdir(), "safe-chain-ca-bundle.pem");
|
||||
const target = path.join(os.tmpdir(), `safe-chain-ca-bundle-${Date.now()}.pem`);
|
||||
fs.writeFileSync(target, combined, { encoding: "utf8" });
|
||||
cachedPath = target;
|
||||
return cachedPath;
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path
|
||||
* @param {string} p - Path to normalize
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizePathF(p) {
|
||||
return p.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize line endings to LF
|
||||
* @param {string} text - Text with mixed line endings
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeLineEndings(text) {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate user certificate file
|
||||
* @param {string} certPath - Path to certificate file
|
||||
* @returns {string | null} Certificate PEM content or null if invalid/unreadable
|
||||
*/
|
||||
function readUserCertificateFile(certPath) {
|
||||
try {
|
||||
// 1) Basic validation
|
||||
if (typeof certPath !== "string" || certPath.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2) Reject path traversal attempts (normalize backslashes first for Windows paths)
|
||||
const normalizedPath = normalizePathF(certPath);
|
||||
if (normalizedPath.includes("..")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3) Check if file exists and is not a directory or symlink
|
||||
let stats;
|
||||
try {
|
||||
stats = fs.lstatSync(certPath);
|
||||
} catch {
|
||||
// File doesn't exist or can't be accessed
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stats.isFile()) {
|
||||
// Reject directories and symlinks
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4) Read file content
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(certPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!content || typeof content !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5) Validate PEM format
|
||||
if (!isParsable(content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return content;
|
||||
} catch {
|
||||
// Silently fail on any errors
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ function removeBundleIfExists() {
|
|||
}
|
||||
}
|
||||
|
||||
// Utility to get a valid PEM certificate for testing
|
||||
function getValidCert() {
|
||||
const cert = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : "";
|
||||
assert.ok(cert.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test");
|
||||
return cert;
|
||||
}
|
||||
|
||||
describe("certBundle.getCombinedCaBundlePath", () => {
|
||||
beforeEach(() => {
|
||||
mock.restoreAll();
|
||||
|
|
@ -69,3 +76,304 @@ describe("certBundle.getCombinedCaBundlePath", () => {
|
|||
assert.ok(!contents.includes(invalidMarker), "Bundle should not include invalid Safe Chain content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("certBundle.getCombinedCaBundlePath with user certs", () => {
|
||||
beforeEach(() => {
|
||||
mock.restoreAll();
|
||||
delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
});
|
||||
|
||||
it("returns a path with full CA bundle (Safe Chain + Mozilla + Node roots) when no user cert in env", async () => {
|
||||
// Mock getCaCertPath to return valid cert
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain certificate blocks");
|
||||
// Should include base bundle (Safe Chain + Mozilla/Node roots)
|
||||
assert.ok(contents.length > 1000, "Bundle should be substantial with Mozilla/Node roots included");
|
||||
});
|
||||
|
||||
it("merges user cert with full base bundle (Safe Chain CA + Mozilla + Node roots)", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
|
||||
// Create Safe Chain CA
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
const safeChainCert = getValidCert();
|
||||
fs.writeFileSync(safeChainPath, safeChainCert, "utf8");
|
||||
|
||||
// Create user cert file
|
||||
const userCertPath = path.join(tmpDir, "user-cert.pem");
|
||||
const userCert = getValidCert();
|
||||
fs.writeFileSync(userCertPath, userCert, "utf8");
|
||||
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
|
||||
// Both certs should be in the bundle
|
||||
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
|
||||
assert.ok(certCount >= 2, "Bundle should contain both Safe Chain and user certificates");
|
||||
});
|
||||
|
||||
it("ignores non-existent user cert path", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
process.env.NODE_EXTRA_CA_CERTS = "/nonexistent/path.pem";
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
// Should still have Safe Chain CA
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
});
|
||||
|
||||
it("ignores invalid PEM user cert", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
const userCertPath = path.join(tmpDir, "invalid.pem");
|
||||
fs.writeFileSync(userCertPath, "NOT A VALID PEM", "utf8");
|
||||
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
// Should still have Safe Chain CA only
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
assert.ok(!contents.includes("NOT A VALID"), "Should not include invalid cert");
|
||||
});
|
||||
|
||||
it("rejects user cert with path traversal attempts", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
process.env.NODE_EXTRA_CA_CERTS = "../../../etc/passwd";
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
// Should only have Safe Chain CA, rejected the traversal path
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
});
|
||||
|
||||
it("rejects user cert with symlink", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
// Create a target file and a symlink to it
|
||||
const targetCert = path.join(tmpDir, "target.pem");
|
||||
fs.writeFileSync(targetCert, getValidCert(), "utf8");
|
||||
|
||||
const symlinkPath = path.join(tmpDir, "symlink.pem");
|
||||
try {
|
||||
fs.symlinkSync(targetCert, symlinkPath);
|
||||
} catch {
|
||||
// Skip test if symlinks are not supported (e.g., on Windows without admin)
|
||||
return;
|
||||
}
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
process.env.NODE_EXTRA_CA_CERTS = symlinkPath;
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
// Should only have Safe Chain CA, symlinks are rejected
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
});
|
||||
|
||||
it("rejects user cert that is a directory", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
const certDir = path.join(tmpDir, "certs");
|
||||
fs.mkdirSync(certDir);
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
process.env.NODE_EXTRA_CA_CERTS = certDir;
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
// Should only have Safe Chain CA
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
});
|
||||
|
||||
it("handles empty string user cert path", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
process.env.NODE_EXTRA_CA_CERTS = " ";
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
});
|
||||
|
||||
it("accepts files with CRLF line endings (Windows-style)", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
// Create a real file with CRLF content to test Windows line ending support
|
||||
const userCertPath = path.join(tmpDir, "user-cert-crlf.pem");
|
||||
const userCert = getValidCert();
|
||||
const certWithCRLF = userCert.replace(/\n/g, "\r\n");
|
||||
fs.writeFileSync(userCertPath, certWithCRLF, "utf8");
|
||||
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
|
||||
assert.ok(certCount >= 2, "Bundle should contain Safe Chain and user certificates with CRLF");
|
||||
});
|
||||
|
||||
it("detects and handles Windows-style path syntax (drive letters and UNC)", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
|
||||
// Test that Windows path syntax is recognized (even if files don't exist on macOS/Linux)
|
||||
// These should gracefully fail (return Safe Chain CA only) rather than crash
|
||||
const winPaths = [
|
||||
"C:\\temp\\cert.pem",
|
||||
"D:\\Users\\name\\certs\\ca.pem",
|
||||
"\\\\server\\share\\cert.pem"
|
||||
];
|
||||
|
||||
for (const winPath of winPaths) {
|
||||
process.env.NODE_EXTRA_CA_CERTS = winPath;
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
assert.ok(fs.existsSync(bundlePath), `Bundle should exist for ${winPath}`);
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects path traversal with Windows-style paths (C:\\temp\\..\\etc\\passwd)", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
|
||||
// Test various Windows-style traversal attempts
|
||||
const traversalPaths = [
|
||||
"C:\\temp\\..\\etc\\passwd",
|
||||
"D:\\Users\\..\\..\\Windows\\System32",
|
||||
"\\\\server\\share\\..\\admin",
|
||||
"../../../etc/passwd", // Unix-style for comparison
|
||||
];
|
||||
|
||||
// First, get baseline bundle without user certs to know expected cert count
|
||||
delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
const baselineBundlePath = getCombinedCaBundlePath();
|
||||
const baselineContents = fs.readFileSync(baselineBundlePath, "utf8");
|
||||
const baselineCertCount = (baselineContents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
|
||||
|
||||
for (const badPath of traversalPaths) {
|
||||
process.env.NODE_EXTRA_CA_CERTS = badPath;
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
// Should contain base bundle (Safe Chain + Mozilla + Node roots) but NOT user cert
|
||||
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
|
||||
assert.strictEqual(certCount, baselineCertCount, `Traversal path ${badPath} should be rejected; base bundle only (no user cert added)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { skipMinimumPackageAge } from "../../../config/settings.js";
|
||||
import {
|
||||
getNpmCustomRegistries,
|
||||
skipMinimumPackageAge,
|
||||
} from "../../../config/settings.js";
|
||||
import { isMalwarePackage } from "../../../scanning/audit/index.js";
|
||||
import { interceptRequests } from "../interceptorBuilder.js";
|
||||
import {
|
||||
|
|
@ -8,14 +11,20 @@ import {
|
|||
} from "./modifyNpmInfo.js";
|
||||
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
|
||||
|
||||
const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
|
||||
const knownJsRegistries = [
|
||||
"registry.npmjs.org",
|
||||
"registry.yarnpkg.com",
|
||||
"registry.npmjs.com",
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
export function npmInterceptorForUrl(url) {
|
||||
const registry = knownJsRegistries.find((reg) => url.includes(reg));
|
||||
const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find(
|
||||
(reg) => url.includes(reg)
|
||||
);
|
||||
|
||||
if (registry) {
|
||||
return buildNpmInterceptor(registry);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ describe("npmInterceptor minimum package age", async () => {
|
|||
namedExports: {
|
||||
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
|
||||
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
||||
getNpmCustomRegistries: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,36 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("npmInterceptor", async () => {
|
||||
let lastPackage;
|
||||
let malwareResponse = false;
|
||||
let lastPackage;
|
||||
let malwareResponse = false;
|
||||
let customRegistries = [];
|
||||
|
||||
mock.module("../../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async (packageName, version) => {
|
||||
lastPackage = { packageName, version };
|
||||
return malwareResponse;
|
||||
},
|
||||
mock.module("../../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async (packageName, version) => {
|
||||
lastPackage = { packageName, version };
|
||||
return malwareResponse;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../config/settings.js", {
|
||||
namedExports: {
|
||||
LOGGING_SILENT: "silent",
|
||||
LOGGING_NORMAL: "normal",
|
||||
LOGGING_VERBOSE: "verbose",
|
||||
ECOSYSTEM_JS: "js",
|
||||
ECOSYSTEM_PY: "py",
|
||||
getLoggingLevel: () => "normal",
|
||||
getEcoSystem: () => "js",
|
||||
setEcoSystem: () => {},
|
||||
getMinimumPackageAgeHours: () => 24,
|
||||
getNpmCustomRegistries: () => customRegistries,
|
||||
skipMinimumPackageAge: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
describe("npmInterceptor", async () => {
|
||||
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
|
||||
|
||||
const parserCases = [
|
||||
|
|
@ -161,3 +178,90 @@ describe("npmInterceptor", async () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("npmInterceptor with custom registries", async () => {
|
||||
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
|
||||
|
||||
it("should create interceptor for custom registry", async () => {
|
||||
// Set custom registries for this test
|
||||
customRegistries = ["npm.company.com", "registry.internal.net"];
|
||||
const url = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
|
||||
assert.ok(interceptor, "Interceptor should be created for custom registry");
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "lodash",
|
||||
version: "4.17.21",
|
||||
});
|
||||
});
|
||||
|
||||
it("should create interceptor for custom registry with scoped packages", async () => {
|
||||
// Set custom registries for this test
|
||||
customRegistries = ["npm.company.com", "registry.internal.net"];
|
||||
malwareResponse = false;
|
||||
|
||||
const url =
|
||||
"https://registry.internal.net/@company/package/-/package-1.0.0.tgz";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
|
||||
assert.ok(
|
||||
interceptor,
|
||||
"Interceptor should be created for custom registry with scoped package"
|
||||
);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "@company/package",
|
||||
version: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple custom registries", async () => {
|
||||
// Set custom registries for this test
|
||||
customRegistries = ["npm.company.com", "registry.internal.net"];
|
||||
malwareResponse = false;
|
||||
|
||||
const url1 = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz";
|
||||
const url2 = "https://registry.internal.net/express/-/express-4.18.2.tgz";
|
||||
|
||||
const interceptor1 = npmInterceptorForUrl(url1);
|
||||
const interceptor2 = npmInterceptorForUrl(url2);
|
||||
|
||||
assert.ok(interceptor1, "Should create interceptor for first registry");
|
||||
assert.ok(interceptor2, "Should create interceptor for second registry");
|
||||
|
||||
await interceptor1.handleRequest(url1);
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "lodash",
|
||||
version: "4.17.21",
|
||||
});
|
||||
|
||||
await interceptor2.handleRequest(url2);
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "express",
|
||||
version: "4.18.2",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create interceptor for non-custom registry", () => {
|
||||
// Set custom registries for this test
|
||||
customRegistries = ["npm.company.com", "registry.internal.net"];
|
||||
malwareResponse = false;
|
||||
|
||||
const url = "https://unknown.registry.com/package/-/package-1.0.0.tgz";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
|
||||
assert.equal(
|
||||
interceptor,
|
||||
undefined,
|
||||
"Should not create interceptor for unknown registry"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -182,13 +182,13 @@ describe("registryProxy.connectTunnel", () => {
|
|||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Should return 502 Bad Gateway
|
||||
// Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts)
|
||||
assert.ok(
|
||||
responseData.includes("HTTP/1.1 502 Bad Gateway"),
|
||||
"Should return 502 for timeout"
|
||||
responseData.includes("HTTP/1.1 504 Gateway Timeout"),
|
||||
"Should return 504 for timeout"
|
||||
);
|
||||
|
||||
// Should timeout around 3 seconds for IMDS endpoints (allow some margin)
|
||||
// Should timeout around 100ms for IMDS endpoints (allow some margin)
|
||||
assert.ok(
|
||||
duration >= 80 && duration < 200,
|
||||
`IMDS timeout should be ~80-200ms, got ${duration}ms`
|
||||
|
|
@ -280,10 +280,10 @@ describe("registryProxy.connectTunnel", () => {
|
|||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Should return 502 Bad Gateway (timeout)
|
||||
// Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts)
|
||||
assert.ok(
|
||||
responseData.includes("HTTP/1.1 502 Bad Gateway"),
|
||||
"Should return 502 for timeout"
|
||||
responseData.includes("HTTP/1.1 504 Gateway Timeout"),
|
||||
"Should return 504 for timeout"
|
||||
);
|
||||
|
||||
// Should NOT be instant - it should retry the connection (taking ~500ms due to mock timeout)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import * as http from "http";
|
|||
import { tunnelRequest } from "./tunnelRequestHandler.js";
|
||||
import { mitmConnect } from "./mitmRequestHandler.js";
|
||||
import { handleHttpProxyRequest } from "./plainHttpProxy.js";
|
||||
import { getCaCertPath } from "./certUtils.js";
|
||||
import { getCombinedCaBundlePath } from "./certBundle.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import chalk from "chalk";
|
||||
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
|
||||
|
|
@ -37,10 +37,12 @@ function getSafeChainProxyEnvironmentVariables() {
|
|||
}
|
||||
|
||||
const proxyUrl = `http://localhost:${state.port}`;
|
||||
const caCertPath = getCombinedCaBundlePath();
|
||||
|
||||
return {
|
||||
HTTPS_PROXY: proxyUrl,
|
||||
GLOBAL_AGENT_HTTP_PROXY: proxyUrl,
|
||||
NODE_EXTRA_CA_CERTS: getCaCertPath(),
|
||||
NODE_EXTRA_CA_CERTS: caCertPath,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export function tunnelRequest(req, clientSocket, head) {
|
|||
function tunnelRequestToDestination(req, clientSocket, head) {
|
||||
const { port, hostname } = new URL(`http://${req.url}`);
|
||||
const isImds = isImdsEndpoint(hostname);
|
||||
const targetPort = Number.parseInt(port) || 443;
|
||||
|
||||
if (timedoutImdsEndpoints.includes(hostname)) {
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
|
|
@ -58,64 +59,77 @@ function tunnelRequestToDestination(req, clientSocket, head) {
|
|||
return;
|
||||
}
|
||||
|
||||
const serverSocket = net.connect(
|
||||
Number.parseInt(port) || 443,
|
||||
hostname,
|
||||
() => {
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
serverSocket.write(head);
|
||||
serverSocket.pipe(clientSocket);
|
||||
clientSocket.pipe(serverSocket);
|
||||
}
|
||||
);
|
||||
|
||||
// Set explicit connection timeout to avoid waiting for OS default (~2 minutes).
|
||||
// IMDS endpoints get shorter timeout (3s) since they're commonly unreachable outside cloud environments.
|
||||
const connectTimeout = getConnectTimeout(hostname);
|
||||
serverSocket.setTimeout(connectTimeout);
|
||||
|
||||
serverSocket.on("timeout", () => {
|
||||
// Suppress error logging for IMDS endpoints - timeouts are expected when not in cloud
|
||||
// Use JS setTimeout for true connection timeout (not idle timeout).
|
||||
// socket.setTimeout() measures inactivity, not time since connection attempt.
|
||||
const connectTimer = setTimeout(() => {
|
||||
if (isImds) {
|
||||
timedoutImdsEndpoints.push(hostname);
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: connect to ${hostname}:${
|
||||
port || 443
|
||||
} timed out after ${connectTimeout}ms`
|
||||
`Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`
|
||||
);
|
||||
} else {
|
||||
ui.writeError(
|
||||
`Safe-chain: connect to ${hostname}:${
|
||||
port || 443
|
||||
} timed out after ${connectTimeout}ms`
|
||||
`Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`
|
||||
);
|
||||
}
|
||||
serverSocket.destroy(); // Clean up socket to prevent event loop hanging
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
serverSocket.destroy();
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end("HTTP/1.1 504 Gateway Timeout\r\n\r\n");
|
||||
}
|
||||
}, connectTimeout);
|
||||
|
||||
const serverSocket = net.connect(targetPort, hostname, () => {
|
||||
// Clear timer to prevent false timeout errors after successful connection
|
||||
clearTimeout(connectTimer);
|
||||
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
serverSocket.write(head);
|
||||
serverSocket.pipe(clientSocket);
|
||||
clientSocket.pipe(serverSocket);
|
||||
});
|
||||
|
||||
clientSocket.on("error", () => {
|
||||
// This can happen if the client TCP socket sends RST instead of FIN.
|
||||
// Not subscribing to 'error' event will cause node to throw and crash.
|
||||
clearTimeout(connectTimer);
|
||||
if (serverSocket.writable) {
|
||||
serverSocket.end();
|
||||
}
|
||||
});
|
||||
|
||||
clientSocket.on("close", () => {
|
||||
// Client closed connection - clean up server socket
|
||||
clearTimeout(connectTimer);
|
||||
if (serverSocket.writable) {
|
||||
serverSocket.end();
|
||||
}
|
||||
});
|
||||
|
||||
serverSocket.on("error", (err) => {
|
||||
clearTimeout(connectTimer);
|
||||
if (isImds) {
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
|
||||
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`
|
||||
);
|
||||
} else {
|
||||
ui.writeError(
|
||||
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
|
||||
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`
|
||||
);
|
||||
}
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
}
|
||||
});
|
||||
|
||||
serverSocket.on("close", () => {
|
||||
// Server closed connection - clean up client socket
|
||||
clearTimeout(connectTimer);
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -94,6 +94,12 @@ export const knownAikidoTools = [
|
|||
ecoSystem: ECOSYSTEM_PY,
|
||||
internalPackageManagerName: "pip",
|
||||
},
|
||||
{
|
||||
tool: "pipx",
|
||||
aikidoCommand: "aikido-pipx",
|
||||
ecoSystem: ECOSYSTEM_PY,
|
||||
internalPackageManagerName: "pipx",
|
||||
}
|
||||
// When adding a new tool here, also update the documentation for the new tool in the README.md
|
||||
];
|
||||
|
||||
|
|
@ -113,6 +119,20 @@ export function getPackageManagerList() {
|
|||
return `${tools.join(", ")}, and ${lastTool} commands`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getShimsDir() {
|
||||
return path.join(os.homedir(), ".safe-chain", "shims");
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getScriptsDir() {
|
||||
return path.join(os.homedir(), ".safe-chain", "scripts");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} executableName
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import chalk from "chalk";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { getPackageManagerList, knownAikidoTools } from "./helpers.js";
|
||||
import { getPackageManagerList, knownAikidoTools, getShimsDir } from "./helpers.js";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { includePython } from "../config/cliArguments.js";
|
||||
import { ECOSYSTEM_PY } from "../config/settings.js";
|
||||
|
||||
/** @type {string} */
|
||||
// This checks the current file's dirname in a way that's compatible with:
|
||||
|
|
@ -32,7 +30,7 @@ export async function setupCi() {
|
|||
);
|
||||
ui.emptyLine();
|
||||
|
||||
const shimsDir = path.join(os.homedir(), ".safe-chain", "shims");
|
||||
const shimsDir = getShimsDir();
|
||||
const binDir = path.join(os.homedir(), ".safe-chain", "bin");
|
||||
// Create the shims directory if it doesn't exist
|
||||
if (!fs.existsSync(shimsDir)) {
|
||||
|
|
@ -159,12 +157,16 @@ function modifyPathForCi(shimsDir, binDir) {
|
|||
ui.writeInformation("##vso[task.prependpath]" + shimsDir);
|
||||
ui.writeInformation("##vso[task.prependpath]" + binDir);
|
||||
}
|
||||
|
||||
if (process.env.BASH_ENV) {
|
||||
// In CircleCI, persisting PATH across steps is done by appending shell exports
|
||||
// to the file referenced by BASH_ENV. CircleCI sources this file for 'run' each step.
|
||||
const exportLine = `export PATH="${shimsDir}:${binDir}:$PATH"` + os.EOL;
|
||||
fs.appendFileSync(process.env.BASH_ENV, exportLine, "utf-8");
|
||||
ui.writeInformation(`Added shims directory to BASH_ENV for CircleCI.`);
|
||||
}
|
||||
}
|
||||
|
||||
function getToolsToSetup() {
|
||||
if (includePython()) {
|
||||
return knownAikidoTools;
|
||||
} else {
|
||||
return knownAikidoTools.filter((tool) => tool.ecoSystem !== ECOSYSTEM_PY);
|
||||
}
|
||||
return knownAikidoTools;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ describe("Setup CI shell integration", () => {
|
|||
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
|
||||
],
|
||||
getPackageManagerList: () => "npm, yarn",
|
||||
getShimsDir: () => mockShimsDir,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import chalk from "chalk";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { detectShells } from "./shellDetection.js";
|
||||
import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
|
||||
import { knownAikidoTools, getPackageManagerList, getScriptsDir } from "./helpers.js";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { includePython } from "../config/cliArguments.js";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
/** @type {string} */
|
||||
|
|
@ -107,10 +105,10 @@ function setupShell(shell) {
|
|||
|
||||
function copyStartupFiles() {
|
||||
const startupFiles = ["init-posix.sh", "init-pwsh.ps1", "init-fish.fish"];
|
||||
const targetDir = getScriptsDir();
|
||||
|
||||
for (const file of startupFiles) {
|
||||
const targetDir = path.join(os.homedir(), ".safe-chain", "scripts");
|
||||
const targetPath = path.join(os.homedir(), ".safe-chain", "scripts", file);
|
||||
const targetPath = path.join(targetDir, file);
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
|
@ -119,7 +117,7 @@ function copyStartupFiles() {
|
|||
// Use absolute path for source
|
||||
const sourcePath = path.join(
|
||||
dirname,
|
||||
includePython() ? "startup-scripts/include-python" : "startup-scripts",
|
||||
"startup-scripts",
|
||||
file
|
||||
);
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
set -gx PATH $PATH $HOME/.safe-chain/bin
|
||||
|
||||
function npx
|
||||
wrapSafeChainCommand "npx" $argv
|
||||
end
|
||||
|
||||
function yarn
|
||||
wrapSafeChainCommand "yarn" $argv
|
||||
end
|
||||
|
||||
function pnpm
|
||||
wrapSafeChainCommand "pnpm" $argv
|
||||
end
|
||||
|
||||
function pnpx
|
||||
wrapSafeChainCommand "pnpx" $argv
|
||||
end
|
||||
|
||||
function bun
|
||||
wrapSafeChainCommand "bun" $argv
|
||||
end
|
||||
|
||||
function bunx
|
||||
wrapSafeChainCommand "bunx" $argv
|
||||
end
|
||||
|
||||
function npm
|
||||
# If args is just -v or --version and nothing else, just run the `npm -v` command
|
||||
# This is because nvm uses this to check the version of npm
|
||||
set argc (count $argv)
|
||||
if test $argc -eq 1
|
||||
switch $argv[1]
|
||||
case "-v" "--version"
|
||||
command npm $argv
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
wrapSafeChainCommand "npm" $argv
|
||||
end
|
||||
|
||||
|
||||
function pip
|
||||
wrapSafeChainCommand "pip" $argv
|
||||
end
|
||||
|
||||
function pip3
|
||||
wrapSafeChainCommand "pip3" $argv
|
||||
end
|
||||
|
||||
function uv
|
||||
wrapSafeChainCommand "uv" $argv
|
||||
end
|
||||
|
||||
function poetry
|
||||
wrapSafeChainCommand "poetry" $argv
|
||||
end
|
||||
|
||||
# `python -m pip`, `python -m pip3`.
|
||||
function python
|
||||
wrapSafeChainCommand "python" $argv
|
||||
end
|
||||
|
||||
# `python3 -m pip`, `python3 -m pip3'.
|
||||
function python3
|
||||
wrapSafeChainCommand "python3" $argv
|
||||
end
|
||||
|
||||
function printSafeChainWarning
|
||||
set original_cmd $argv[1]
|
||||
|
||||
# Fish equivalent of ANSI color codes: yellow background, black text for "Warning:"
|
||||
set_color -b yellow black
|
||||
printf "Warning:"
|
||||
set_color normal
|
||||
printf " safe-chain is not available to protect you from installing malware. %s will run without it.\n" $original_cmd
|
||||
|
||||
# Cyan text for the install command
|
||||
printf "Install safe-chain by using "
|
||||
set_color cyan
|
||||
printf "npm install -g @aikidosec/safe-chain"
|
||||
set_color normal
|
||||
printf ".\n"
|
||||
end
|
||||
|
||||
function wrapSafeChainCommand
|
||||
set original_cmd $argv[1]
|
||||
set cmd_args $argv[2..-1]
|
||||
|
||||
if type -q safe-chain
|
||||
# If the safe-chain command is available, just run it with the provided arguments
|
||||
safe-chain $original_cmd $cmd_args
|
||||
else
|
||||
# If the safe-chain command is not available, print a warning and run the original command
|
||||
printSafeChainWarning $original_cmd
|
||||
command $original_cmd $cmd_args
|
||||
end
|
||||
end
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
export PATH="$PATH:$HOME/.safe-chain/bin"
|
||||
|
||||
function npx() {
|
||||
wrapSafeChainCommand "npx" "$@"
|
||||
}
|
||||
|
||||
function yarn() {
|
||||
wrapSafeChainCommand "yarn" "$@"
|
||||
}
|
||||
|
||||
function pnpm() {
|
||||
wrapSafeChainCommand "pnpm" "$@"
|
||||
}
|
||||
|
||||
function pnpx() {
|
||||
wrapSafeChainCommand "pnpx" "$@"
|
||||
}
|
||||
|
||||
function bun() {
|
||||
wrapSafeChainCommand "bun" "$@"
|
||||
}
|
||||
|
||||
function bunx() {
|
||||
wrapSafeChainCommand "bunx" "$@"
|
||||
}
|
||||
|
||||
function npm() {
|
||||
if [[ "$1" == "-v" || "$1" == "--version" ]] && [[ $# -eq 1 ]]; then
|
||||
# If args is just -v or --version and nothing else, just run the npm version command
|
||||
# This is because nvm uses this to check the version of npm
|
||||
command npm "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
wrapSafeChainCommand "npm" "$@"
|
||||
}
|
||||
|
||||
|
||||
function pip() {
|
||||
wrapSafeChainCommand "pip" "$@"
|
||||
}
|
||||
|
||||
function pip3() {
|
||||
wrapSafeChainCommand "pip3" "$@"
|
||||
}
|
||||
|
||||
function uv() {
|
||||
wrapSafeChainCommand "uv" "$@"
|
||||
}
|
||||
|
||||
function poetry() {
|
||||
wrapSafeChainCommand "poetry" "$@"
|
||||
}
|
||||
|
||||
# `python -m pip`, `python -m pip3`.
|
||||
function python() {
|
||||
wrapSafeChainCommand "python" "$@"
|
||||
}
|
||||
|
||||
# `python3 -m pip`, `python3 -m pip3'.
|
||||
function python3() {
|
||||
wrapSafeChainCommand "python3" "$@"
|
||||
}
|
||||
|
||||
function printSafeChainWarning() {
|
||||
# \033[43;30m is used to set the background color to yellow and text color to black
|
||||
# \033[0m is used to reset the text formatting
|
||||
printf "\033[43;30mWarning:\033[0m safe-chain is not available to protect you from installing malware. %s will run without it.\n" "$1"
|
||||
# \033[36m is used to set the text color to cyan
|
||||
printf "Install safe-chain by using \033[36mnpm install -g @aikidosec/safe-chain\033[0m.\n"
|
||||
}
|
||||
|
||||
function wrapSafeChainCommand() {
|
||||
local original_cmd="$1"
|
||||
|
||||
if command -v safe-chain > /dev/null 2>&1; then
|
||||
# If the aikido command is available, just run it with the provided arguments
|
||||
safe-chain "$@"
|
||||
else
|
||||
# If the aikido command is not available, print a warning and run the original command
|
||||
printSafeChainWarning "$original_cmd"
|
||||
|
||||
command "$original_cmd" "$@"
|
||||
fi
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
# Use cross-platform path separator (: on Unix, ; on Windows)
|
||||
$pathSeparator = if ($IsWindows) { ';' } else { ':' }
|
||||
$safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin'
|
||||
$env:PATH = "$env:PATH$pathSeparator$safeChainBin"
|
||||
|
||||
function npx {
|
||||
Invoke-WrappedCommand "npx" $args
|
||||
}
|
||||
|
||||
function yarn {
|
||||
Invoke-WrappedCommand "yarn" $args
|
||||
}
|
||||
|
||||
function pnpm {
|
||||
Invoke-WrappedCommand "pnpm" $args
|
||||
}
|
||||
|
||||
function pnpx {
|
||||
Invoke-WrappedCommand "pnpx" $args
|
||||
}
|
||||
|
||||
function bun {
|
||||
Invoke-WrappedCommand "bun" $args
|
||||
}
|
||||
|
||||
function bunx {
|
||||
Invoke-WrappedCommand "bunx" $args
|
||||
}
|
||||
|
||||
function npm {
|
||||
# If args is just -v or --version and nothing else, just run the npm version command
|
||||
# This is because nvm uses this to check the version of npm
|
||||
if (($args.Length -eq 1) -and (($args[0] -eq "-v") -or ($args[0] -eq "--version"))) {
|
||||
Invoke-RealCommand "npm" $args
|
||||
return
|
||||
}
|
||||
|
||||
Invoke-WrappedCommand "npm" $args
|
||||
}
|
||||
|
||||
function pip {
|
||||
Invoke-WrappedCommand "pip" $args
|
||||
}
|
||||
|
||||
function pip3 {
|
||||
Invoke-WrappedCommand "pip3" $args
|
||||
}
|
||||
|
||||
function uv {
|
||||
Invoke-WrappedCommand "uv" $args
|
||||
}
|
||||
|
||||
function poetry {
|
||||
Invoke-WrappedCommand "poetry" $args
|
||||
}
|
||||
|
||||
# `python -m pip`, `python -m pip3`.
|
||||
function python {
|
||||
Invoke-WrappedCommand 'python' $args
|
||||
}
|
||||
|
||||
# `python3 -m pip`, `python3 -m pip3'.
|
||||
function python3 {
|
||||
Invoke-WrappedCommand 'python3' $args
|
||||
}
|
||||
|
||||
|
||||
function Write-SafeChainWarning {
|
||||
param([string]$Command)
|
||||
|
||||
# PowerShell equivalent of ANSI color codes: yellow background, black text for "Warning:"
|
||||
Write-Host "Warning:" -BackgroundColor Yellow -ForegroundColor Black -NoNewline
|
||||
Write-Host " safe-chain is not available to protect you from installing malware. $Command will run without it."
|
||||
|
||||
# Cyan text for the install command
|
||||
Write-Host "Install safe-chain by using " -NoNewline
|
||||
Write-Host "npm install -g @aikidosec/safe-chain" -ForegroundColor Cyan -NoNewline
|
||||
Write-Host "."
|
||||
}
|
||||
|
||||
function Test-CommandAvailable {
|
||||
param([string]$Command)
|
||||
|
||||
try {
|
||||
Get-Command $Command -ErrorAction Stop | Out-Null
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-RealCommand {
|
||||
param(
|
||||
[string]$Command,
|
||||
[string[]]$Arguments
|
||||
)
|
||||
|
||||
# Find the real executable to avoid calling our wrapped functions
|
||||
$realCommand = Get-Command -Name $Command -CommandType Application | Select-Object -First 1
|
||||
if ($realCommand) {
|
||||
& $realCommand.Source @Arguments
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WrappedCommand {
|
||||
param(
|
||||
[string]$OriginalCmd,
|
||||
[string[]]$Arguments
|
||||
)
|
||||
|
||||
if (Test-CommandAvailable "safe-chain") {
|
||||
& safe-chain $OriginalCmd @Arguments
|
||||
}
|
||||
else {
|
||||
Write-SafeChainWarning $OriginalCmd
|
||||
Invoke-RealCommand $OriginalCmd $Arguments
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,36 @@ function npm
|
|||
wrapSafeChainCommand "npm" $argv
|
||||
end
|
||||
|
||||
function pip
|
||||
wrapSafeChainCommand "pip" $argv
|
||||
end
|
||||
|
||||
function pip3
|
||||
wrapSafeChainCommand "pip3" $argv
|
||||
end
|
||||
|
||||
function uv
|
||||
wrapSafeChainCommand "uv" $argv
|
||||
end
|
||||
|
||||
function poetry
|
||||
wrapSafeChainCommand "poetry" $argv
|
||||
end
|
||||
|
||||
# `python -m pip`, `python -m pip3`.
|
||||
function python
|
||||
wrapSafeChainCommand "python" $argv
|
||||
end
|
||||
|
||||
# `python3 -m pip`, `python3 -m pip3'.
|
||||
function python3
|
||||
wrapSafeChainCommand "python3" $argv
|
||||
end
|
||||
|
||||
function pipx
|
||||
wrapSafeChainCommand "pipx" $argv
|
||||
end
|
||||
|
||||
function printSafeChainWarning
|
||||
set original_cmd $argv[1]
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,36 @@ function npm() {
|
|||
wrapSafeChainCommand "npm" "$@"
|
||||
}
|
||||
|
||||
function pip() {
|
||||
wrapSafeChainCommand "pip" "$@"
|
||||
}
|
||||
|
||||
function pip3() {
|
||||
wrapSafeChainCommand "pip3" "$@"
|
||||
}
|
||||
|
||||
function uv() {
|
||||
wrapSafeChainCommand "uv" "$@"
|
||||
}
|
||||
|
||||
function poetry() {
|
||||
wrapSafeChainCommand "poetry" "$@"
|
||||
}
|
||||
|
||||
# `python -m pip`, `python -m pip3`.
|
||||
function python() {
|
||||
wrapSafeChainCommand "python" "$@"
|
||||
}
|
||||
|
||||
# `python3 -m pip`, `python3 -m pip3'.
|
||||
function python3() {
|
||||
wrapSafeChainCommand "python3" "$@"
|
||||
}
|
||||
|
||||
function pipx() {
|
||||
wrapSafeChainCommand "pipx" "$@"
|
||||
}
|
||||
|
||||
function printSafeChainWarning() {
|
||||
# \033[43;30m is used to set the background color to yellow and text color to black
|
||||
# \033[0m is used to reset the text formatting
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# Use cross-platform path separator (: on Unix, ; on Windows)
|
||||
$pathSeparator = if ($IsWindows) { ';' } else { ':' }
|
||||
# $IsWindows is only available in PowerShell Core 6.0+. If it doesn't exist, assume Windows PowerShell
|
||||
$isWindowsPlatform = if (Test-Path variable:IsWindows) { $IsWindows } else { $true }
|
||||
$pathSeparator = if ($isWindowsPlatform) { ';' } else { ':' }
|
||||
$safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin'
|
||||
$env:PATH = "$env:PATH$pathSeparator$safeChainBin"
|
||||
|
||||
|
|
@ -38,6 +40,36 @@ function npm {
|
|||
Invoke-WrappedCommand "npm" $args
|
||||
}
|
||||
|
||||
function pip {
|
||||
Invoke-WrappedCommand "pip" $args
|
||||
}
|
||||
|
||||
function pip3 {
|
||||
Invoke-WrappedCommand "pip3" $args
|
||||
}
|
||||
|
||||
function uv {
|
||||
Invoke-WrappedCommand "uv" $args
|
||||
}
|
||||
|
||||
function poetry {
|
||||
Invoke-WrappedCommand "poetry" $args
|
||||
}
|
||||
|
||||
# `python -m pip`, `python -m pip3`.
|
||||
function python {
|
||||
Invoke-WrappedCommand 'python' $args
|
||||
}
|
||||
|
||||
# `python3 -m pip`, `python3 -m pip3'.
|
||||
function python3 {
|
||||
Invoke-WrappedCommand 'python3' $args
|
||||
}
|
||||
|
||||
function pipx {
|
||||
Invoke-WrappedCommand "pipx" $args
|
||||
}
|
||||
|
||||
function Write-SafeChainWarning {
|
||||
param([string]$Command)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import chalk from "chalk";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { detectShells } from "./shellDetection.js";
|
||||
import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
|
||||
import { knownAikidoTools, getPackageManagerList, getShimsDir, getScriptsDir } from "./helpers.js";
|
||||
import fs from "fs";
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
|
|
@ -62,3 +63,44 @@ export async function teardown() {
|
|||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes directories created by setup-ci and setup commands
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function teardownDirectories() {
|
||||
const shimsDir = getShimsDir();
|
||||
const scriptsDir = getScriptsDir();
|
||||
|
||||
// Remove CI shims directory
|
||||
if (fs.existsSync(shimsDir)) {
|
||||
try {
|
||||
fs.rmSync(shimsDir, { recursive: true, force: true });
|
||||
ui.writeInformation(
|
||||
`${chalk.bold("- CI Shims:")} ${chalk.green("Removed successfully")}`
|
||||
);
|
||||
} catch (/** @type {any} */ error) {
|
||||
ui.writeError(
|
||||
`${chalk.bold("- CI Shims:")} ${chalk.red(
|
||||
"Failed to remove"
|
||||
)}. Error: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove scripts directory
|
||||
if (fs.existsSync(scriptsDir)) {
|
||||
try {
|
||||
fs.rmSync(scriptsDir, { recursive: true, force: true });
|
||||
ui.writeInformation(
|
||||
`${chalk.bold("- Scripts:")} ${chalk.green("Removed successfully")}`
|
||||
);
|
||||
} catch (/** @type {any} */ error) {
|
||||
ui.writeError(
|
||||
`${chalk.bold("- Scripts:")} ${chalk.red(
|
||||
"Failed to remove"
|
||||
)}. Error: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,12 +33,16 @@ export class DockerTestContainer {
|
|||
].join(" ");
|
||||
|
||||
execSync(
|
||||
`docker build -t ${imageName} -f ${dockerFile} ${contextPath} ${buildArgs}`,
|
||||
`docker build --progress=plain -t ${imageName} -f ${dockerFile} ${contextPath} ${buildArgs}`,
|
||||
{
|
||||
stdio: "ignore",
|
||||
stdio: "pipe",
|
||||
maxBuffer: 10 * 1024 * 1024, // Default is 1MB, increase to 10MB to account for large build logs
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Only print the build logs if the build fails
|
||||
if (error.stdout) console.log(error.stdout.toString());
|
||||
if (error.stderr) console.error(error.stderr.toString());
|
||||
throw new Error(`Failed to build Docker image: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ RUN apt-get install -y fish && \
|
|||
touch /root/.config/fish/config.fish
|
||||
|
||||
# Install Volta and Node.js
|
||||
RUN curl https://get.volta.sh | bash
|
||||
RUN curl -fsSL https://get.volta.sh | bash
|
||||
RUN volta install node@${NODE_VERSION}
|
||||
RUN volta install npm@${NPM_VERSION}
|
||||
RUN volta install yarn@${YARN_VERSION}
|
||||
|
|
|
|||
347
test/e2e/certbundle.e2e.spec.js
Normal file
347
test/e2e/certbundle.e2e.spec.js
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("E2E: NODE_EXTRA_CA_CERTS merging", () => {
|
||||
let container;
|
||||
|
||||
before(async () => {
|
||||
DockerTestContainer.buildImage();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Run a new Docker container for each test
|
||||
container = new DockerTestContainer();
|
||||
await container.start();
|
||||
|
||||
const installationShell = await container.openShell("zsh");
|
||||
await installationShell.runCommand("safe-chain setup");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Stop and clean up the container after each test
|
||||
if (container) {
|
||||
await container.stop();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
it(`npm install works without NODE_EXTRA_CA_CERTS set`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Ensure NODE_EXTRA_CA_CERTS is not set
|
||||
await shell.runCommand("unset NODE_EXTRA_CA_CERTS");
|
||||
|
||||
const result = await shell.runCommand("npm install axios");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("added") || result.output.includes("up to date"),
|
||||
`npm install failed without NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`npm install works with valid NODE_EXTRA_CA_CERTS set`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Create a temporary valid certificate (using the system's Mozilla CA bundle)
|
||||
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/valid-certs.pem");
|
||||
|
||||
// Verify the cert file was created
|
||||
const { output: checkOutput } = await shell.runCommand("test -f /tmp/valid-certs.pem && echo exists");
|
||||
assert.ok(
|
||||
checkOutput.includes("exists"),
|
||||
`Certificate file was not created at /tmp/valid-certs.pem`
|
||||
);
|
||||
|
||||
// Set NODE_EXTRA_CA_CERTS and run npm install
|
||||
const result = await shell.runCommand(
|
||||
"NODE_EXTRA_CA_CERTS=/tmp/valid-certs.pem npm install axios"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("added") || result.output.includes("up to date"),
|
||||
`npm install failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`npm install works with non-existent NODE_EXTRA_CA_CERTS path`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Set NODE_EXTRA_CA_CERTS to a non-existent path
|
||||
const result = await shell.runCommand(
|
||||
'export NODE_EXTRA_CA_CERTS="/tmp/nonexistent-certs.pem" && npm install axios'
|
||||
);
|
||||
|
||||
// Should still succeed - safe-chain should gracefully handle missing user certs
|
||||
assert.ok(
|
||||
result.output.includes("added") || result.output.includes("up to date"),
|
||||
`npm install failed with non-existent NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
|
||||
);
|
||||
|
||||
// Should show a warning
|
||||
assert.ok(
|
||||
result.output.includes("Safe-chain") || result.output.includes("Could not read"),
|
||||
`Expected safe-chain warning about missing certs. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`npm install works with invalid (non-PEM) NODE_EXTRA_CA_CERTS`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Create an invalid certificate file (not valid PEM)
|
||||
await shell.runCommand(
|
||||
'echo "This is not a valid PEM certificate" > /tmp/invalid-certs.pem'
|
||||
);
|
||||
|
||||
// Set NODE_EXTRA_CA_CERTS to invalid cert
|
||||
const result = await shell.runCommand(
|
||||
'export NODE_EXTRA_CA_CERTS="/tmp/invalid-certs.pem" && npm install axios'
|
||||
);
|
||||
|
||||
// Should still succeed - safe-chain should skip invalid user certs
|
||||
assert.ok(
|
||||
result.output.includes("added") || result.output.includes("up to date"),
|
||||
`npm install failed with invalid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
|
||||
);
|
||||
|
||||
// Should show a warning about invalid cert
|
||||
assert.ok(
|
||||
result.output.includes("Safe-chain") || result.output.includes("Could not read"),
|
||||
`Expected safe-chain warning about invalid certs. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`npm install handles NODE_EXTRA_CA_CERTS with path traversal attempt`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Try to set NODE_EXTRA_CA_CERTS with path traversal
|
||||
const result = await shell.runCommand(
|
||||
'export NODE_EXTRA_CA_CERTS="/tmp/../../../etc/passwd" && npm install axios'
|
||||
);
|
||||
|
||||
// Should still succeed - safe-chain should reject path traversal
|
||||
assert.ok(
|
||||
result.output.includes("added") || result.output.includes("up to date"),
|
||||
`npm install failed with path traversal NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`npm install handles empty NODE_EXTRA_CA_CERTS`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Create an empty certificate file
|
||||
await shell.runCommand("touch /tmp/empty-certs.pem");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
'export NODE_EXTRA_CA_CERTS="/tmp/empty-certs.pem" && npm install axios'
|
||||
);
|
||||
|
||||
// Should still succeed - empty file should be ignored gracefully
|
||||
assert.ok(
|
||||
result.output.includes("added") || result.output.includes("up to date"),
|
||||
`npm install failed with empty NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`npm install handles NODE_EXTRA_CA_CERTS pointing to a directory`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Create a directory instead of a file
|
||||
await shell.runCommand("mkdir -p /tmp/cert-dir");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
'export NODE_EXTRA_CA_CERTS="/tmp/cert-dir" && npm install axios'
|
||||
);
|
||||
|
||||
// Should still succeed - directory should be treated as invalid cert file
|
||||
assert.ok(
|
||||
result.output.includes("added") || result.output.includes("up to date"),
|
||||
`npm install failed when NODE_EXTRA_CA_CERTS points to directory. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`npm install handles relative NODE_EXTRA_CA_CERTS path`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Create a cert file and try to reference it with relative path
|
||||
await shell.runCommand(
|
||||
"mkdir -p /tmp/cert-test && cp /etc/ssl/certs/ca-certificates.crt /tmp/cert-test/certs.pem"
|
||||
);
|
||||
|
||||
const result = await shell.runCommand(
|
||||
'cd /tmp/cert-test && export NODE_EXTRA_CA_CERTS="./certs.pem" && npm install axios'
|
||||
);
|
||||
|
||||
// Should still succeed - relative paths should be resolved properly
|
||||
assert.ok(
|
||||
result.output.includes("added") || result.output.includes("up to date"),
|
||||
`npm install failed with relative NODE_EXTRA_CA_CERTS path. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`npm install handles absolute NODE_EXTRA_CA_CERTS path`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Create cert file with absolute path
|
||||
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/absolute-certs.pem");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"NODE_EXTRA_CA_CERTS=/tmp/absolute-certs.pem npm install axios"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("added") || result.output.includes("up to date"),
|
||||
`npm install failed with absolute NODE_EXTRA_CA_CERTS path. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`npm install with multiple packages still respects merged certificates`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Create valid cert
|
||||
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/merge-certs.pem");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"NODE_EXTRA_CA_CERTS=/tmp/merge-certs.pem npm install axios lodash"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("added") || result.output.includes("up to date"),
|
||||
`npm install with multiple packages failed. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`npm install correctly blocks malware even with merged certificates`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Create valid cert
|
||||
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/secure-merge-certs.pem");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"NODE_EXTRA_CA_CERTS=/tmp/secure-merge-certs.pem npm install safe-chain-test"
|
||||
);
|
||||
|
||||
// Should block the malware package
|
||||
assert.ok(
|
||||
result.output.includes("Malicious") || result.output.includes("blocked"),
|
||||
`Malware package should be blocked even with merged certificates. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pip install works without NODE_EXTRA_CA_CERTS set`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("safe-chain setup");
|
||||
await shell.runCommand("unset NODE_EXTRA_CA_CERTS");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"pip3 install --break-system-packages requests"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
|
||||
`pip3 install failed without NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pip install works with valid NODE_EXTRA_CA_CERTS set`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("safe-chain setup");
|
||||
|
||||
// Create a temporary valid certificate
|
||||
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/pip-valid-certs.pem");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"NODE_EXTRA_CA_CERTS=/tmp/pip-valid-certs.pem pip3 install --break-system-packages requests"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
|
||||
`pip3 install failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pip install handles non-existent NODE_EXTRA_CA_CERTS gracefully`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("safe-chain setup");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
'export NODE_EXTRA_CA_CERTS="/tmp/nonexistent-pip-certs.pem" && pip3 install --break-system-packages requests'
|
||||
);
|
||||
|
||||
// Should still work - gracefully handle missing user certs
|
||||
assert.ok(
|
||||
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
|
||||
`pip3 install failed with non-existent NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pip install handles invalid NODE_EXTRA_CA_CERTS gracefully`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("safe-chain setup");
|
||||
|
||||
// Create invalid cert
|
||||
await shell.runCommand(
|
||||
'echo "invalid certificate content" > /tmp/pip-invalid-certs.pem'
|
||||
);
|
||||
|
||||
const result = await shell.runCommand(
|
||||
'export NODE_EXTRA_CA_CERTS="/tmp/pip-invalid-certs.pem" && pip3 install --break-system-packages requests'
|
||||
);
|
||||
|
||||
// Should still work - skip invalid user certs
|
||||
assert.ok(
|
||||
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
|
||||
`pip3 install failed with invalid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`yarn install works with valid NODE_EXTRA_CA_CERTS set`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Create valid cert
|
||||
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/yarn-certs.pem");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"NODE_EXTRA_CA_CERTS=/tmp/yarn-certs.pem yarn add axios"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
!result.output.toLowerCase().includes("error") || result.output.includes("Done"),
|
||||
`yarn add failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pnpm install works with valid NODE_EXTRA_CA_CERTS set`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Create valid cert
|
||||
await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/pnpm-certs.pem");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"NODE_EXTRA_CA_CERTS=/tmp/pnpm-certs.pem pnpm add axios"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
!result.output.toLowerCase().includes("error") || result.output.includes("Progress"),
|
||||
`pnpm add failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`bun install works with valid NODE_EXTRA_CA_CERTS set`, async () => {
|
||||
const shell = await container.openShell("bash");
|
||||
|
||||
// Create valid cert and run bun in the same command to ensure file exists
|
||||
const result = await shell.runCommand(
|
||||
"cp /etc/ssl/certs/ca-certificates.crt /tmp/bun-certs.pem && NODE_EXTRA_CA_CERTS=/tmp/bun-certs.pem bun i axios"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
!result.output.toLowerCase().includes("error") || result.output.includes("installed"),
|
||||
`bun i failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
});
|
||||
45
test/e2e/include-python-deprecation.e2e.spec.js
Normal file
45
test/e2e/include-python-deprecation.e2e.spec.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("E2E: deprecated --include-python handling", () => {
|
||||
let container;
|
||||
|
||||
before(async () => {
|
||||
DockerTestContainer.buildImage();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
container = new DockerTestContainer();
|
||||
await container.start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (container) {
|
||||
await container.stop();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
for (let shell of ["bash", "zsh"]) {
|
||||
it(`safe-chain setup warns and continues for ${shell}`, async () => {
|
||||
const sh = await container.openShell(shell);
|
||||
const result = await sh.runCommand("safe-chain setup --include-python");
|
||||
|
||||
assert.ok(
|
||||
result.output.toLowerCase().includes("deprecated and ignored"),
|
||||
`Expected warning about deprecated --include-python. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`safe-chain setup-ci warns and continues for ${shell}`, async () => {
|
||||
const sh = await container.openShell(shell);
|
||||
const result = await sh.runCommand("safe-chain setup-ci --include-python");
|
||||
|
||||
assert.ok(
|
||||
result.output.toLowerCase().includes("deprecated and ignored"),
|
||||
`Expected warning about deprecated --include-python. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -86,7 +86,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => {
|
|||
// Setup safe-chain CI shims
|
||||
const installationShell = await container.openShell(shell);
|
||||
await installationShell.runCommand(
|
||||
"safe-chain setup-ci --include-python"
|
||||
"safe-chain setup-ci"
|
||||
);
|
||||
|
||||
// Add $HOME/.safe-chain/shims to PATH for subsequent shells
|
||||
|
|
@ -115,7 +115,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => {
|
|||
it(`setup-ci routes python -m pip through safe-chain for ${shell}`, async () => {
|
||||
const installationShell = await container.openShell(shell);
|
||||
await installationShell.runCommand(
|
||||
"safe-chain setup-ci --include-python"
|
||||
"safe-chain setup-ci"
|
||||
);
|
||||
await installationShell.runCommand(
|
||||
"echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc"
|
||||
|
|
@ -138,7 +138,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => {
|
|||
it(`setup-ci routes python3 -m pip through safe-chain for ${shell}`, async () => {
|
||||
const installationShell = await container.openShell(shell);
|
||||
await installationShell.runCommand(
|
||||
"safe-chain setup-ci --include-python"
|
||||
"safe-chain setup-ci"
|
||||
);
|
||||
await installationShell.runCommand(
|
||||
"echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc"
|
||||
|
|
@ -161,7 +161,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => {
|
|||
it(`setup-ci routes pip through safe-chain for ${shell}`, async () => {
|
||||
const installationShell = await container.openShell(shell);
|
||||
await installationShell.runCommand(
|
||||
"safe-chain setup-ci --include-python"
|
||||
"safe-chain setup-ci"
|
||||
);
|
||||
await installationShell.runCommand(
|
||||
"echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc"
|
||||
|
|
@ -184,7 +184,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => {
|
|||
it(`setup-ci routes pip3 through safe-chain for ${shell}`, async () => {
|
||||
const installationShell = await container.openShell(shell);
|
||||
await installationShell.runCommand(
|
||||
"safe-chain setup-ci --include-python"
|
||||
"safe-chain setup-ci"
|
||||
);
|
||||
await installationShell.runCommand(
|
||||
"echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ describe("E2E: pip coverage", () => {
|
|||
await container.start();
|
||||
|
||||
const installationShell = await container.openShell("zsh");
|
||||
await installationShell.runCommand("safe-chain setup --include-python");
|
||||
await installationShell.runCommand("safe-chain setup");
|
||||
|
||||
// Clear pip cache before each test to ensure fresh downloads through proxy
|
||||
await installationShell.runCommand("pip3 cache purge");
|
||||
|
|
|
|||
200
test/e2e/pipx.e2e.spec.js
Normal file
200
test/e2e/pipx.e2e.spec.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("E2E: pipx coverage", () => {
|
||||
let container;
|
||||
|
||||
before(async () => {
|
||||
DockerTestContainer.buildImage();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
container = new DockerTestContainer();
|
||||
await container.start();
|
||||
|
||||
const installationShell = await container.openShell("zsh");
|
||||
await installationShell.runCommand("safe-chain setup");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (container) {
|
||||
await container.stop();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
it(`successfully installs known safe packages with pipx install`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"pipx install ruff --safe-chain-logging=verbose"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found.") || result.output.includes("installed successfully"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`safe-chain blocks installation of malicious Python packages via pipx`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"pipx install safe-chain-pi-test"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked by safe-chain"),
|
||||
`Expected malware to be blocked. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output.includes("Exiting without installing malicious packages."),
|
||||
`Expected exit message. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pipx upgrade upgrades installed packages`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("pipx install ruff==0.1.0");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"pipx upgrade ruff"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found.") || result.output.includes("Upgraded") || result.output.includes("upgraded"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pipx run downloads and executes a safe tool`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"pipx run ruff --version"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found.") || /ruff/i.test(result.output),
|
||||
`Expected safe run to succeed. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pipx run blocks malicious tool download`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"pipx run safe-chain-pi-test --version"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked by safe-chain"),
|
||||
`Expected malicious run to be blocked. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output.includes("Exiting without installing malicious packages."),
|
||||
`Expected exit message. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pipx runpip installs safe dependency inside an app venv`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Prepare an app environment
|
||||
await shell.runCommand("pipx install ruff");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"pipx runpip ruff install requests==2.32.3"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found.") || /Successfully installed/i.test(result.output) || /requests/i.test(result.output),
|
||||
`Expected safe dependency install inside app venv. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pipx runpip blocks malicious dependency install`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
// Prepare an app environment
|
||||
await shell.runCommand("pipx install ruff");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"pipx runpip ruff install safe-chain-pi-test"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked by safe-chain"),
|
||||
`Expected malicious dependency to be blocked. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output.includes("Exiting without installing malicious packages."),
|
||||
`Expected exit message. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pipx list shows installed packages`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("pipx install ruff");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"pipx list"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("ruff"),
|
||||
`Expected ruff in list output. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pipx uninstall removes packages`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("pipx install ruff --safe-chain-logging=verbose");
|
||||
await shell.runCommand("pipx uninstall ruff --safe-chain-logging=verbose");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"pipx list"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
!result.output.includes("ruff"),
|
||||
`Expected ruff to be removed from list. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it('pipx inject installs safe packages into existing venvs', async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("pipx install ruff --safe-chain-logging=verbose");
|
||||
const result = await shell.runCommand(
|
||||
"pipx inject ruff requests==2.32.3 --safe-chain-logging=verbose"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found.") || /Successfully installed/i.test(result.output) || /requests/i.test(result.output),
|
||||
`Expected safe package to be injected. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it('pipx inject blocks malicious packages from being installed into existing venvs', async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
|
||||
await shell.runCommand("pipx install ruff --safe-chain-logging=verbose");
|
||||
const result = await shell.runCommand(
|
||||
"pipx inject ruff safe-chain-pi-test --safe-chain-logging=verbose"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked by safe-chain"),
|
||||
`Expected malicious package to be blocked. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output.includes("Exiting without installing malicious packages."),
|
||||
`Expected exit message. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -15,7 +15,7 @@ describe("E2E: poetry coverage", () => {
|
|||
await container.start();
|
||||
|
||||
const installationShell = await container.openShell("zsh");
|
||||
await installationShell.runCommand("safe-chain setup --include-python");
|
||||
await installationShell.runCommand("safe-chain setup");
|
||||
|
||||
// Clear poetry cache
|
||||
await installationShell.runCommand("command poetry cache clear pypi --all -n");
|
||||
|
|
|
|||
96
test/e2e/teardown-dirs.e2e.spec.js
Normal file
96
test/e2e/teardown-dirs.e2e.spec.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("E2E: safe-chain teardown command", () => {
|
||||
let container;
|
||||
|
||||
before(async () => {
|
||||
DockerTestContainer.buildImage();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
container = new DockerTestContainer();
|
||||
await container.start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (container) {
|
||||
await container.stop();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("safe-chain teardown removes shims directory created by setup-ci", async () => {
|
||||
const shell = await container.openShell("bash");
|
||||
|
||||
// Run setup-ci
|
||||
await shell.runCommand("safe-chain setup-ci");
|
||||
|
||||
// Verify shims directory exists
|
||||
const checkShimsExist = await shell.runCommand("test -d ~/.safe-chain/shims && echo 'exists' || echo 'missing'");
|
||||
assert.ok(checkShimsExist.output.includes("exists"), "Shims directory should exist after setup-ci");
|
||||
|
||||
// Run teardown
|
||||
await shell.runCommand("safe-chain teardown");
|
||||
|
||||
// Verify shims directory is gone
|
||||
const checkShimsGone = await shell.runCommand("test -d ~/.safe-chain/shims && echo 'exists' || echo 'missing'");
|
||||
assert.ok(checkShimsGone.output.includes("missing"), "Shims directory should be removed after teardown");
|
||||
});
|
||||
|
||||
it("safe-chain teardown removes scripts directory created by setup", async () => {
|
||||
const shell = await container.openShell("bash");
|
||||
|
||||
// Run setup
|
||||
await shell.runCommand("safe-chain setup");
|
||||
|
||||
// Verify scripts directory exists
|
||||
const checkScriptsExist = await shell.runCommand("test -d ~/.safe-chain/scripts && echo 'exists' || echo 'missing'");
|
||||
assert.ok(checkScriptsExist.output.includes("exists"), "Scripts directory should exist after setup");
|
||||
|
||||
// Run teardown
|
||||
await shell.runCommand("safe-chain teardown");
|
||||
|
||||
// Verify scripts directory is gone
|
||||
const checkScriptsGone = await shell.runCommand("test -d ~/.safe-chain/scripts && echo 'exists' || echo 'missing'");
|
||||
assert.ok(checkScriptsGone.output.includes("missing"), "Scripts directory should be removed after teardown");
|
||||
});
|
||||
|
||||
it("safe-chain teardown removes shims directory created by setup-ci", async () => {
|
||||
const shell = await container.openShell("bash");
|
||||
|
||||
// Run setup-ci
|
||||
await shell.runCommand("safe-chain setup-ci");
|
||||
// Verify shims directory exists
|
||||
const checkShimsExist = await shell.runCommand("test -d ~/.safe-chain/shims && echo 'exists' || echo 'missing'");
|
||||
assert.ok(checkShimsExist.output.includes("exists"), "Shims directory should exist after setup-ci");
|
||||
|
||||
// Verify Python shims were created
|
||||
const checkPythonShims = await shell.runCommand("test -f ~/.safe-chain/shims/pip && echo 'exists' || echo 'missing'");
|
||||
assert.ok(checkPythonShims.output.includes("exists"), "Python shims should exist after setup-ci");
|
||||
// Run teardown
|
||||
await shell.runCommand("safe-chain teardown");
|
||||
|
||||
// Verify shims directory is gone
|
||||
const checkShimsGone = await shell.runCommand("test -d ~/.safe-chain/shims && echo 'exists' || echo 'missing'");
|
||||
assert.ok(checkShimsGone.output.includes("missing"), "Shims directory should be removed after teardown");
|
||||
});
|
||||
|
||||
it("safe-chain teardown removes scripts directory created by setup", async () => {
|
||||
const shell = await container.openShell("bash");
|
||||
|
||||
// Run setup
|
||||
await shell.runCommand("safe-chain setup");
|
||||
// Verify scripts directory exists
|
||||
const checkScriptsExist = await shell.runCommand("test -d ~/.safe-chain/scripts && echo 'exists' || echo 'missing'");
|
||||
assert.ok(checkScriptsExist.output.includes("exists"), "Scripts directory should exist after setup");
|
||||
|
||||
// Run teardown
|
||||
await shell.runCommand("safe-chain teardown");
|
||||
|
||||
// Verify scripts directory is gone
|
||||
const checkScriptsGone = await shell.runCommand("test -d ~/.safe-chain/scripts && echo 'exists' || echo 'missing'");
|
||||
assert.ok(checkScriptsGone.output.includes("missing"), "Scripts directory should be removed after teardown");
|
||||
});
|
||||
});
|
||||
|
|
@ -15,7 +15,7 @@ describe("E2E: uv coverage", () => {
|
|||
await container.start();
|
||||
|
||||
const installationShell = await container.openShell("zsh");
|
||||
await installationShell.runCommand("safe-chain setup --include-python");
|
||||
await installationShell.runCommand("safe-chain setup");
|
||||
|
||||
// Clear uv cache
|
||||
await installationShell.runCommand("uv cache clean");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue