Compare commits

..

No commits in common. "main" and "1.1.9" have entirely different histories.
main ... 1.1.9

193 changed files with 3984 additions and 19236 deletions

View file

@ -4,135 +4,9 @@ on:
push: push:
tags: tags:
- "*" - "*"
release:
types: [published]
permissions:
id-token: write
contents: write
jobs: jobs:
set-version: build:
name: Set version number
if: github.event_name == 'push'
runs-on: open-source-releaser
outputs:
version: ${{ steps.get_version.outputs.tag }}
steps:
- name: Set version number
id: get_version
run: |
version="${{ github.ref_name }}"
echo "tag=$version" >> $GITHUB_OUTPUT
create-binaries:
if: github.event_name == 'push'
needs: set-version
uses: ./.github/workflows/create-artifact.yml
with:
version: ${{ needs.set-version.outputs.version }}
publish-binaries:
name: Publish to GitHub release
if: github.event_name == 'push'
needs: [set-version, create-binaries]
runs-on: open-source-releaser
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Download all binary artifacts
uses: actions/download-artifact@v4
with:
path: binaries/
pattern: safe-chain-*
merge-multiple: false
- name: Rename binaries to include platform and architecture
run: |
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-linuxstatic-x64/safe-chain release-artifacts/safe-chain-linuxstatic-x64
mv binaries/safe-chain-linuxstatic-arm64/safe-chain release-artifacts/safe-chain-linuxstatic-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 and checksums
env:
VERSION: ${{ needs.set-version.outputs.version }}
run: |
SHA_MACOS_X64=$(sha256sum release-artifacts/safe-chain-macos-x64 | awk '{print $1}')
SHA_MACOS_ARM64=$(sha256sum release-artifacts/safe-chain-macos-arm64 | awk '{print $1}')
SHA_LINUX_X64=$(sha256sum release-artifacts/safe-chain-linux-x64 | awk '{print $1}')
SHA_LINUX_ARM64=$(sha256sum release-artifacts/safe-chain-linux-arm64 | awk '{print $1}')
SHA_LINUXSTATIC_X64=$(sha256sum release-artifacts/safe-chain-linuxstatic-x64 | awk '{print $1}')
SHA_LINUXSTATIC_ARM64=$(sha256sum release-artifacts/safe-chain-linuxstatic-arm64 | awk '{print $1}')
SHA_WIN_X64=$(sha256sum release-artifacts/safe-chain-win-x64.exe | awk '{print $1}')
SHA_WIN_ARM64=$(sha256sum release-artifacts/safe-chain-win-arm64.exe | awk '{print $1}')
sed \
-e "s/\$(fetch_latest_version)/${VERSION}/" \
-e "s|^SHA256_MACOS_X64=\"\"|SHA256_MACOS_X64=\"${SHA_MACOS_X64}\"|" \
-e "s|^SHA256_MACOS_ARM64=\"\"|SHA256_MACOS_ARM64=\"${SHA_MACOS_ARM64}\"|" \
-e "s|^SHA256_LINUX_X64=\"\"|SHA256_LINUX_X64=\"${SHA_LINUX_X64}\"|" \
-e "s|^SHA256_LINUX_ARM64=\"\"|SHA256_LINUX_ARM64=\"${SHA_LINUX_ARM64}\"|" \
-e "s|^SHA256_LINUXSTATIC_X64=\"\"|SHA256_LINUXSTATIC_X64=\"${SHA_LINUXSTATIC_X64}\"|" \
-e "s|^SHA256_LINUXSTATIC_ARM64=\"\"|SHA256_LINUXSTATIC_ARM64=\"${SHA_LINUXSTATIC_ARM64}\"|" \
-e "s|^SHA256_WIN_X64=\"\"|SHA256_WIN_X64=\"${SHA_WIN_X64}\"|" \
-e "s|^SHA256_WIN_ARM64=\"\"|SHA256_WIN_ARM64=\"${SHA_WIN_ARM64}\"|" \
install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh
sed \
-e "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" \
-e "s|^\$SHA256_MACOS_X64 = \"\"|\$SHA256_MACOS_X64 = \"${SHA_MACOS_X64}\"|" \
-e "s|^\$SHA256_MACOS_ARM64 = \"\"|\$SHA256_MACOS_ARM64 = \"${SHA_MACOS_ARM64}\"|" \
-e "s|^\$SHA256_LINUX_X64 = \"\"|\$SHA256_LINUX_X64 = \"${SHA_LINUX_X64}\"|" \
-e "s|^\$SHA256_LINUX_ARM64 = \"\"|\$SHA256_LINUX_ARM64 = \"${SHA_LINUX_ARM64}\"|" \
-e "s|^\$SHA256_LINUXSTATIC_X64 = \"\"|\$SHA256_LINUXSTATIC_X64 = \"${SHA_LINUXSTATIC_X64}\"|" \
-e "s|^\$SHA256_LINUXSTATIC_ARM64 = \"\"|\$SHA256_LINUXSTATIC_ARM64 = \"${SHA_LINUXSTATIC_ARM64}\"|" \
-e "s|^\$SHA256_WIN_X64 = \"\"|\$SHA256_WIN_X64 = \"${SHA_WIN_X64}\"|" \
-e "s|^\$SHA256_WIN_ARM64 = \"\"|\$SHA256_WIN_ARM64 = \"${SHA_WIN_ARM64}\"|" \
install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1
cp install-scripts/uninstall-safe-chain.sh release-artifacts/uninstall-safe-chain.sh
cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1
cp install-scripts/install-endpoint-mac.sh release-artifacts/install-endpoint-mac.sh
cp install-scripts/install-endpoint-windows.ps1 release-artifacts/install-endpoint-windows.ps1
cp install-scripts/uninstall-endpoint-mac.sh release-artifacts/uninstall-endpoint-mac.sh
cp install-scripts/uninstall-endpoint-windows.ps1 release-artifacts/uninstall-endpoint-windows.ps1
- name: Create draft release and upload assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.set-version.outputs.version }}
run: |
if ! gh release view "$VERSION" &>/dev/null; then
gh release create "$VERSION" --draft --title "$VERSION" --generate-notes
fi
gh release upload "$VERSION" --clobber \
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-linuxstatic-x64 \
release-artifacts/safe-chain-linuxstatic-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 \
release-artifacts/install-endpoint-mac.sh \
release-artifacts/install-endpoint-windows.ps1 \
release-artifacts/uninstall-endpoint-mac.sh \
release-artifacts/uninstall-endpoint-windows.ps1
publish-npm:
name: Publish to npm
if: github.event_name == 'release'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -144,12 +18,22 @@ jobs:
with: with:
node-version: "lts/*" node-version: "lts/*"
registry-url: "https://registry.npmjs.org/" registry-url: "https://registry.npmjs.org/"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
- name: Setup safe-chain - name: Setup safe-chain
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci run: |
npm i -g @aikidosec/safe-chain
safe-chain setup-ci
- name: Set version number
id: get_version
run: |
version="${{ github.ref_name }}"
echo "tag=$version" >> $GITHUB_OUTPUT
- name: Set the version in safe-chain package - name: Set the version in safe-chain package
run: npm --no-git-tag-version version ${{ github.event.release.tag_name }} --workspace=packages/safe-chain run: npm --no-git-tag-version version ${{ steps.get_version.outputs.tag }} --workspace=packages/safe-chain
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@ -162,15 +46,10 @@ jobs:
cp README.md packages/safe-chain/ cp README.md packages/safe-chain/
cp LICENSE packages/safe-chain/ cp LICENSE packages/safe-chain/
cp -r docs packages/safe-chain/ cp -r docs packages/safe-chain/
cp npm-shrinkwrap.json packages/safe-chain/
- name: Publish to npm - name: Publish to npm
run: | run: |
VERSION="${{ github.event.release.tag_name }}" echo "Publishing version ${{ steps.get_version.outputs.tag }} to NPM"
echo "Publishing version $VERSION to NPM" npm publish --workspace=packages/safe-chain --access public
if [[ "$VERSION" == *"-"* ]]; then env:
PRERELEASE_TAG=$(echo "$VERSION" | sed 's/.*-\([^-]*\)$/\1/') NPM_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
npm publish --workspace=packages/safe-chain --access public --provenance --tag "$PRERELEASE_TAG"
else
npm publish --workspace=packages/safe-chain --access public --provenance
fi

View file

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

View file

@ -1,94 +0,0 @@
name: Create binaries
on:
pull_request:
workflow_call:
inputs:
version:
description: "Version to set in package.json"
required: false
type: string
jobs:
create-binaries:
name: Create binary for ${{ matrix.os }}-${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- os: macos
arch: x64
runner: macos-15-intel
target: node20-macos-x64
extension: ""
- os: macos
arch: arm64
runner: macos-latest
target: node20-macos-arm64
extension: ""
- os: linux
arch: x64
runner: ubuntu-latest
target: node20-linux-x64
extension: ""
- os: linux
arch: arm64
runner: ubuntu-24.04-arm
target: node20-linux-arm64
extension: ""
- os: linuxstatic
arch: x64
runner: ubuntu-latest
target: node20-linuxstatic-x64
extension: ""
- os: linuxstatic
arch: arm64
runner: ubuntu-24.04-arm
target: node20-linuxstatic-arm64
extension: ""
- os: win
arch: x64
runner: windows-latest
target: node20-win-x64
extension: ".exe"
- os: win
arch: arm64
runner: windows-11-arm
target: node20-win-arm64
extension: ".exe"
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: "20.x"
- name: Setup safe-chain
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
shell: bash
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Set the version in safe-chain package
if: inputs.version != ''
env:
VERSION: ${{ inputs.version }}
shell: bash
run: npm --no-git-tag-version version $VERSION --workspace=packages/safe-chain --ignore-scripts
- name: Create binary
run: |
node build.js ${{ matrix.target }}
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: safe-chain-${{ matrix.os }}-${{ matrix.arch }}
path: dist/*

View file

@ -6,12 +6,7 @@ jobs:
unit-test: unit-test:
name: Run unit tests and linting name: Run unit tests and linting
runs-on: ${{ matrix.os }} runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps: steps:
- name: Checkout code - name: Checkout code
@ -23,11 +18,12 @@ jobs:
node-version: "lts/*" node-version: "lts/*"
- name: Setup safe-chain - name: Setup safe-chain
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci run: |
shell: bash npm i -g @aikidosec/safe-chain
safe-chain setup-ci
- name: Install dependencies - name: Install dependencies
run: npm ci --ignore-scripts run: npm ci
- name: Run unit tests - name: Run unit tests
run: npm test run: npm test
@ -39,12 +35,10 @@ jobs:
run: npm run typecheck --workspace=packages/safe-chain run: npm run typecheck --workspace=packages/safe-chain
- name: Create package tarball - name: Create package tarball
if: matrix.os == 'ubuntu-latest'
run: npm pack --workspace=packages/safe-chain run: npm pack --workspace=packages/safe-chain
- name: Upload package tarball - name: Upload package tarball
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: matrix.os == 'ubuntu-latest'
with: with:
name: safe-chain-package name: safe-chain-package
path: aikidosec-safe-chain-*.tgz path: aikidosec-safe-chain-*.tgz
@ -77,7 +71,7 @@ jobs:
- node_version: "20" - node_version: "20"
npm_version: "9.0.0" npm_version: "9.0.0"
yarn_version: "latest" yarn_version: "latest"
pnpm_version: "10.0.0" pnpm_version: "latest"
# Version pinning scenario # Version pinning scenario
- node_version: "22" - node_version: "22"
npm_version: "10.2.0" npm_version: "10.2.0"
@ -87,12 +81,17 @@ jobs:
- node_version: "18" - node_version: "18"
npm_version: "latest" npm_version: "latest"
yarn_version: "latest" yarn_version: "latest"
pnpm_version: "10.0.0" pnpm_version: "latest"
# Future compatibility (becomes LTS October 2025) # Future compatibility (becomes LTS October 2025)
- node_version: "24" - node_version: "24"
npm_version: "latest" npm_version: "latest"
yarn_version: "latest" yarn_version: "latest"
pnpm_version: "latest" pnpm_version: "latest"
# EOL compatibility testing - Node 16 (EOL Sept 2023)
- node_version: "16"
npm_version: "8.0.0"
yarn_version: "1.22.0"
pnpm_version: "8.0.0"
steps: steps:
- name: Checkout code - name: Checkout code
@ -104,7 +103,9 @@ jobs:
node-version: "lts/*" node-version: "lts/*"
- name: Setup safe-chain - name: Setup safe-chain
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci run: |
npm i -g @aikidosec/safe-chain@1.0.24
safe-chain setup-ci
- name: Install dependencies (root) - name: Install dependencies (root)
run: npm ci run: npm ci

9
.gitignore vendored
View file

@ -143,11 +143,4 @@ vite.config.ts.timestamp-*
# AI # AI
Claude.md Claude.md
.claude .claude
.reference .reference
# Build files
build/
dist/
# Jetbrains IDEs
.idea/**

533
README.md
View file

@ -1,99 +1,48 @@
![Aikido Safe Chain](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/banner.svg)
# Aikido Safe Chain # Aikido Safe Chain
[![NPM Version](https://img.shields.io/npm/v/%40aikidosec%2Fsafe-chain?style=flat-square)](https://www.npmjs.com/package/@aikidosec/safe-chain) The Aikido Safe Chain **prevents developers from installing malware** on their workstations while developing in the Javascript ecosystem (through npm, npx, yarn, pnpm, pnpx, bun and bunx). It's **free** to use and does not require any token.
[![NPM Downloads](https://img.shields.io/npm/dw/%40aikidosec%2Fsafe-chain?style=flat-square)](https://www.npmjs.com/package/@aikidosec/safe-chain)
- ✅ **Block malware on developer laptops and CI/CD** The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, or pip/pip3 from downloading or running the malware.
- ✅ **Supports npm and PyPI** more package managers coming
- ✅ **Blocks packages newer than 48 hours** without breaking your build
- ✅ **Tokenless, free, no build data shared**
## Need protection beyond npm & PyPI? Aikido Safe Chain works on Node.js version 16 and above and supports the following package managers:
[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection?utm_source=github.com&utm_medium=referral&utm_campaign=safechain) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more. - ✅ **npm**
- ✅ **npx**
Get centralized policy management, request-and-approval workflows, and visibility across every developer workstation in your org. Powered by the same Aikido Intel feed. Deploy it manually or manage it through your MDM tool (Jamf, Fleet, or Iru). - ✅ **yarn**
- ✅ **pnpm**
--- - ✅ **pnpx**
- ✅ **bun**
Aikido Safe Chain supports the following package managers: - ✅ **bunx**
- ✅ **pip** (beta)
- 📦 **npm** - ✅ **pip3** (beta)
- 📦 **npx**
- 📦 **yarn**
- 📦 **pnpm**
- 📦 **pnpx**
- 📦 **rush**
- 📦 **rushx**
- 📦 **bun**
- 📦 **bunx**
- 📦 **pip**
- 📦 **pip3**
- 📦 **uv**
- 📦 **poetry**
- 📦 **uvx**
- 📦 **pipx**
- 📦 **pdm**
# Usage # Usage
![Aikido Safe Chain demo](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/safe-package-manager-demo.gif)
## Installation ## Installation
Installing the Aikido Safe Chain is easy with our one-line installer. Installing the Aikido Safe Chain is easy. You just need 3 simple steps:
### Unix/Linux/macOS 1. **Install the Aikido Safe Chain package globally** using npm:
```shell
```shell npm install -g @aikidosec/safe-chain
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh ```
``` 2. **Setup the shell integration** by running:
### Windows (PowerShell)
```powershell
iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1" -UseBasicParsing)
```
### 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://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, rush, rushx, bun, bunx, pip, pip3, poetry, uv, uvx, pipx and pdm are loaded correctly. If you do not restart your terminal, the aliases will not be available.
2. **Verify the installation** by running the verification command:
```shell ```shell
npm safe-chain-verify safe-chain setup
pnpm safe-chain-verify
pip safe-chain-verify
uv safe-chain-verify
# Any other supported package manager: {packagemanager} safe-chain-verify
``` ```
- The output should display "OK: Safe-chain works!" confirming that Aikido Safe Chain is properly installed and running. To enable Python (pip/pip3) support (beta), use the `--include-python` flag:
3. **(Optional) Test malware blocking** by attempting to install a test package: ```shell
safe-chain setup --include-python
```
3. **❗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.
4. **Verify the installation** by running one of the following commands:
For JavaScript/Node.js: For JavaScript/Node.js:
@ -101,7 +50,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
npm install safe-chain-test npm install safe-chain-test
``` ```
For Python: For Python (beta):
```shell ```shell
pip3 install safe-chain-pi-test pip3 install safe-chain-pi-test
@ -109,7 +58,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
- The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware.
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx` and `pdm` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, or `pip3` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
You can check the installed version by running: You can check the installed version by running:
@ -121,26 +70,17 @@ safe-chain --version
### Malware Blocking ### Malware Blocking
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, uv, uvx, poetry, pipx or pdm commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. 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`, or `pip3` commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
### Minimum package age ### Minimum package age (npm only)
Safe Chain applies minimum package age checks to supported ecosystems. For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours 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 bypass this protection for specific installs using the `--safe-chain-skip-minimum-package-age` flag.
Current enforcement differs by ecosystem: ⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to PyPI/pip.
- npm-based package managers:
- during normal package resolution, Safe Chain suppresses versions that are newer than the configured minimum age from the package metadata returned by the registry
- for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages
- Python package managers:
- during package resolution, Safe Chain suppresses too-young files and releases from PyPI metadata responses
- for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages
By default, the minimum package age is 48 hours. 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.
### Shell Integration ### Shell Integration
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx, pdm). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
- ✅ **Bash** - ✅ **Bash**
- ✅ **Zsh** - ✅ **Zsh**
@ -148,238 +88,70 @@ The Aikido Safe Chain integrates with your shell to provide a seamless experienc
- ✅ **PowerShell** - ✅ **PowerShell**
- ✅ **PowerShell Core** - ✅ **PowerShell Core**
More information about the shell integration can be found in the [shell integration documentation](https://github.com/AikidoSec/safe-chain/blob/main/docs/shell-integration.md). More information about the shell integration can be found in the [shell integration documentation](docs/shell-integration.md).
## Uninstallation ## Uninstallation
To uninstall the Aikido Safe Chain, use our one-line uninstaller: To uninstall the Aikido Safe Chain, you can run the following command:
### Unix/Linux/macOS 1. **Remove all aliases from your shell** by running:
```shell
```shell safe-chain teardown
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/uninstall-safe-chain.sh | sh ```
``` 2. **Uninstall the Aikido Safe Chain package** using npm:
```shell
### Windows (PowerShell) npm uninstall -g @aikidosec/safe-chain
```
```powershell 3. **❗Restart your terminal** to remove the aliases.
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 # Configuration
## Logging ## Logging
You can control the output from Aikido Safe Chain using the `--safe-chain-logging` flag or the `SAFE_CHAIN_LOGGING` environment variable. You can control the output from Aikido Safe Chain using the `--safe-chain-logging` flag:
### Configuration Options - `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit.
You can set the logging level through multiple sources (in order of priority): Example usage:
1. **CLI Argument** (highest priority): ```shell
- `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit. npm install express --safe-chain-logging=silent
```
```shell - `--safe-chain-logging=verbose` - Enables detailed diagnostic output from Aikido Safe Chain. Useful for troubleshooting issues or understanding what Safe Chain is doing behind the scenes.
npm install express --safe-chain-logging=silent
```
- `--safe-chain-logging=verbose` - Enables detailed diagnostic output from Aikido Safe Chain. Useful for troubleshooting issues or understanding what Safe Chain is doing behind the scenes. Example usage:
```shell ```shell
npm install express --safe-chain-logging=verbose npm install express --safe-chain-logging=verbose
``` ```
2. **Environment Variable**:
```shell
export SAFE_CHAIN_LOGGING=verbose
npm install express
```
Valid values: `silent`, `normal`, `verbose`
This is useful for setting a default logging level for all package manager commands in your terminal session or CI/CD environment.
## Minimum Package Age
You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 48 hours old before they can be installed.
For npm-based package managers, this check currently has two enforcement modes:
- Safe Chain suppresses too-young versions from package metadata during normal dependency resolution.
- Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list.
For Python package managers, this check currently has two enforcement modes:
- Safe Chain suppresses too-young files and releases from PyPI metadata during dependency resolution.
- Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list.
### Configuration Options
You can set the minimum package age through multiple sources (in order of priority):
1. **CLI Argument** (highest priority):
```shell
npm install express --safe-chain-minimum-package-age-hours=48
```
2. **Environment Variable**:
```shell
export SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS=48
npm install express
```
3. **Config File** (`~/.safe-chain/config.json`):
```json
{
"minimumPackageAgeHours": 48
}
```
### Excluding Packages
Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Use `@scope/*` to trust all packages from an organization:
```shell
export SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*"
```
```json
{
"npm": {
"minimumPackageAgeExclusions": ["@aikidosec/*"]
},
"pip": {
"minimumPackageAgeExclusions": ["requests"]
}
}
```
## Custom Registries
Configure Safe Chain to scan packages from custom or private registries.
Supported ecosystems:
- Node.js
- Python
### Configuration Options
You can set custom registries through environment variable or config file. Both sources are merged together.
1. **Environment Variable** (comma-separated):
```shell
export SAFE_CHAIN_NPM_CUSTOM_REGISTRIES="npm.company.com,registry.internal.net"
export SAFE_CHAIN_PIP_CUSTOM_REGISTRIES="pip.company.com,registry.internal.net"
```
2. **Config File** (`~/.safe-chain/config.json`):
```json
{
"npm": {
"customRegistries": ["npm.company.com", "registry.internal.net"]
},
"pip": {
"customRegistries": ["pip.company.com", "registry.internal.net"]
}
}
```
## PYPI Configuration File
If you rely on a `pip.conf` file for pip configuration you must point pip at it explicitly via the `PIP_CONFIG_FILE` environment variable so Safe Chain can merge it.
Safe Chain runs pip behind its MITM proxy and writes a temporary pip configuration file to inject its certificate and proxy settings. When `PIP_CONFIG_FILE` is set, Safe Chain merges its settings into a copy of your file (your original file is never modified) so your `index-url`, credentials, and other options are preserved. When `PIP_CONFIG_FILE` is not set, pip's user-level config (e.g. `~/.config/pip/pip.conf`) might be overridden by Safe Chain's temporary file and your settings will not be picked up.
## Malware List Base URL
Configure Safe Chain to fetch malware databases and new packages lists from a custom mirror URL. This allows you to host your own copy of the Aikido malware database.
### Configuration Options
You can set the malware list base URL through multiple sources (in order of priority):
1. **CLI Argument** (highest priority):
```shell
npm install express --safe-chain-malware-list-base-url=https://your-mirror.com
```
2. **Environment Variable**:
```shell
export SAFE_CHAIN_MALWARE_LIST_BASE_URL=https://your-mirror.com
npm install express
```
3. **Config File** (`~/.safe-chain/config.json`):
```json
{
"malwareListBaseUrl": "https://your-mirror.com"
}
```
The base URL should point to a server that mirrors the structure of `https://malware-list.aikido.dev/`, including the following paths:
- `/malware_predictions.json` (JavaScript ecosystem malware database)
- `/malware_pypi.json` (Python ecosystem malware database)
- `/releases/npm.json` (JavaScript new packages list)
- `/releases/pypi.json` (Python new packages list)
## Custom Install Directory
By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by passing an explicit install directory to the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools.
When set, all Safe Chain data (binary, shims, scripts, config) is placed under the custom directory instead of `~/.safe-chain`.
### Unix/Linux/macOS
```shell
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --install-dir /usr/local/.safe-chain
```
### Windows
```powershell
iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -InstallDir 'C:\ProgramData\safe-chain'"
```
# Usage in CI/CD # Usage in CI/CD
You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation.
## Installation for CI/CD For optimal protection in CI/CD environments, we recommend using **npm >= 10.4.0** as it provides full dependency tree scanning. Other package managers currently offer limited scanning of install command arguments only.
Use the `--ci` flag to automatically configure Aikido Safe Chain for CI/CD environments. This sets up executable shims in the PATH instead of shell aliases. ## Setup
### Unix/Linux/macOS (GitHub Actions, Azure Pipelines, etc.) To use Aikido Safe Chain in CI/CD environments, run the following command after installing the package:
```shell ```shell
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci safe-chain setup-ci
``` ```
### Windows (Azure Pipelines, etc.) To enable Python (pip/pip3) support (beta) in CI/CD, use the `--include-python` flag:
```powershell ```shell
iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" safe-chain setup-ci --include-python
``` ```
This automatically configures your CI environment to use Aikido Safe Chain for all package manager commands.
## Supported Platforms ## Supported Platforms
- ✅ **GitHub Actions** - ✅ **GitHub Actions**
- ✅ **Azure Pipelines** - ✅ **Azure Pipelines**
- ✅ **CircleCI**
- ✅ **Jenkins**
- ✅ **Bitbucket Pipelines**
- ✅ **GitLab Pipelines**
## GitHub Actions Example ## GitHub Actions Example
@ -390,11 +162,14 @@ iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download
node-version: "22" node-version: "22"
cache: "npm" cache: "npm"
- name: Install safe-chain - name: Setup safe-chain
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci run: |
npm i -g @aikidosec/safe-chain
safe-chain setup-ci
- name: Install dependencies - name: Install dependencies
run: npm ci run: |
npm ci
``` ```
## Azure DevOps Example ## Azure DevOps Example
@ -405,162 +180,14 @@ iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download
versionSpec: "22.x" versionSpec: "22.x"
displayName: "Install Node.js" displayName: "Install Node.js"
- script: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - script: |
displayName: "Install safe-chain" npm i -g @aikidosec/safe-chain
safe-chain setup-ci
displayName: "Install safe chain"
- script: npm ci - script: |
displayName: "Install dependencies" npm ci
``` displayName: "npm install and build"
## 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
```
## Jenkins Example
Note: This assumes Node.js and npm are installed on the Jenkins agent.
```groovy
pipeline {
agent any
environment {
// Jenkins does not automatically persist PATH updates from setup-ci,
// so add the shims + binary directory explicitly for all stages.
// If you installed into a custom directory, replace ~/.safe-chain with that path here.
PATH = "${env.HOME}/.safe-chain/shims:${env.HOME}/.safe-chain/bin:${env.PATH}"
}
stages {
stage('Install safe-chain') {
steps {
sh '''
set -euo pipefail
# Install Safe Chain for CI
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
'''
}
}
stage('Install project dependencies etc...') {
steps {
sh '''
set -euo pipefail
npm ci
'''
}
}
}
}
```
## Bitbucket Pipelines Example
```yaml
image: node:22
steps:
- step:
name: Install
script:
- curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- export PATH=~/.safe-chain/shims:~/.safe-chain/bin:$PATH
- npm ci
``` ```
After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection.
## GitLab Pipelines Example
To add safe-chain in GitLab pipelines, you need to install it in the image running the pipeline. This can be done by:
1. Define a dockerfile to run your build
```dockerfile
FROM node:lts
# Install safe-chain
RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
# Add safe-chain to PATH (update paths if you used a custom install dir)
ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}"
```
2. Build the Docker image in your CI pipeline
```yaml
build-image:
stage: build-image
image: docker:latest
services:
- docker:dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE:latest
```
3. Use the image in your pipeline:
```yaml
npm-ci:
stage: install
image: $CI_REGISTRY_IMAGE:latest
script:
- npm ci
```
The full pipeline for this example looks like this:
```yaml
stages:
- build-image
- install
build-image:
stage: build-image
image: docker:latest
services:
- docker:dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE:latest
npm-ci:
stage: install
image: $CI_REGISTRY_IMAGE:latest
script:
- npm ci
```
# Troubleshooting
Having issues? See the [Troubleshooting Guide](./docs/troubleshooting) for help with common problems.
# Report Issues
If you encounter problems:
1. Visit [GitHub Issues](https://github.com/AikidoSec/safe-chain/issues)
2. Include:
* Operating system and version
* Shell type and version
* `safe-chain --version` output
* Output from verification commands
* Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument)

139
build.js
View file

@ -1,139 +0,0 @@
import { build } from "esbuild";
import { mkdir, cp, rm, readFile, writeFile, stat } from "node:fs/promises";
import { spawn } from "node:child_process";
import { resolve } from "node:path";
const target = process.argv[2];
if (!target) {
// eslint-disable-next-line no-console
console.error("Usage: node build.js <target>");
// eslint-disable-next-line no-console
console.error("Example: node build.js node22-macos-arm64");
process.exit(1);
}
(async function main() {
const startBuildTime = performance.now();
await clearOutputFolder();
console.log("- Cleared output folder ✅")
// Esbuild creates a single safe-chain.cjs with all dependencies included
await bundleSafeChain();
console.log("- Bundled safe-chain into safe-chain.cjs (es-build) ✅")
// Copy assets that need to be included in the binary
// - All shell scripts that are used to setup safe-chain
// - Certifi because it contains static root certs for Python
// - Package.json for its metadata (package name, version, ...)
await copyShellScripts();
await copyCertifi();
await copyAndModifyPackageJson();
console.log("- Copied auxiliary resources (shell, package.json,...) ✅")
// Creates a single binary with safe-chain.cjs and the copied assets
await buildSafeChainBinary(target);
console.log(`- Built safe-chain binary for ${target} (pkg) ✅`)
const totalBuildTime = (performance.now() - startBuildTime)/1000;
const totalSizeInMb = (await stat("./dist/safe-chain" + (process.platform === "win32" ? ".exe" : ""))).size / (1024*1024);
console.log(`🏁 Finished build in ${totalBuildTime.toFixed(2)}s, total build size: ${totalSizeInMb.toFixed(2)}MB`);
})();
async function clearOutputFolder() {
await rm("./build", { recursive: true, force: true });
await mkdir("./build");
}
async function bundleSafeChain() {
await build({
entryPoints: ["./packages/safe-chain/bin/safe-chain.js"],
bundle: true,
platform: "node",
target: "node24",
outfile: "./build/bin/safe-chain.cjs",
external: ["certifi"],
});
let bundledContent = await readFile("./build/bin/safe-chain.cjs", "utf-8");
await writeFile("./build/bin/safe-chain.cjs", bundledContent);
}
async function copyShellScripts() {
await mkdir("./build/bin/startup-scripts", { recursive: true });
await cp(
"./packages/safe-chain/src/shell-integration/startup-scripts/",
"./build/bin/startup-scripts",
{ recursive: true }
);
await mkdir("./build/bin/path-wrappers", { recursive: true });
await cp(
"./packages/safe-chain/src/shell-integration/path-wrappers/",
"./build/bin/path-wrappers",
{ recursive: true }
);
}
async function copyCertifi() {
await mkdir("./build/node_modules/certifi", { recursive: true });
await cp("./node_modules/certifi/", "./build/node_modules/certifi", {
recursive: true,
});
}
async function copyAndModifyPackageJson() {
const packageJsonContent = await readFile(
"./packages/safe-chain/package.json",
"utf-8"
);
const packageJson = JSON.parse(packageJsonContent);
delete packageJson.main;
delete packageJson.scripts;
delete packageJson.exports;
delete packageJson.dependencies;
delete packageJson.devDependencies;
packageJson.bin = {
"safe-chain": "bin/safe-chain.cjs",
};
packageJson.type = "commonjs";
packageJson.pkg = {
outputPath: "dist",
assets: [
"node_modules/certifi/**/*",
"bin/startup-scripts/**/*",
"bin/path-wrappers/**/*",
],
};
await writeFile("./build/package.json", JSON.stringify(packageJson, null, 2));
return packageJson;
}
function buildSafeChainBinary(target) {
return new Promise((promiseResolve, reject) => {
// Use .cmd on Windows, resolve to absolute path for cross-platform compatibility
const pkgBin = process.platform === "win32"
? resolve("node_modules/.bin/pkg.cmd")
: resolve("node_modules/.bin/pkg");
let pkgArgs = [];
pkgArgs = pkgArgs.concat(["./build/package.json", "-t", target]);
const pkg = spawn(pkgBin, pkgArgs, {
stdio: "inherit",
shell: true,
});
pkg.on("close", (code) => {
if (code !== 0) {
reject(new Error(`pkg process exited with code ${code}`));
} else {
promiseResolve();
}
});
});
}

View file

@ -1,25 +0,0 @@
# Release Guide
## Steps
### 1. Create and push a version tag
```bash
git tag 1.0.0
git push origin 1.0.0
```
This triggers the build pipeline, which compiles binaries for all platforms and creates a draft GitHub release.
### 2. Wait for artifacts to build
Monitor the [Actions tab](https://github.com/AikidoSec/safe-chain/actions) until the `Create Release` workflow completes.
### 3. Publish the GitHub release
1. Go to the [Releases page](https://github.com/AikidoSec/safe-chain/releases)
2. Open the draft release created for your tag
3. Add release notes
4. Click **Publish release**
Publishing the release automatically triggers an npm publish. Pre-release versions (e.g. `1.0.0-beta`) are published to npm under a tag matching the pre-release identifier (e.g. `beta`). Stable versions are published to the `latest` tag.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

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

View file

@ -1,298 +0,0 @@
# Troubleshooting
This guide helps you diagnose and resolve common issues with Aikido Safe Chain.
## Verification & Diagnostics
**Check Installation**
```bash
# Check version
safe-chain --version
```
**Verify Shell Integration**
Run the verification command for your package manager:
```bash
npm safe-chain-verify
pnpm safe-chain-verify
```
```
Expected output: `OK: Safe-chain works!`
```
**Test Malware Blocking**
Verify that malware detection is working:
```
npm install safe-chain-test
```
These test packages are flagged as malware and should be blocked by Safe Chain.
**If the test package installs successfully instead of being blocked**, see Malware Not Being Blocked below.
## Logging Options
Use logging flags or environment variables to get more information:
```bash
# Verbose mode - detailed diagnostic output for troubleshooting
npm install express --safe-chain-logging=verbose
# Or set it globally for all commands in your session
export SAFE_CHAIN_LOGGING=verbose
npm install express
# Silent mode - suppress all output except malware blocking
npm install express --safe-chain-logging=silent
```
## Common Issues
### Malware Not Being Blocked
**Symptom:** Test malware packages (like `safe-chain-test`) install successfully when they should be blocked
**Most Common Cause:** The package is cached in your package manager's local store
Safe-chain blocks malicious packages by intercepting network requests to package registries using its proxy.
When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy.
**Resolution Steps**
1) Clear your package manager's cache
```bash
# For npm
npm cache clean --force
# For pnpm
pnpm store prune
# For yarn (classic)
yarn cache clean
# For yarn (berry/v2+)
yarn cache clean --all
# For bun
bun pm cache rm
```
2) Clean local installation artifacts:
```bash
# Remove node_modules if you want a completely fresh install
rm -rf node_modules
```
3) Re-test malware blocking:
```bash
npm install safe-chain-test # Should be blocked
```
### Shell Aliases Not Working After Installation
**Symptom:** Running `npm` shows regular npm instead of safe-chain wrapped version
**First step:** Restart your terminal (most common fix)
**Verify it's working:**
```bash
type npm
```
Should show: `npm is a function`
**If still not working:**
Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`:
* Bash: `~/.bashrc`
* Zsh: `~/.zshrc`
* Fish: `~/.config/fish/config.fish`
* PowerShell: `$PROFILE`
### "Command Not Found: safe-chain"
**Symptom:** Binary not found in PATH
**First step:** Restart your terminal
**Check PATH:**
```bash
echo $PATH
```
Should include `~/.safe-chain/bin`
**If persists:** Re-run the installation script
### PowerShell Execution Policy Blocks Scripts (Windows)
**Symptom:** When opening PowerShell, you see an error like:
```
. : File C:\Users\<username>\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 cannot be loaded because
running scripts is disabled on this system.
CategoryInfo : SecurityError: (:) [], PSSecurityException
FullyQualifiedErrorId : UnauthorizedAccess
```
**Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile.
**Resolution**
1) Set the execution policy to allow local scripts
Open PowerShell as Administrator and run:
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned
```
This allows:
* Local scripts (like safe-chain's) to run without signing
* Downloaded scripts to run only if signed by a trusted publisher
2) Restart PowerShell and verify the error is resolved.
> [!IMPORTANT]
> `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability.
### Shell Aliases Persist After Uninstallation
**Symptom:** safe-chain commands still active after running uninstall script
**Steps**
1. Run `safe-chain teardown` (if binary still exists)
2. Restart your terminal
3. If still present, manually edit shell config files:
* Bash: `~/.bashrc`
* Zsh: `~/.zshrc`
* Fish: `~/.config/fish/config.fish`
* PowerShell: `$PROFILE`
4. Remove lines that source scripts from `~/.safe-chain/scripts/`
5. Restart terminal again
## Manual Verification Steps
### Check Installation Status
```bash
# Check installation location (helps identify if installed via npm or as standalone binary)
which safe-chain
# Verify binary exists
ls ~/.safe-chain/bin/safe-chain
# Check version
safe-chain --version
# Test shell integration
type npm
type pip
```
**Expected `which` output:**
* Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users/<username>/.safe-chain/bin/safe-chain`
* npm global (outdated): path containing `node_modules` or nvm version paths
If `which` shows an npm installation, see Check for Conflicting Installations.
### Check Shell Integration
```bash
# Which shell you're using
echo $SHELL
# Check if startup file sources safe-chain
# For Bash:
grep safe-chain ~/.bashrc
# For Zsh:
grep safe-chain ~/.zshrc
# For Fish:
grep safe-chain ~/.config/fish/config.fish
# Verify scripts exist
ls ~/.safe-chain/scripts/
```
### Check for Conflicting Installations
> **Note:** The install/uninstall scripts automatically detect and remove conflicting installations, but you can manually check:
```bash
# Check npm global
npm list -g @aikidosec/safe-chain
# Check Volta
volta list safe-chain
# Check nvm (all versions)
for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do
nvm exec "$version" npm list -g @aikidosec/safe-chain 2>/dev/null && echo "Found in $version"
done
```
### Manual Cleanup
> **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails.
#### Remove npm Global Installation
```bash
npm uninstall -g @aikidosec/safe-chain
```
#### Remove Volta Installation
```bash
volta uninstall @aikidosec/safe-chain
```
#### Remove nvm Installations (All Versions)
```bash
# Automated approach
for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do
nvm exec "$version" npm uninstall -g @aikidosec/safe-chain
done
# Or manual per version
nvm use <version>
npm uninstall -g @aikidosec/safe-chain
```
#### Clean Shell Configuration Files
Manually remove safe-chain entries from:
* Bash: `~/.bashrc`
* Zsh: `~/.zshrc`
* Fish: `~/.config/fish/config.fish`
* PowerShell: `$PROFILE`
Look for and remove:
* Lines sourcing from `~/.safe-chain/scripts/`
* Any safe-chain related function definitions
#### Remove Installation Directory
```bash
rm -rf ~/.safe-chain
```

View file

@ -1,133 +0,0 @@
#!/bin/sh
# Downloads and installs Aikido Endpoint Protection on macOS
#
# Usage: curl -fsSL <url> | sudo sh -s -- --token <TOKEN>
set -e # Exit on error
# Configuration
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.pkg"
DOWNLOAD_SHA256="ad800f9e476b0a75bf32b1c079f060ecb98bc16972a4e8cca29cf165388ea9fe"
TOKEN_FILE="/tmp/aikido_endpoint_token.txt"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
# Helper functions
info() {
printf "${GREEN}[INFO]${NC} %s\n" "$1"
}
error() {
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
exit 1
}
# Download file
download() {
url="$1"
dest="$2"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url" -o "$dest" || error "Failed to download from $url"
elif command -v wget >/dev/null 2>&1; then
wget -q "$url" -O "$dest" || error "Failed to download from $url"
else
error "Neither curl nor wget found. Please install one of them."
fi
}
# Verify SHA256 checksum
verify_checksum() {
file="$1"
expected="$2"
actual=$(shasum -a 256 "$file" | awk '{ print $1 }')
if [ "$actual" != "$expected" ]; then
error "Checksum verification failed. Expected: $expected, Got: $actual"
fi
info "Checksum verified successfully."
}
# Cleanup temporary files
cleanup() {
if [ -f "$PKG_FILE" ]; then
rm -f "$PKG_FILE"
fi
if [ -f "$TOKEN_FILE" ]; then
rm -f "$TOKEN_FILE"
fi
}
# Parse command-line arguments
parse_arguments() {
TOKEN=""
while [ $# -gt 0 ]; do
case "$1" in
--token)
if [ -z "${2:-}" ]; then
error "--token requires a value"
fi
TOKEN="$2"
shift 2
;;
*)
error "Unknown argument: $1"
;;
esac
done
}
# Main installation
main() {
parse_arguments "$@"
# 1. Check if we're running on macOS
if [ "$(uname -s)" != "Darwin" ]; then
error "This script is only supported on macOS."
fi
# Check if we're running as root
if [ "$(id -u)" -ne 0 ]; then
error "Root privileges required. Please re-run with sudo, e.g.: curl -fsSL <url> | sudo sh -s -- --token <TOKEN>"
fi
# Check if token is provided via command argument
if [ -z "$TOKEN" ]; then
error "Token is required. Pass it with --token <TOKEN> or enter it when prompted."
fi
# Validate token to prevent injection
case "$TOKEN" in
*[\"\'\;\`\$\ ]*)
error "Invalid token format. Token must not contain quotes, semicolons, backticks, dollar signs, or whitespace."
;;
esac
# 2. Download and verify checksum
PKG_FILE=$(mktemp /tmp/AikidoEndpoint.XXXXXX.pkg)
trap cleanup EXIT
info "Downloading Aikido Endpoint Protection..."
download "$INSTALL_URL" "$PKG_FILE"
info "Verifying checksum..."
verify_checksum "$PKG_FILE" "$DOWNLOAD_SHA256"
# 3. Write token to file for the installer
printf "%s" "$TOKEN" > "$TOKEN_FILE"
# 4. Install the package
info "Installing Aikido Endpoint Protection..."
installer -pkg "$PKG_FILE" -target /
info "Aikido Endpoint Protection installed successfully!"
}
main "$@"

View file

@ -1,100 +0,0 @@
# Downloads and installs Aikido Endpoint Protection on Windows
#
# Usage: iex "& { $(iwr '<url>' -UseBasicParsing) } -token <TOKEN>"
param(
[string]$token
)
# Configuration
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.msi"
$DownloadSha256 = "e2750c59124f53456a8f9cdb9e81fd9ce2f2491869f68f01602444ad519be5be"
# Ensure TLS 1.2 is enabled for downloads
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# Helper functions
function Write-Info {
param([string]$Message)
Write-Host "[INFO] $Message" -ForegroundColor Green
}
function Write-Error-Custom {
param([string]$Message)
Write-Host "[ERROR] $Message" -ForegroundColor Red
exit 1
}
# Check if running as Administrator
function Test-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
# Main installation
function Install-Endpoint {
# 1. Check if we're running as Administrator
if (-not (Test-Administrator)) {
Write-Error-Custom "Administrator privileges required. Please run this script in an elevated terminal (Run as Administrator)."
}
# Check if token is provided, prompt if not
if ([string]::IsNullOrWhiteSpace($token)) {
$token = Read-Host "Enter your Aikido endpoint token"
if ([string]::IsNullOrWhiteSpace($token)) {
Write-Error-Custom "Token is required. Pass it with -token <TOKEN> or enter it when prompted."
}
}
# Validate token to prevent command/property injection via msiexec
if ($token -match '[";`$\s]') {
Write-Error-Custom "Invalid token format. Token must not contain quotes, semicolons, backticks, dollar signs, or whitespace."
}
# 2. Download the .msi
$msiFile = Join-Path $env:TEMP "AikidoEndpoint-$([System.Guid]::NewGuid().ToString('N')).msi"
Write-Info "Downloading Aikido Endpoint Protection..."
try {
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $InstallUrl -OutFile $msiFile -UseBasicParsing
$ProgressPreference = 'Continue'
}
catch {
Write-Error-Custom "Failed to download from $InstallUrl : $_"
}
try {
# Verify SHA256 checksum
Write-Info "Verifying checksum..."
$actualHash = (Get-FileHash -Path $msiFile -Algorithm SHA256).Hash.ToLower()
if ($actualHash -ne $DownloadSha256) {
Write-Error-Custom "Checksum verification failed. Expected: $DownloadSha256, Got: $actualHash"
}
Write-Info "Checksum verified successfully."
# 3. Install the package with token passed as MSI property
Write-Info "Installing Aikido Endpoint Protection..."
$process = Start-Process -FilePath "msiexec" -ArgumentList "/i", "`"$msiFile`"", "/qn", "/norestart", "AIKIDO_TOKEN=$token" -Wait -PassThru
if ($process.ExitCode -ne 0) {
Write-Error-Custom "MSI installer failed (exit code: $($process.ExitCode))."
}
Write-Info "Aikido Endpoint Protection installed successfully!"
}
finally {
# Cleanup
if (Test-Path $msiFile) {
Remove-Item -Path $msiFile -Force -ErrorAction SilentlyContinue
}
}
}
# Run installation
try {
Install-Endpoint
}
catch {
Write-Error-Custom "Installation failed: $_"
}

View file

@ -1,381 +0,0 @@
# Downloads and installs safe-chain for Windows
#
# Usage with "iex (iwr {url} -UseBasicParsing)" --> See README.md
param(
[switch]$ci,
[switch]$includepython,
[string]$InstallDir
)
# Validates and normalizes the requested install directory.
# Rejects non-absolute, root, PATH-like, and traversal-containing paths.
function Test-InstallDir {
param([string]$Dir)
if ([string]::IsNullOrWhiteSpace($Dir)) {
return @{ Ok = $true; Normalized = $null }
}
if (-not [System.IO.Path]::IsPathRooted($Dir)) {
return @{ Ok = $false; Reason = "-InstallDir must be an absolute path, got: $Dir" }
}
if ($Dir.Contains([System.IO.Path]::PathSeparator)) {
return @{ Ok = $false; Reason = "-InstallDir must not contain the PATH separator ($([System.IO.Path]::PathSeparator))" }
}
$inputSegments = $Dir.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries)
if ($inputSegments -contains "..") {
return @{ Ok = $false; Reason = "-InstallDir must not contain path traversal segments" }
}
$normalized = [System.IO.Path]::GetFullPath($Dir)
$root = [System.IO.Path]::GetPathRoot($normalized)
if ($normalized.TrimEnd('\', '/') -eq $root.TrimEnd('\', '/')) {
return @{ Ok = $false; Reason = "-InstallDir cannot be a root or drive-root directory" }
}
return @{ Ok = $true; Normalized = $normalized }
}
$Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set
$SafeChainBase = if ($InstallDir) { $InstallDir } else { Join-Path $HOME ".safe-chain" }
$installDirValidation = Test-InstallDir -Dir $SafeChainBase
if (-not $installDirValidation.Ok) {
Write-Host "[ERROR] $($installDirValidation.Reason)" -ForegroundColor Red
exit 1
}
$SafeChainBase = $installDirValidation.Normalized
$InstallDir = Join-Path $SafeChainBase "bin"
$RepoUrl = "https://github.com/AikidoSec/safe-chain"
# SHA256 checksums for release binaries.
# Empty in source; populated by the release pipeline.
# When empty (running from main), checksum verification is skipped.
# Non-Windows hashes are unused today (PS script is Windows-only) but baked in
# for future cross-platform support.
$SHA256_MACOS_X64 = ""
$SHA256_MACOS_ARM64 = ""
$SHA256_LINUX_X64 = ""
$SHA256_LINUX_ARM64 = ""
$SHA256_LINUXSTATIC_X64 = ""
$SHA256_LINUXSTATIC_ARM64 = ""
$SHA256_WIN_X64 = ""
$SHA256_WIN_ARM64 = ""
# Ensure TLS 1.2 is enabled for downloads
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# 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
}
# 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 {
$response = Invoke-RestMethod -Uri "https://api.github.com/repos/AikidoSec/safe-chain/releases/latest" -UseBasicParsing
$latestVersion = $response.tag_name
if ([string]::IsNullOrWhiteSpace($latestVersion)) {
Write-Error-Custom "Failed to fetch latest version from GitHub API. Please set SAFE_CHAIN_VERSION environment variable."
}
return $latestVersion
}
catch {
Write-Error-Custom "Failed to fetch latest version from GitHub API: $($_.Exception.Message). Please set SAFE_CHAIN_VERSION environment variable."
}
}
# Detect architecture
function Get-Architecture {
$arch = $env:PROCESSOR_ARCHITECTURE
switch ($arch) {
"AMD64" { return "x64" }
"ARM64" { return "arm64" }
default { Write-Error-Custom "Unsupported architecture: $arch" }
}
}
# Emits the deprecation warning for SAFE_CHAIN_VERSION and prints the version-pinned install command.
# Returns immediately when no version was provided through the environment.
function Write-VersionDeprecationWarning {
if ([string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) {
return
}
Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated."
Write-Warn ""
Write-Warn "Please use direct download URLs for version pinning instead:"
Write-Warn ""
if ($ci) {
Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`""
} else {
Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)"
}
Write-Warn ""
}
# Builds the Windows release binary filename for the detected architecture.
# Centralizes binary name generation for the download step.
function Get-BinaryName {
param([string]$Architecture)
return "safe-chain-win-$Architecture.exe"
}
# Returns the expected SHA256 for the given OS+arch, or empty if not baked in.
function Get-ExpectedSha256 {
param([string]$Os, [string]$Architecture)
switch ("$Os-$Architecture") {
"macos-x64" { return $SHA256_MACOS_X64 }
"macos-arm64" { return $SHA256_MACOS_ARM64 }
"linux-x64" { return $SHA256_LINUX_X64 }
"linux-arm64" { return $SHA256_LINUX_ARM64 }
"linuxstatic-x64" { return $SHA256_LINUXSTATIC_X64 }
"linuxstatic-arm64" { return $SHA256_LINUXSTATIC_ARM64 }
"win-x64" { return $SHA256_WIN_X64 }
"win-arm64" { return $SHA256_WIN_ARM64 }
default { return "" }
}
}
function Test-Checksum {
param([string]$File, [string]$Expected)
if ([string]::IsNullOrWhiteSpace($Expected)) { return }
$actual = (Get-FileHash -Path $File -Algorithm SHA256).Hash.ToLowerInvariant()
$expectedLower = $Expected.ToLowerInvariant()
if ($actual -ne $expectedLower) {
Remove-Item -Path $File -Force -ErrorAction SilentlyContinue
Write-Error-Custom "Checksum verification failed. Expected: $expectedLower, Got: $actual"
}
Write-Info "Checksum verified."
}
# Runs safe-chain setup or setup-ci after the binary is installed.
# Temporarily appends the install directory to PATH and downgrades setup failures to warnings.
function Invoke-SafeChainSetup {
param(
[string]$BinaryPath,
[string]$InstallDirectory
)
$setupCmd = if ($ci) { "setup-ci" } else { "setup" }
Write-Info "Running safe-chain $setupCmd..."
try {
$env:Path = "$env:Path;$InstallDirectory"
& $BinaryPath $setupCmd
if ($LASTEXITCODE -ne 0) {
Write-Warn "safe-chain was installed but setup encountered issues."
Write-Warn "You can run 'safe-chain $setupCmd' manually later."
}
}
catch {
Write-Warn "safe-chain was installed but setup encountered issues: $_"
Write-Warn "You can run 'safe-chain $setupCmd' manually later."
}
}
# 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 installation
function Install-SafeChain {
Write-VersionDeprecationWarning
# Fetch latest version if VERSION is not set
if ([string]::IsNullOrWhiteSpace($Version)) {
Write-Info "Fetching latest release version..."
$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 ($ci) {
$installMsg += " in ci"
}
if ($includepython) {
Write-Warn "-includepython is deprecated and ignored. Python ecosystem is now included by default."
}
Write-Info $installMsg
# Check for existing safe-chain installation through npm or volta
Remove-NpmInstallation
Remove-VoltaInstallation
# Detect platform
$arch = Get-Architecture
$binaryName = Get-BinaryName -Architecture $arch
Write-Info "Detected architecture: $arch"
# Create installation directory
if (-not (Test-Path $InstallDir)) {
Write-Info "Creating installation directory: $InstallDir"
try {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
catch {
Write-Error-Custom "Failed to create directory $InstallDir : $_"
}
}
# Download binary
$downloadUrl = "$RepoUrl/releases/download/$Version/$binaryName"
$tempFile = Join-Path $InstallDir $binaryName
Write-Info "Downloading from: $downloadUrl"
try {
# Download with progress suppressed for cleaner output
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $downloadUrl -OutFile $tempFile -UseBasicParsing
$ProgressPreference = 'Continue'
}
catch {
Write-Error-Custom "Failed to download from $downloadUrl : $_"
}
$expectedSha = Get-ExpectedSha256 -Os "win" -Architecture $arch
Test-Checksum -File $tempFile -Expected $expectedSha
# Rename to final location
$finalFile = Join-Path $InstallDir "safe-chain.exe"
try {
# Remove existing file if present (Move-Item -Force doesn't overwrite)
if (Test-Path $finalFile) {
Remove-Item -Path $finalFile -Force
}
Move-Item -Path $tempFile -Destination $finalFile -Force
}
catch {
Write-Error-Custom "Failed to move binary to $finalFile : $_"
}
Write-Info "Binary installed to: $finalFile"
Invoke-SafeChainSetup -BinaryPath $finalFile -InstallDirectory $InstallDir
}
# Run installation
try {
Install-SafeChain
}
catch {
Write-Error-Custom "Installation failed: $_"
}

View file

@ -1,509 +0,0 @@
#!/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
# Validates a user-provided install dir and exits on unsafe values.
# Rejects relative paths, root paths, PATH separators, and traversal segments.
validate_install_dir() {
dir="$1"
if [ -z "$dir" ]; then
return 0
fi
case "$dir" in
/*) ;;
*)
printf '[ERROR] --install-dir must be an absolute path, got: %s\n' "$dir" >&2
exit 1
;;
esac
case "$dir" in
*:*)
printf '[ERROR] --install-dir must not contain the PATH separator (:)\n' >&2
exit 1
;;
esac
if [ "$dir" = "/" ]; then
printf '[ERROR] --install-dir cannot be a root or drive-root directory\n' >&2
exit 1
fi
old_ifs=$IFS
IFS='/'
set -- $dir
IFS=$old_ifs
for segment in "$@"; do
if [ "$segment" = ".." ]; then
printf '[ERROR] --install-dir must not contain path traversal segments\n' >&2
exit 1
fi
done
}
# Configuration
VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set
SAFE_CHAIN_BASE="${HOME}/.safe-chain"
INSTALL_DIR="${SAFE_CHAIN_BASE}/bin"
REPO_URL="https://github.com/AikidoSec/safe-chain"
# SHA256 checksums for release binaries.
# Empty in source; populated by the release pipeline via sed.
# When empty (running from main), checksum verification is skipped.
SHA256_MACOS_X64=""
SHA256_MACOS_ARM64=""
SHA256_LINUX_X64=""
SHA256_LINUX_ARM64=""
SHA256_LINUXSTATIC_X64=""
SHA256_LINUXSTATIC_ARM64=""
SHA256_WIN_X64=""
SHA256_WIN_ARM64=""
# Colors for output
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
}
# Detect OS
# For legacy versions (when SAFE_CHAIN_VERSION is set), use 'linux' instead of 'linuxstatic'
detect_os() {
case "$(uname -s)" in
Linux*)
if [ -n "$SAFE_CHAIN_VERSION" ]; then
echo "linux"
else
echo "linuxstatic"
fi
;;
Darwin*) echo "macos" ;;
MINGW*|MSYS*|CYGWIN*) echo "win" ;;
*) error "Unsupported operating system: $(uname -s)" ;;
esac
}
# Detect architecture
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "x64" ;;
aarch64|arm64) echo "arm64" ;;
*) error "Unsupported architecture: $(uname -m)" ;;
esac
}
# Check if command exists
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
if command_exists curl; then
latest_version=$(curl -fsSL "https://api.github.com/repos/AikidoSec/safe-chain/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
elif command_exists wget; then
latest_version=$(wget -qO- "https://api.github.com/repos/AikidoSec/safe-chain/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
else
error "Neither curl nor wget found. Please install one of them or set SAFE_CHAIN_VERSION environment variable."
fi
if [ -z "$latest_version" ]; then
error "Failed to fetch latest version from GitHub API. Please set SAFE_CHAIN_VERSION environment variable."
fi
echo "$latest_version"
}
# Returns the expected SHA256 for the detected platform, or empty if the
# release pipeline has not baked one in (i.e. running the source from main).
get_expected_sha256() {
os="$1"; arch="$2"
case "${os}-${arch}" in
macos-x64) echo "$SHA256_MACOS_X64" ;;
macos-arm64) echo "$SHA256_MACOS_ARM64" ;;
linux-x64) echo "$SHA256_LINUX_X64" ;;
linux-arm64) echo "$SHA256_LINUX_ARM64" ;;
linuxstatic-x64) echo "$SHA256_LINUXSTATIC_X64" ;;
linuxstatic-arm64) echo "$SHA256_LINUXSTATIC_ARM64" ;;
win-x64) echo "$SHA256_WIN_X64" ;;
win-arm64) echo "$SHA256_WIN_ARM64" ;;
*) echo "" ;;
esac
}
compute_sha256() {
file="$1"
if command_exists sha256sum; then
sha256sum "$file" | awk '{print $1}'
elif command_exists shasum; then
shasum -a 256 "$file" | awk '{print $1}'
else
echo ""
fi
}
# Verifies the downloaded binary against the expected hash baked in by the release pipeline.
# No-op when no expected hash is set (running the script from main).
verify_checksum() {
file="$1"; expected="$2"
if [ -z "$expected" ]; then
return
fi
actual=$(compute_sha256 "$file")
if [ -z "$actual" ]; then
rm -f "$file"
error "Cannot verify checksum: neither sha256sum nor shasum is available. Install one and re-run."
fi
if [ "$actual" != "$expected" ]; then
rm -f "$file"
error "Checksum verification failed. Expected: $expected, Got: $actual"
fi
info "Checksum verified."
}
# Download file
download() {
url="$1"
dest="$2"
if command_exists curl; then
curl -fsSL "$url" -o "$dest" || error "Failed to download from $url"
elif command_exists wget; then
wget -q "$url" -O "$dest" || error "Failed to download from $url"
else
error "Neither curl nor wget found. Please install one of them."
fi
}
# Prints the deprecation warning for SAFE_CHAIN_VERSION and the replacement install command.
# Returns immediately when no version was pinned through the environment.
warn_deprecated_version_env() {
if [ -z "$SAFE_CHAIN_VERSION" ]; then
return
fi
warn "SAFE_CHAIN_VERSION environment variable is deprecated."
warn ""
warn "Please use direct download URLs for version pinning instead:"
warn ""
if [ "$USE_CI_SETUP" = "true" ]; then
warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci"
else
warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh"
fi
warn ""
}
# Ensures VERSION is populated before installation continues.
# Fetches the latest release only when no explicit version was provided.
ensure_version() {
if [ -n "$VERSION" ]; then
return
fi
info "Fetching latest release version..."
VERSION=$(fetch_latest_version)
}
# Constructs platform-specific binary filename to match GitHub release asset naming convention.
get_binary_name() {
os="$1"
arch="$2"
if [ "$os" = "win" ]; then
printf 'safe-chain-%s-%s.exe\n' "$os" "$arch"
else
printf 'safe-chain-%s-%s\n' "$os" "$arch"
fi
}
# Returns the final installation path for the downloaded safe-chain binary.
# Uses INSTALL_DIR and the platform-specific executable name.
get_final_binary_path() {
os="$1"
if [ "$os" = "win" ]; then
printf '%s/safe-chain.exe\n' "$INSTALL_DIR"
else
printf '%s/safe-chain\n' "$INSTALL_DIR"
fi
}
run_setup_command() {
final_file="$1"
setup_cmd="setup"
if [ "$USE_CI_SETUP" = "true" ]; then
setup_cmd="setup-ci"
fi
info "Running safe-chain $setup_cmd..."
if ! "$final_file" "$setup_cmd"; then
warn "safe-chain was installed but setup encountered issues."
warn "You can run 'safe-chain $setup_cmd' manually later."
fi
}
# 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
}
# Check and uninstall nvm-managed package if present across all Node versions
remove_nvm_installation() {
# This script is run in sh shell for greatest compatibility.
# Because nvm is usually setup in bash/zsh/fish startup scripts, we need to source it.
# Otherwise it won't be available in sh.
if [ -s "$HOME/.nvm/nvm.sh" ]; then
# Source nvm to make it available in this script
. "$HOME/.nvm/nvm.sh" >/dev/null 2>&1
elif [ -s "$NVM_DIR/nvm.sh" ]; then
. "$NVM_DIR/nvm.sh" >/dev/null 2>&1
fi
# Check if nvm is now available
if ! command_exists nvm; then
return
fi
nvm_versions=$(nvm list 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || echo "")
if [ -z "$nvm_versions" ]; then
return
fi
# Track if we found any installations
found_installation=false
uninstall_failed=false
current_version=$(nvm current 2>/dev/null || echo "")
# Check each version for safe-chain installation
for version in $nvm_versions; do
# Check if this version has safe-chain installed
# Use nvm exec to run npm list in the context of that Node version
if nvm exec "$version" npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then
if [ "$found_installation" = false ]; then
info "Detected nvm installation(s) of @aikidosec/safe-chain"
info "Uninstalling from all Node versions..."
found_installation=true
fi
info " Removing from Node $version..."
if nvm exec "$version" npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then
info " Successfully uninstalled from Node $version"
else
warn " Failed to uninstall from Node $version"
uninstall_failed=true
fi
fi
done
# Restore original Node version if it was set
if [ -n "$current_version" ] && [ "$current_version" != "none" ] && [ "$current_version" != "system" ]; then
nvm use "$current_version" >/dev/null 2>&1 || true
fi
# If any uninstall failed, error out instead of continuing
if [ "$uninstall_failed" = true ]; then
error "Failed to uninstall @aikidosec/safe-chain from all nvm Node versions. Please uninstall manually and try again."
fi
}
# Parse command-line arguments
parse_arguments() {
while [ $# -gt 0 ]; do
case "$1" in
--ci)
USE_CI_SETUP=true
;;
--install-dir)
shift
if [ $# -eq 0 ]; then
error "Missing value for --install-dir"
fi
if [ -z "$1" ]; then
error "--install-dir must not be empty"
fi
SAFE_CHAIN_BASE="$1"
;;
--install-dir=*)
SAFE_CHAIN_BASE="${1#--install-dir=}"
if [ -z "$SAFE_CHAIN_BASE" ]; then
error "--install-dir must not be empty"
fi
;;
--include-python)
warn "--include-python is deprecated and ignored. Python ecosystem is now included by default."
;;
*)
error "Unknown argument: $1"
;;
esac
shift
done
validate_install_dir "${SAFE_CHAIN_BASE}"
INSTALL_DIR="${SAFE_CHAIN_BASE}/bin"
}
# Main installation
main() {
# Initialize argument flags
USE_CI_SETUP=false
# Parse command-line arguments
parse_arguments "$@"
warn_deprecated_version_env
ensure_version
# 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 [ "$USE_CI_SETUP" = "true" ]; then
INSTALL_MSG="${INSTALL_MSG} in ci"
fi
info "$INSTALL_MSG"
# Check for existing safe-chain installation through nvm, volta, or npm
remove_npm_installation
remove_volta_installation
remove_nvm_installation
# Detect platform
OS=$(detect_os)
ARCH=$(detect_arch)
BINARY_NAME=$(get_binary_name "$OS" "$ARCH")
info "Detected platform: ${OS}-${ARCH}"
# Create installation directory
if [ ! -d "$INSTALL_DIR" ]; then
info "Creating installation directory: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR" || error "Failed to create directory $INSTALL_DIR"
fi
# Download binary
DOWNLOAD_URL="${REPO_URL}/releases/download/${VERSION}/${BINARY_NAME}"
TEMP_FILE="${INSTALL_DIR}/${BINARY_NAME}"
info "Downloading from: $DOWNLOAD_URL"
download "$DOWNLOAD_URL" "$TEMP_FILE"
EXPECTED_SHA256=$(get_expected_sha256 "$OS" "$ARCH")
verify_checksum "$TEMP_FILE" "$EXPECTED_SHA256"
# Rename and make executable
FINAL_FILE=$(get_final_binary_path "$OS")
mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE"
if [ "$OS" != "win" ]; then
chmod +x "$FINAL_FILE" || error "Failed to make binary executable"
fi
info "Binary installed to: $FINAL_FILE"
run_setup_command "$FINAL_FILE"
}
main "$@"

View file

@ -1,50 +0,0 @@
#!/bin/sh
# Uninstalls Aikido Endpoint Protection on macOS
#
# Usage: curl -fsSL <url> | sudo sh
set -e # Exit on error
# Configuration
UNINSTALL_SCRIPT="/Applications/Aikido Endpoint Protection.app/Contents/Resources/scripts/uninstall"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
# Helper functions
info() {
printf "${GREEN}[INFO]${NC} %s\n" "$1"
}
error() {
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
exit 1
}
# Main uninstallation
main() {
# Check if we're running on macOS
if [ "$(uname -s)" != "Darwin" ]; then
error "This script is only supported on macOS."
fi
# Check if we're running as root
if [ "$(id -u)" -ne 0 ]; then
error "Root privileges required. Please re-run with sudo, e.g.: curl -fsSL <url> | sudo sh"
fi
# Check if the uninstall script exists
if [ ! -f "$UNINSTALL_SCRIPT" ]; then
error "Aikido Endpoint Protection does not appear to be installed (uninstall script not found)."
fi
info "Uninstalling Aikido Endpoint Protection..."
"$UNINSTALL_SCRIPT"
info "Aikido Endpoint Protection uninstalled successfully!"
}
main "$@"

View file

@ -1,59 +0,0 @@
# Uninstalls Aikido Endpoint Protection endpoint on Windows
#
# Usage: iex (iwr '<url>' -UseBasicParsing)
# Configuration
$AppName = "Aikido Endpoint Protection"
# Helper functions
function Write-Info {
param([string]$Message)
Write-Host "[INFO] $Message" -ForegroundColor Green
}
function Write-Error-Custom {
param([string]$Message)
Write-Host "[ERROR] $Message" -ForegroundColor Red
exit 1
}
# Check if running as Administrator
function Test-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
# Main uninstallation
function Uninstall-Endpoint {
# Check if we're running as Administrator
if (-not (Test-Administrator)) {
Write-Error-Custom "Administrator privileges required. Please run this script in an elevated terminal (Run as Administrator)."
}
# Find the installed product
Write-Info "Looking for Aikido Endpoint Protection installation..."
$app = Get-WmiObject -Class Win32_Product -Filter "Name='$AppName'"
if (-not $app) {
Write-Error-Custom "Aikido Endpoint Protection does not appear to be installed."
}
$productCode = $app.IdentifyingNumber
Write-Info "Uninstalling Aikido Endpoint Protection..."
$process = Start-Process -FilePath "msiexec" -ArgumentList "/x", $productCode, "/qn", "/norestart" -Wait -PassThru
if ($process.ExitCode -ne 0) {
Write-Error-Custom "Uninstall failed (exit code: $($process.ExitCode))."
}
Write-Info "Aikido Endpoint Protection uninstalled successfully!"
}
# Run uninstallation
try {
Uninstall-Endpoint
}
catch {
Write-Error-Custom "Uninstallation failed: $_"
}

View file

@ -1,249 +0,0 @@
# 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 }
# 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
}
# Derives the safe-chain base install directory from a resolved binary path.
# Rejects wrapper scripts and paths that do not match the packaged bin layout.
function Get-InstallDirFromBinaryPath {
param([string]$BinaryPath)
if ([string]::IsNullOrWhiteSpace($BinaryPath)) {
return $null
}
try {
$resolvedPath = (Resolve-Path -LiteralPath $BinaryPath -ErrorAction Stop).Path
}
catch {
$resolvedPath = [System.IO.Path]::GetFullPath($BinaryPath)
}
$fileName = [System.IO.Path]::GetFileName($resolvedPath)
if (($fileName -ne "safe-chain") -and ($fileName -ne "safe-chain.exe")) {
return $null
}
if ($resolvedPath -match '\.(js|cjs|mjs|cmd|ps1)$') {
return $null
}
$binDir = Split-Path -Parent $resolvedPath
if ((Split-Path -Leaf $binDir) -ne "bin") {
return $null
}
return (Split-Path -Parent $binDir)
}
# Returns the first safe-chain command found on PATH, if any.
# Used as the starting point for install-dir discovery.
function Get-SafeChainCommand {
return Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1
}
# Returns the safe-chain command path only when it points to a valid packaged binary install.
# Prevents teardown from invoking arbitrary wrappers or scripts from PATH.
function Get-ValidatedSafeChainCommandPath {
$command = Get-SafeChainCommand
if (-not $command -or [string]::IsNullOrWhiteSpace($command.Path)) {
return $null
}
$installDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path
if (-not $installDir) {
return $null
}
return $command.Path
}
# Invokes the validated safe-chain binary with get-install-dir and returns the reported base directory.
# Safely returns $null when the command is unavailable or the lookup fails.
function Get-ReportedInstallDir {
$safeChainPath = Get-ValidatedSafeChainCommandPath
if (-not $safeChainPath) {
return $null
}
try {
$reportedInstallDir = & $safeChainPath get-install-dir 2>$null | Select-Object -First 1
if ($reportedInstallDir) {
$reportedInstallDir = $reportedInstallDir.Trim()
}
if ($reportedInstallDir) {
return $reportedInstallDir
}
}
catch {
return $null
}
return $null
}
# Determines the safe-chain base install directory for uninstall.
# Prefers the binary-reported location, then derives it from PATH, then falls back to the default home-dir layout.
function Get-SafeChainInstallDir {
$reportedInstallDir = Get-ReportedInstallDir
if ($reportedInstallDir) {
return $reportedInstallDir
}
$command = Get-SafeChainCommand
if ($command -and $command.Path) {
$discoveredInstallDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path
if ($discoveredInstallDir) {
return $discoveredInstallDir
}
}
return (Join-Path $HomeDir ".safe-chain")
}
# Finds the installed safe-chain binary inside the resolved install directory.
# Falls back to a validated safe-chain command when the expected file is missing.
function Find-SafeChainBinary {
param([string]$DotSafeChain)
$safeChainExe = Join-Path $DotSafeChain "bin/safe-chain.exe"
$safeChainBin = Join-Path $DotSafeChain "bin/safe-chain"
if (Test-Path $safeChainExe) {
return $safeChainExe
}
if (Test-Path $safeChainBin) {
return $safeChainBin
}
return Get-ValidatedSafeChainCommandPath
}
# Runs safe-chain teardown before removing the installation directory.
# Converts teardown failures into warnings so uninstall can still complete.
function Invoke-SafeChainTeardown {
param([string]$SafeChainPath)
if (-not $SafeChainPath) {
Write-Warn "safe-chain command not found. Proceeding with uninstallation."
return
}
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..."
}
}
# 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..."
$DotSafeChain = Get-SafeChainInstallDir
$safeChainPath = Find-SafeChainBinary -DotSafeChain $DotSafeChain
Invoke-SafeChainTeardown -SafeChainPath $safeChainPath
# Remove npm and Volta installations
Remove-NpmInstallation
Remove-VoltaInstallation
# Remove .safe-chain directory
if (Test-Path $DotSafeChain) {
Write-Info "Removing installation directory: $DotSafeChain"
try {
Remove-Item -Path $DotSafeChain -Recurse -Force
Write-Info "Successfully removed installation directory"
}
catch {
Write-Error-Custom "Failed to remove $DotSafeChain : $_"
}
}
else {
Write-Info "Installation directory $DotSafeChain does not exist. Nothing to remove."
}
Write-Info "safe-chain has been uninstalled successfully!"
}
# Run uninstallation
try {
Uninstall-SafeChain
}
catch {
Write-Error-Custom "Uninstallation failed: $_"
}

View file

@ -1,312 +0,0 @@
#!/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
# 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
}
# Resolves a path to its canonical filesystem location when possible.
# Follows symlinks so binary validation can inspect the real installed path.
resolve_path() {
target="$1"
while [ -L "$target" ]; do
link_target=$(readlink "$target" 2>/dev/null || echo "")
if [ -z "$link_target" ]; then
break
fi
case "$link_target" in
/*) target="$link_target" ;;
*)
target="$(dirname "$target")/$link_target"
;;
esac
done
target_dir=$(dirname "$target")
target_name=$(basename "$target")
if cd "$target_dir" 2>/dev/null; then
printf '%s/%s\n' "$(pwd -P)" "$target_name"
else
printf '%s\n' "$target"
fi
}
# Derives the safe-chain base install directory from a packaged binary path.
# Rejects wrapper scripts and paths that do not match the expected bin layout.
derive_install_dir_from_binary() {
binary_path="$1"
if [ -z "$binary_path" ]; then
return 1
fi
resolved_path=$(resolve_path "$binary_path")
binary_name=$(basename "$resolved_path")
case "$binary_name" in
safe-chain|safe-chain.exe) ;;
*) return 1 ;;
esac
case "$resolved_path" in
*.js|*.cjs|*.mjs|*.cmd|*.ps1) return 1 ;;
esac
binary_dir=$(dirname "$resolved_path")
if [ "$(basename "$binary_dir")" != "bin" ]; then
return 1
fi
dirname "$binary_dir"
}
# Determines the installed safe-chain base directory for uninstall.
# Prefers the binary-reported location, then infers it from PATH, then falls back to ~/.safe-chain.
get_install_dir() {
reported_install_dir=$(get_reported_install_dir || true)
if [ -n "$reported_install_dir" ]; then
printf '%s\n' "$reported_install_dir"
return 0
fi
command_path=$(get_safe_chain_command_path || true)
install_dir=$(derive_install_dir_from_binary "$command_path" || true)
if [ -n "$install_dir" ]; then
printf '%s\n' "$install_dir"
return 0
fi
printf '%s\n' "${HOME}/.safe-chain"
}
# Returns the current safe-chain command path from PATH.
# Fails when safe-chain is not currently resolvable.
get_safe_chain_command_path() {
if ! command_exists safe-chain; then
return 1
fi
command -v safe-chain
}
# Returns the safe-chain command path only when it resolves to a valid packaged binary install.
# Prevents the uninstaller from invoking arbitrary PATH entries.
get_validated_safe_chain_command_path() {
command_path=$(get_safe_chain_command_path || true)
if [ -z "$command_path" ]; then
return 1
fi
install_dir=$(derive_install_dir_from_binary "$command_path" || true)
if [ -z "$install_dir" ]; then
return 1
fi
printf '%s\n' "$command_path"
}
# Asks the validated safe-chain binary for its install directory via get-install-dir.
# Returns nothing if the command is unavailable or the lookup fails.
get_reported_install_dir() {
safe_chain_path=$(get_validated_safe_chain_command_path || true)
if [ -z "$safe_chain_path" ]; then
return 1
fi
install_dir=$("$safe_chain_path" get-install-dir 2>/dev/null || true)
if [ -n "$install_dir" ]; then
printf '%s\n' "$install_dir"
return 0
fi
return 1
}
# Locates the installed safe-chain binary to use for teardown.
# Checks the discovered install dir first, then falls back to a validated PATH entry.
find_installed_safe_chain_binary() {
dot_safe_chain="$1"
safe_chain_location="$dot_safe_chain/bin/safe-chain"
if [ -x "$safe_chain_location" ]; then
printf '%s\n' "$safe_chain_location"
return 0
fi
command_path=$(get_validated_safe_chain_command_path || true)
if [ -n "$command_path" ]; then
printf '%s\n' "$command_path"
return 0
fi
return 1
}
# Runs safe-chain teardown before removing files.
# Continues with uninstall even if teardown is unavailable or fails.
run_safe_chain_teardown() {
safe_chain_command="$1"
if [ -z "$safe_chain_command" ]; then
warn "safe-chain command not found. Proceeding with uninstallation."
return
fi
info "Running safe-chain teardown..."
"$safe_chain_command" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..."
}
# 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
}
# Check and uninstall nvm-managed package if present across all Node versions
remove_nvm_installation() {
# This script is run in sh shell for greatest compatibility.
# Because nvm is usually setup in bash/zsh/fish startup scripts, we need to source it.
# Otherwise it won't be available in sh.
if [ -s "$HOME/.nvm/nvm.sh" ]; then
# Source nvm to make it available in this script
. "$HOME/.nvm/nvm.sh" >/dev/null 2>&1
elif [ -s "$NVM_DIR/nvm.sh" ]; then
. "$NVM_DIR/nvm.sh" >/dev/null 2>&1
fi
# Check if nvm is now available
if ! command_exists nvm; then
return
fi
# Get list of installed Node versions
nvm_versions=$(nvm list 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || echo "")
if [ -z "$nvm_versions" ]; then
return
fi
# Track if we found any installations
found_installation=false
uninstall_failed=false
current_version=$(nvm current 2>/dev/null || echo "")
# Check each version for safe-chain installation
for version in $nvm_versions; do
# Check if this version has safe-chain installed
# Use nvm exec to run npm list in the context of that Node version
if nvm exec "$version" npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then
if [ "$found_installation" = false ]; then
info "Detected nvm installation(s) of @aikidosec/safe-chain"
info "Uninstalling from all Node versions..."
found_installation=true
fi
info " Removing from Node $version..."
if nvm exec "$version" npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then
info " Successfully uninstalled from Node $version"
else
warn " Failed to uninstall from Node $version"
uninstall_failed=true
fi
fi
done
# Restore original Node version if it was set
if [ -n "$current_version" ] && [ "$current_version" != "none" ] && [ "$current_version" != "system" ]; then
nvm use "$current_version" >/dev/null 2>&1 || true
fi
# Show warning if any uninstall failed (but don't error out during uninstall)
if [ "$uninstall_failed" = true ]; then
warn "Failed to uninstall @aikidosec/safe-chain from some nvm Node versions"
warn "You may need to manually run: nvm exec <version> npm uninstall -g @aikidosec/safe-chain"
fi
}
# Main uninstallation
main() {
DOT_SAFE_CHAIN=$(get_install_dir)
SAFE_CHAIN_COMMAND=$(find_installed_safe_chain_binary "$DOT_SAFE_CHAIN" || true)
run_safe_chain_teardown "$SAFE_CHAIN_COMMAND"
# Check for existing safe-chain installation through nvm, volta, or npm
remove_npm_installation
remove_volta_installation
remove_nvm_installation
# Remove install dir recursively if it exists
if [ -d "$DOT_SAFE_CHAIN" ]; then
info "Removing installation directory $DOT_SAFE_CHAIN"
rm -rf "$DOT_SAFE_CHAIN" || error "Failed to remove $DOT_SAFE_CHAIN"
else
info "Installation directory $DOT_SAFE_CHAIN does not exist. Nothing to remove."
fi
}
main "$@"

3183
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load diff

2168
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@
"test/e2e" "test/e2e"
], ],
"scripts": { "scripts": {
"test": "npm run test --workspace=packages/safe-chain", "test": "npm run test --workspace=packages/safe-chain --workspace=packages/safe-chain-bun",
"test:e2e": "npm run test --workspace=test/e2e", "test:e2e": "npm run test --workspace=test/e2e",
"lint": "npm run lint --workspace=packages/safe-chain", "lint": "npm run lint --workspace=packages/safe-chain",
"typecheck": "npm run typecheck --workspace=packages/safe-chain" "typecheck": "npm run typecheck --workspace=packages/safe-chain"
@ -19,8 +19,6 @@
"author": "Aikido Security", "author": "Aikido Security",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"devDependencies": { "devDependencies": {
"oxlint": "^1.22.0", "oxlint": "^1.22.0"
"esbuild": "^0.27.0",
"@yao-pkg/pkg": "6.10.1"
} }
} }

View file

@ -0,0 +1,30 @@
{
"name": "@aikidosec/safe-chain-bun",
"version": "1.0.0",
"type": "module",
"main": "src/index.js",
"scripts": {
"test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'"
},
"exports": {
".": {
"bun": "./src/index.js",
"default": "./src/index.js"
}
},
"keywords": ["bun", "security", "scanner", "malware", "aikido"],
"author": "Aikido Security",
"license": "AGPL-3.0-or-later",
"description": "Aikido Security Scanner for Bun package manager - detects malware and security threats during package installation",
"repository": {
"type": "git",
"url": "git+https://github.com/AikidoSec/safe-chain.git",
"directory": "packages/safe-chain-bun"
},
"dependencies": {
"@aikidosec/safe-chain": "file:../safe-chain"
},
"peerDependencies": {
"bun": ">=1.2.21"
}
}

View file

@ -0,0 +1,38 @@
// oxlint-disable no-console
import { auditChanges } from "@aikidosec/safe-chain/scanning";
// Bun Security Scanner for Safe-Chain
// This is the entry point for Bun's native security scanner integration
export const scanner = {
version: "1", // Our scanner is using version 1 of the bun security scanner API.
async scan({ packages }) {
const advisories = [];
try {
const changes = packages.map((pkg) => ({
name: pkg.name,
version: pkg.version,
type: "add",
}));
const audit = await auditChanges(changes);
if (!audit.isAllowed) {
for (const change of audit.disallowedChanges) {
advisories.push({
level: "fatal", // Fatal will block the installation process, this is what we want for packages that contain malware.
package: change.name,
url: null,
description: `Package ${change.name}@${change.version} contains known security threats (${change.reason}). Installation blocked by Safe-Chain.`,
});
}
}
} catch (/** @type any */ error) {
console.warn(`Safe-Chain security scan failed: ${error.message}`);
}
return advisories;
},
};

View file

@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import { describe, it, mock } from "node:test";
describe("Bun Scanner", async () => {
const mockAuditChanges = mock.fn();
// Mock the scanning module
mock.module("@aikidosec/safe-chain/scanning", {
namedExports: {
auditChanges: mockAuditChanges,
},
});
const { scanner } = await import("./index.js");
it("should export scanner object with version", () => {
assert.strictEqual(scanner.version, "1");
assert.strictEqual(typeof scanner.scan, "function");
});
it("should return empty advisories for clean packages", async () => {
mockAuditChanges.mock.mockImplementation(() => ({
allowedChanges: [{ name: "express", version: "4.18.2", type: "add" }],
disallowedChanges: [],
isAllowed: true,
}));
const packages = [{ name: "express", version: "4.18.2" }];
const result = await scanner.scan({ packages });
assert.deepEqual(result, []);
assert.strictEqual(mockAuditChanges.mock.callCount(), 1);
assert.deepEqual(mockAuditChanges.mock.calls[0].arguments[0], [
{ name: "express", version: "4.18.2", type: "add" },
]);
});
it("should return fatal advisory for malware packages", async () => {
mockAuditChanges.mock.mockImplementation(() => ({
allowedChanges: [],
disallowedChanges: [
{
name: "malicious-pkg",
version: "1.0.0",
type: "add",
reason: "MALWARE",
},
],
isAllowed: false,
}));
const packages = [{ name: "malicious-pkg", version: "1.0.0" }];
const result = await scanner.scan({ packages });
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
level: "fatal",
package: "malicious-pkg",
url: null,
description: "Package malicious-pkg@1.0.0 contains known security threats (MALWARE). Installation blocked by Safe-Chain.",
});
});
it("should handle multiple packages with mixed results", async () => {
mockAuditChanges.mock.mockImplementation(() => ({
allowedChanges: [{ name: "express", version: "4.18.2", type: "add" }],
disallowedChanges: [
{
name: "malicious-pkg",
version: "1.0.0",
type: "add",
reason: "MALWARE",
},
{
name: "another-bad-pkg",
version: "2.1.0",
type: "add",
reason: "MALWARE",
},
],
isAllowed: false,
}));
const packages = [
{ name: "express", version: "4.18.2" },
{ name: "malicious-pkg", version: "1.0.0" },
{ name: "another-bad-pkg", version: "2.1.0" },
];
const result = await scanner.scan({ packages });
assert.strictEqual(result.length, 2);
assert.strictEqual(result[0].package, "malicious-pkg");
assert.strictEqual(result[0].level, "fatal");
assert.strictEqual(result[1].package, "another-bad-pkg");
assert.strictEqual(result[1].level, "fatal");
});
it("should handle empty package list", async () => {
mockAuditChanges.mock.mockImplementation(() => ({
allowedChanges: [],
disallowedChanges: [],
isAllowed: true,
}));
const result = await scanner.scan({ packages: [] });
assert.deepEqual(result, []);
assert.deepEqual(
mockAuditChanges.mock.calls[mockAuditChanges.mock.callCount() - 1]
.arguments[0],
[]
);
});
it("should convert Bun package format to safe-chain format correctly", async () => {
mockAuditChanges.mock.mockImplementation(() => ({
allowedChanges: [],
disallowedChanges: [],
isAllowed: true,
}));
const bunPackages = [
{ name: "lodash", version: "4.17.21" },
{ name: "@types/node", version: "20.0.0" },
];
await scanner.scan({ packages: bunPackages });
const expectedChanges = [
{ name: "lodash", version: "4.17.21", type: "add" },
{ name: "@types/node", version: "20.0.0", type: "add" },
];
assert.deepEqual(
mockAuditChanges.mock.calls[mockAuditChanges.mock.callCount() - 1]
.arguments[0],
expectedChanges
);
});
});

View file

@ -7,8 +7,6 @@ import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
setEcoSystem(ECOSYSTEM_JS); setEcoSystem(ECOSYSTEM_JS);
const packageManagerName = "bun"; const packageManagerName = "bun";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
(async () => { process.exit(exitCode);
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -7,8 +7,6 @@ import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
setEcoSystem(ECOSYSTEM_JS); setEcoSystem(ECOSYSTEM_JS);
const packageManagerName = "bunx"; const packageManagerName = "bunx";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
(async () => { process.exit(exitCode);
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -7,8 +7,6 @@ import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
setEcoSystem(ECOSYSTEM_JS); setEcoSystem(ECOSYSTEM_JS);
const packageManagerName = "npm"; const packageManagerName = "npm";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
(async () => { process.exit(exitCode);
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -7,8 +7,6 @@ import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
setEcoSystem(ECOSYSTEM_JS); setEcoSystem(ECOSYSTEM_JS);
const packageManagerName = "npx"; const packageManagerName = "npx";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
(async () => { process.exit(exitCode);
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

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

View file

@ -3,15 +3,16 @@
import { main } from "../src/main.js"; import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
import { PIP_PACKAGE_MANAGER, PIP_COMMAND } from "../src/packagemanager/pip/pipSettings.js"; import { setCurrentPipInvocation, PIP_INVOCATIONS, PIP_PACKAGE_MANAGER } from "../src/packagemanager/pip/pipSettings.js";
// Set eco system // Set eco system
setEcoSystem(ECOSYSTEM_PY); setEcoSystem(ECOSYSTEM_PY);
initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PIP_COMMAND, args: process.argv.slice(2) }); // Set current invocation
setCurrentPipInvocation(PIP_INVOCATIONS.PIP);
(async () => { initializePackageManager(PIP_PACKAGE_MANAGER);
// Pass through only user-supplied pip args
var exitCode = await main(process.argv.slice(2)); // Pass through only user-supplied pip args
process.exit(exitCode); var exitCode = await main(process.argv.slice(2));
})(); process.exit(exitCode);

View file

@ -3,15 +3,17 @@
import { main } from "../src/main.js"; import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
import { PIP_PACKAGE_MANAGER, PIP3_COMMAND } from "../src/packagemanager/pip/pipSettings.js"; import { setCurrentPipInvocation, PIP_INVOCATIONS, PIP_PACKAGE_MANAGER } from "../src/packagemanager/pip/pipSettings.js";
// Set eco system // Set eco system
setEcoSystem(ECOSYSTEM_PY); setEcoSystem(ECOSYSTEM_PY);
initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PIP3_COMMAND, args: process.argv.slice(2) }); // Set current invocation
setCurrentPipInvocation(PIP_INVOCATIONS.PIP3);
(async () => { // Create package manager
// Pass through only user-supplied pip args initializePackageManager(PIP_PACKAGE_MANAGER);
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode); // Pass through only user-supplied pip args
})(); var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);

View file

@ -1,16 +0,0 @@
#!/usr/bin/env node
import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
// Set eco system
setEcoSystem(ECOSYSTEM_PY);
initializePackageManager("pipx");
(async () => {
// Pass through only user-supplied pipx args
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -7,8 +7,6 @@ import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
setEcoSystem(ECOSYSTEM_JS); setEcoSystem(ECOSYSTEM_JS);
const packageManagerName = "pnpm"; const packageManagerName = "pnpm";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
(async () => { process.exit(exitCode);
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -7,8 +7,6 @@ import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
setEcoSystem(ECOSYSTEM_JS); setEcoSystem(ECOSYSTEM_JS);
const packageManagerName = "pnpx"; const packageManagerName = "pnpx";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
(async () => { process.exit(exitCode);
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

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

View file

@ -1,7 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { PIP_PACKAGE_MANAGER, PYTHON_COMMAND } from "../src/packagemanager/pip/pipSettings.js"; import { setCurrentPipInvocation, PIP_INVOCATIONS, PIP_PACKAGE_MANAGER } from "../src/packagemanager/pip/pipSettings.js";
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
import { main } from "../src/main.js"; import { main } from "../src/main.js";
@ -11,9 +11,18 @@ setEcoSystem(ECOSYSTEM_PY);
// Strip nodejs and wrapper script from args // Strip nodejs and wrapper script from args
let argv = process.argv.slice(2); let argv = process.argv.slice(2);
initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PYTHON_COMMAND, args: argv }); if (argv[0] === '-m' && (argv[1] === 'pip' || argv[1] === 'pip3')) {
setEcoSystem(ECOSYSTEM_PY);
setCurrentPipInvocation(argv[1] === 'pip3' ? PIP_INVOCATIONS.PY_PIP3 : PIP_INVOCATIONS.PY_PIP);
initializePackageManager(PIP_PACKAGE_MANAGER);
// Strip off the '-m pip' or '-m pip3' from the args
argv = argv.slice(2);
(async () => {
var exitCode = await main(argv); var exitCode = await main(argv);
process.exit(exitCode); process.exit(exitCode);
})(); } else {
// Forward to real python binary for non-pip flows
const { spawn } = await import('child_process');
spawn('python', argv, { stdio: 'inherit' });
}

View file

@ -1,7 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { PIP_PACKAGE_MANAGER, PYTHON3_COMMAND } from "../src/packagemanager/pip/pipSettings.js"; import { setCurrentPipInvocation, PIP_INVOCATIONS, PIP_PACKAGE_MANAGER } from "../src/packagemanager/pip/pipSettings.js";
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
import { main } from "../src/main.js"; import { main } from "../src/main.js";
@ -11,9 +11,18 @@ setEcoSystem(ECOSYSTEM_PY);
// Strip nodejs and wrapper script from args // Strip nodejs and wrapper script from args
let argv = process.argv.slice(2); let argv = process.argv.slice(2);
initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PYTHON3_COMMAND, args: argv }); if (argv[0] === '-m' && (argv[1] === 'pip' || argv[1] === 'pip3')) {
setEcoSystem(ECOSYSTEM_PY);
setCurrentPipInvocation(argv[1] === 'pip3' ? PIP_INVOCATIONS.PY3_PIP3 : PIP_INVOCATIONS.PY3_PIP);
initializePackageManager(PIP_PACKAGE_MANAGER);
// Strip off the '-m pip' or '-m pip3' from the args
argv = argv.slice(2);
(async () => {
var exitCode = await main(argv); var exitCode = await main(argv);
process.exit(exitCode); process.exit(exitCode);
})(); } else {
// Forward to real python3 binary for non-pip flows
const { spawn } = await import('child_process');
spawn('python3', argv, { stdio: 'inherit' });
}

View file

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

View file

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

View file

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

View file

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

View file

@ -7,8 +7,6 @@ import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
setEcoSystem(ECOSYSTEM_JS); setEcoSystem(ECOSYSTEM_JS);
const packageManagerName = "yarn"; const packageManagerName = "yarn";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
(async () => { process.exit(exitCode);
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -1,42 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
// Strip PKG_EXECPATH from the environment so any child process safe-chain
// spawns (npm, uv, pip, …) doesn't inherit it. If it leaks into a subsequent
// safe-chain invocation (e.g. via a shim) the yao-pkg bootstrap would treat
// argv[1] as a script path and fail with MODULE_NOT_FOUND.
delete process.env.PKG_EXECPATH;
import chalk from "chalk"; import chalk from "chalk";
import { createRequire } from "module";
import { ui } from "../src/environment/userInteraction.js"; import { ui } from "../src/environment/userInteraction.js";
import { setup } from "../src/shell-integration/setup.js"; import { setup } from "../src/shell-integration/setup.js";
import { import { teardown } from "../src/shell-integration/teardown.js";
teardown,
teardownDirectories,
} from "../src/shell-integration/teardown.js";
import { setupCi } from "../src/shell-integration/setup-ci.js"; import { setupCi } from "../src/shell-integration/setup-ci.js";
import { initializeCliArguments } from "../src/config/cliArguments.js"; import { initializeCliArguments } from "../src/config/cliArguments.js";
import { setEcoSystem } from "../src/config/settings.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { main } from "../src/main.js";
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs";
import { knownAikidoTools, getPackageManagerList } from "../src/shell-integration/helpers.js";
import { getInstalledSafeChainDir } from "../src/installLocation.js";
/** @type {string} */
// This checks the current file's dirname in a way that's compatible with:
// - Modulejs (import.meta.url)
// - ES modules (__dirname)
// This is needed because safe-chain's npm package is built using ES modules,
// but building the binaries requires commonjs.
let dirname;
if (import.meta.url) {
const filename = fileURLToPath(import.meta.url);
dirname = path.dirname(filename);
} else {
dirname = __dirname;
}
if (process.argv.length < 3) { if (process.argv.length < 3) {
ui.writeError("No command provided. Please provide a command to execute."); ui.writeError("No command provided. Please provide a command to execute.");
@ -49,46 +19,19 @@ initializeCliArguments(process.argv);
const command = process.argv[2]; const command = process.argv[2];
const tool = knownAikidoTools.find((tool) => tool.tool === command); if (command === "help" || command === "--help" || command === "-h") {
if (tool) {
const args = process.argv.slice(3);
setEcoSystem(tool.ecoSystem);
// Provide tool context to PM (pip uses this; others ignore)
const toolContext = { tool: tool.tool, args };
initializePackageManager(tool.internalPackageManagerName, toolContext);
(async () => {
var exitCode = await main(args);
process.exit(exitCode);
})();
} else if (command === "help" || command === "--help" || command === "-h") {
writeHelp(); writeHelp();
process.exit(0); process.exit(0);
} else if (command === "setup") { }
if (command === "setup") {
setup(); setup();
} else if (command === "teardown") { } else if (command === "teardown") {
teardown(); teardown();
teardownDirectories();
} else if (command === "setup-ci") { } else if (command === "setup-ci") {
setupCi(); setupCi();
} else if (command === "get-install-dir") {
const installDir = getInstalledSafeChainDir();
if (!installDir) {
ui.writeError(
"Install directory is only available for packaged safe-chain binaries.",
);
process.exit(1);
}
ui.writeInformation(installDir);
process.exit(0);
} else if (command === "--version" || command === "-v" || command === "-v") { } else if (command === "--version" || command === "-v" || command === "-v") {
(async () => { ui.writeInformation(`Current safe-chain version: ${getVersion()}`);
ui.writeInformation(`Current safe-chain version: ${await getVersion()}`);
})();
} else { } else {
ui.writeError(`Unknown command: ${command}.`); ui.writeError(`Unknown command: ${command}.`);
ui.emptyLine(); ui.emptyLine();
@ -100,54 +43,52 @@ if (tool) {
function writeHelp() { function writeHelp() {
ui.writeInformation( ui.writeInformation(
chalk.bold("Usage: ") + chalk.cyan("safe-chain <command>"), chalk.bold("Usage: ") + chalk.cyan("safe-chain <command>")
); );
ui.emptyLine(); ui.emptyLine();
ui.writeInformation( ui.writeInformation(
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan( `Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
"teardown", "teardown"
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("get-install-dir")}, ${chalk.cyan("help")}, ${chalk.cyan( )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan(
"--version", "--version"
)}`, )}`
); );
ui.emptyLine(); ui.emptyLine();
ui.writeInformation( ui.writeInformation(
`- ${chalk.cyan( `- ${chalk.cyan(
"safe-chain setup", "safe-chain setup"
)}: This will setup your shell to wrap safe-chain around ${getPackageManagerList()}.`, )}: 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( ui.writeInformation(
`- ${chalk.cyan( `- ${chalk.cyan(
"safe-chain teardown", "safe-chain teardown"
)}: This will remove safe-chain aliases from your shell configuration.`, )}: This will remove safe-chain aliases from your shell configuration.`
); );
ui.writeInformation( ui.writeInformation(
`- ${chalk.cyan( `- ${chalk.cyan(
"safe-chain setup-ci", "safe-chain setup-ci"
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`, )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`
); );
ui.writeInformation( ui.writeInformation(
`- ${chalk.cyan( ` ${chalk.yellow(
"safe-chain get-install-dir", "--include-python"
)}: Print the install directory for packaged safe-chain binaries.`, )}: Experimental: include Python package managers (pip, pip3) in the setup.`
); );
ui.writeInformation( ui.writeInformation(
`- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan( `- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan(
"-v", "-v"
)}): Display the current version of safe-chain.`, )}): Display the current version of safe-chain.`
); );
ui.emptyLine(); ui.emptyLine();
} }
async function getVersion() { function getVersion() {
const packageJsonPath = path.join(dirname, "..", "package.json"); const require = createRequire(import.meta.url);
const packageJson = require("../package.json");
const data = await fs.promises.readFile(packageJsonPath); return packageJson.version;
const json = JSON.parse(data.toString("utf8"));
if (json && json.version) {
return json.version;
}
return "0.0.0";
} }

View file

@ -13,19 +13,12 @@
"aikido-yarn": "bin/aikido-yarn.js", "aikido-yarn": "bin/aikido-yarn.js",
"aikido-pnpm": "bin/aikido-pnpm.js", "aikido-pnpm": "bin/aikido-pnpm.js",
"aikido-pnpx": "bin/aikido-pnpx.js", "aikido-pnpx": "bin/aikido-pnpx.js",
"aikido-rush": "bin/aikido-rush.js",
"aikido-rushx": "bin/aikido-rushx.js",
"aikido-bun": "bin/aikido-bun.js", "aikido-bun": "bin/aikido-bun.js",
"aikido-bunx": "bin/aikido-bunx.js", "aikido-bunx": "bin/aikido-bunx.js",
"aikido-uv": "bin/aikido-uv.js",
"aikido-uvx": "bin/aikido-uvx.js",
"aikido-pip": "bin/aikido-pip.js", "aikido-pip": "bin/aikido-pip.js",
"aikido-pip3": "bin/aikido-pip3.js", "aikido-pip3": "bin/aikido-pip3.js",
"aikido-python": "bin/aikido-python.js", "aikido-python": "bin/aikido-python.js",
"aikido-python3": "bin/aikido-python3.js", "aikido-python3": "bin/aikido-python3.js",
"aikido-poetry": "bin/aikido-poetry.js",
"aikido-pipx": "bin/aikido-pipx.js",
"aikido-pdm": "bin/aikido-pdm.js",
"safe-chain": "bin/safe-chain.js" "safe-chain": "bin/safe-chain.js"
}, },
"type": "module", "type": "module",
@ -40,25 +33,25 @@
"keywords": [], "keywords": [],
"author": "Aikido Security", "author": "Aikido Security",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [rush](https://rushjs.io/), [rushx](https://rushjs.io/pages/commands/rushx/), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), [pip](https://pip.pypa.io/), and [pdm](https://pdm-project.org/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, uv, uvx, pip/pip3, or pdm from downloading or running the malware.", "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), and [bunx](https://bun.sh/docs/cli/bunx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, or bunx from downloading or running the malware.",
"dependencies": { "dependencies": {
"certifi": "14.5.15", "certifi": "^14.5.15",
"chalk": "5.4.1", "chalk": "5.4.1",
"https-proxy-agent": "7.0.6", "https-proxy-agent": "7.0.6",
"ini": "6.0.0", "ini": "^6.0.0",
"make-fetch-happen": "15.0.3", "make-fetch-happen": "14.0.3",
"node-forge": "1.3.2", "node-forge": "1.3.1",
"npm-registry-fetch": "19.1.1", "npm-registry-fetch": "18.0.2",
"ora": "8.2.0",
"semver": "7.7.2" "semver": "7.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/ini": "^4.1.1", "@types/ini": "^4.1.1",
"@types/make-fetch-happen": "^10.0.4", "@types/make-fetch-happen": "^10.0.4",
"@types/node": "^18.19.130", "@types/node": "^18.19.130",
"@types/node-forge": "^1.3.14",
"@types/npm-registry-fetch": "^8.0.9", "@types/npm-registry-fetch": "^8.0.9",
"@types/semver": "^7.7.1", "@types/semver": "^7.7.1",
"esbuild": "^0.27.0", "@types/node-forge": "^1.3.14",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"main": "src/main.js", "main": "src/main.js",

View file

@ -1,24 +1,11 @@
import fetch from "make-fetch-happen"; import fetch from "make-fetch-happen";
import { import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
getEcoSystem,
ECOSYSTEM_JS,
ECOSYSTEM_PY,
getMalwareListBaseUrl,
} from "../config/settings.js";
import { ui } from "../environment/userInteraction.js";
const malwareDatabasePaths = { const malwareDatabaseUrls = {
[ECOSYSTEM_JS]: "malware_predictions.json", [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json",
[ECOSYSTEM_PY]: "malware_pypi.json", [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json",
}; };
const newPackagesListPaths = {
[ECOSYSTEM_JS]: "releases/npm.json",
[ECOSYSTEM_PY]: "releases/pypi.json",
};
const DEFAULT_FETCH_RETRY_ATTEMPTS = 4;
/** /**
* @typedef {Object} MalwarePackage * @typedef {Object} MalwarePackage
* @property {string} package_name * @property {string} package_name
@ -26,162 +13,42 @@ const DEFAULT_FETCH_RETRY_ATTEMPTS = 4;
* @property {string} reason * @property {string} reason
*/ */
/**
* @typedef {Object} NewPackageEntry
* @property {string} [source]
* @property {string} package_name
* @property {string} version
* @property {number} released_on - Unix timestamp (seconds)
* @property {number} scraped_on - Unix timestamp (seconds)
*/
/** /**
* @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>} * @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>}
*/ */
export async function fetchMalwareDatabase() { export async function fetchMalwareDatabase() {
return retry(async () => { const ecosystem = getEcoSystem();
const ecosystem = getEcoSystem(); const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)];
const baseUrl = getMalwareListBaseUrl(); const response = await fetch(malwareDatabaseUrl);
const path = malwareDatabasePaths[ if (!response.ok) {
/** @type {keyof typeof malwareDatabasePaths} */ (ecosystem) throw new Error(`Error fetching ${ecosystem} malware database: ${response.statusText}`);
]; }
const malwareDatabaseUrl = `${baseUrl}/${path}`;
const response = await fetch(malwareDatabaseUrl);
if (!response.ok) {
throw new Error(
`Error fetching ${ecosystem} malware database: ${response.statusText}`
);
}
try { try {
let malwareDatabase = await response.json(); let malwareDatabase = await response.json();
return { return {
malwareDatabase: malwareDatabase, malwareDatabase: malwareDatabase,
version: response.headers.get("etag") || undefined, version: response.headers.get("etag") || undefined,
}; };
} catch (/** @type {any} */ error) { } catch (/** @type {any} */ error) {
throw new Error(`Error parsing malware database: ${error.message}`); throw new Error(`Error parsing malware database: ${error.message}`);
} }
}, DEFAULT_FETCH_RETRY_ATTEMPTS);
} }
/** /**
* @returns {Promise<string | undefined>} * @returns {Promise<string | undefined>}
*/ */
export async function fetchMalwareDatabaseVersion() { export async function fetchMalwareDatabaseVersion() {
return retry(async () => { const ecosystem = getEcoSystem();
const ecosystem = getEcoSystem(); const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)];
const baseUrl = getMalwareListBaseUrl(); const response = await fetch(malwareDatabaseUrl, {
const path = malwareDatabasePaths[ method: "HEAD",
/** @type {keyof typeof malwareDatabasePaths} */ (ecosystem) });
];
const malwareDatabaseUrl = `${baseUrl}/${path}`;
const response = await fetch(malwareDatabaseUrl, {
method: "HEAD",
});
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`Error fetching ${ecosystem} malware database version: ${response.statusText}` `Error fetching ${ecosystem} malware database version: ${response.statusText}`
); );
}
return response.headers.get("etag") || undefined;
}, DEFAULT_FETCH_RETRY_ATTEMPTS);
}
/**
* @returns {Promise<{newPackagesList: NewPackageEntry[], version: string | undefined}>}
*/
export async function fetchNewPackagesList() {
return retry(async () => {
const ecosystem = getEcoSystem();
const baseUrl = getMalwareListBaseUrl();
const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)];
if (!path) {
return { newPackagesList: [], version: undefined };
}
const url = `${baseUrl}/${path}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Error fetching ${ecosystem} new packages list: ${response.statusText}`
);
}
try {
const newPackagesList = await response.json();
return {
newPackagesList,
version: response.headers.get("etag") || undefined,
};
} catch (/** @type {any} */ error) {
throw new Error(`Error parsing new packages list: ${error.message}`);
}
}, DEFAULT_FETCH_RETRY_ATTEMPTS);
}
/**
* @returns {Promise<string | undefined>}
*/
export async function fetchNewPackagesListVersion() {
return retry(async () => {
const ecosystem = getEcoSystem();
const baseUrl = getMalwareListBaseUrl();
const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)];
if (!path) {
return undefined;
}
const url = `${baseUrl}/${path}`;
const response = await fetch(url, { method: "HEAD" });
if (!response.ok) {
throw new Error(
`Error fetching ${ecosystem} new packages list version: ${response.statusText}`
);
}
return response.headers.get("etag") || undefined;
}, DEFAULT_FETCH_RETRY_ATTEMPTS);
}
/**
* Retries an asynchronous function multiple times until it succeeds or exhausts all attempts.
*
* @template T
* @param {() => Promise<T>} func - The asynchronous function to retry
* @param {number} attempts - The number of attempts
* @returns {Promise<T>} The return value of the function if successful
* @throws {Error} The last error encountered if all retry attempts fail
*/
async function retry(func, attempts) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await func();
} catch (error) {
ui.writeVerbose(
"An error occurred while trying to download Aikido data",
error
);
lastError = error;
}
if (i < attempts - 1) {
// When this is not the last try, back-off exponentially:
// 1st attempt - 500ms delay
// 2nd attempt - 1000ms delay
// 3rd attempt - 2000ms delay
// 4th attempt - 4000ms delay
// ...
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 500));
}
} }
return response.headers.get("etag") || undefined;
throw lastError;
} }

View file

@ -1,231 +0,0 @@
import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert";
describe("aikido API", async () => {
const mockFetch = mock.fn();
let ecosystem = "js";
mock.module("make-fetch-happen", {
defaultExport: mockFetch,
});
mock.module("../environment/userInteraction.js", {
namedExports: {
ui: {
writeVerbose: () => {},
},
},
});
mock.module("../config/settings.js", {
namedExports: {
getEcoSystem: () => ecosystem,
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
},
});
const {
fetchMalwareDatabase,
fetchMalwareDatabaseVersion,
fetchNewPackagesList,
fetchNewPackagesListVersion,
} = await import("./aikido.js");
beforeEach(() => {
mockFetch.mock.resetCalls();
ecosystem = "js";
});
describe("fetchMalwareDatabase", () => {
it("should succeed immediately when fetch succeeds on first try", async () => {
const malwareData = [
{ package_name: "malicious-pkg", version: "1.0.0", reason: "test" },
];
mockFetch.mock.mockImplementationOnce(() => ({
ok: true,
json: async () => malwareData,
headers: { get: () => '"etag-123"' },
}));
const result = await fetchMalwareDatabase();
assert.strictEqual(mockFetch.mock.calls.length, 1);
assert.deepStrictEqual(result.malwareDatabase, malwareData);
assert.strictEqual(result.version, '"etag-123"');
});
it("should throw error after exhausting all retries", async () => {
mockFetch.mock.mockImplementation(() => {
throw new Error("Network error");
});
await assert.rejects(() => fetchMalwareDatabase(), {
message: "Network error",
});
assert.strictEqual(mockFetch.mock.calls.length, 4);
});
it("should succeed after failing 3 times and succeeding on 4th attempt", async () => {
const malwareData = [
{ package_name: "bad-pkg", version: "2.0.0", reason: "malware" },
];
let callCount = 0;
mockFetch.mock.mockImplementation(() => {
callCount++;
if (callCount < 4) {
throw new Error("Network error");
}
return {
ok: true,
json: async () => malwareData,
headers: { get: () => '"etag-456"' },
};
});
const result = await fetchMalwareDatabase();
assert.strictEqual(mockFetch.mock.calls.length, 4);
assert.deepStrictEqual(result.malwareDatabase, malwareData);
assert.strictEqual(result.version, '"etag-456"');
});
});
describe("fetchMalwareDatabaseVersion", () => {
it("should succeed immediately when fetch succeeds on first try", async () => {
mockFetch.mock.mockImplementationOnce(() => ({
ok: true,
headers: { get: () => '"version-etag"' },
}));
const result = await fetchMalwareDatabaseVersion();
assert.strictEqual(mockFetch.mock.calls.length, 1);
assert.strictEqual(result, '"version-etag"');
});
it("should throw error after exhausting all retries", async () => {
mockFetch.mock.mockImplementation(() => {
throw new Error("Connection refused");
});
await assert.rejects(() => fetchMalwareDatabaseVersion(), {
message: "Connection refused",
});
assert.strictEqual(mockFetch.mock.calls.length, 4);
});
it("should succeed after failing 3 times and succeeding on 4th attempt", async () => {
let callCount = 0;
mockFetch.mock.mockImplementation(() => {
callCount++;
if (callCount < 4) {
throw new Error("Timeout");
}
return {
ok: true,
headers: { get: () => '"final-etag"' },
};
});
const result = await fetchMalwareDatabaseVersion();
assert.strictEqual(mockFetch.mock.calls.length, 4);
assert.strictEqual(result, '"final-etag"');
});
});
describe("fetchNewPackagesList", () => {
it("should succeed immediately when fetch succeeds on first try", async () => {
const releases = [
{
package_name: "fresh-pkg",
version: "1.0.0",
released_on: 123,
},
];
mockFetch.mock.mockImplementationOnce(() => ({
ok: true,
json: async () => releases,
headers: { get: () => '"etag-new-packages"' },
}));
const result = await fetchNewPackagesList();
assert.strictEqual(mockFetch.mock.calls.length, 1);
assert.strictEqual(
mockFetch.mock.calls[0].arguments[0],
"https://malware-list.aikido.dev/releases/npm.json"
);
assert.deepStrictEqual(result.newPackagesList, releases);
assert.strictEqual(result.version, '"etag-new-packages"');
});
it("should throw error after exhausting all retries", async () => {
mockFetch.mock.mockImplementation(() => {
throw new Error("Network error");
});
await assert.rejects(() => fetchNewPackagesList(), {
message: "Network error",
});
assert.strictEqual(mockFetch.mock.calls.length, 4);
});
it("should return an empty list without fetching for unsupported ecosystems", async () => {
ecosystem = "ruby";
const result = await fetchNewPackagesList();
assert.strictEqual(mockFetch.mock.calls.length, 0);
assert.deepStrictEqual(result.newPackagesList, []);
assert.strictEqual(result.version, undefined);
});
it("should return undefined version without fetching for unsupported ecosystems", async () => {
ecosystem = "ruby";
const result = await fetchNewPackagesListVersion();
assert.strictEqual(mockFetch.mock.calls.length, 0);
assert.strictEqual(result, undefined);
});
});
describe("fetchNewPackagesListVersion", () => {
it("should succeed immediately when fetch succeeds on first try", async () => {
mockFetch.mock.mockImplementationOnce(() => ({
ok: true,
headers: { get: () => '"new-packages-etag"' },
}));
const result = await fetchNewPackagesListVersion();
assert.strictEqual(mockFetch.mock.calls.length, 1);
assert.strictEqual(
mockFetch.mock.calls[0].arguments[0],
"https://malware-list.aikido.dev/releases/npm.json"
);
assert.deepStrictEqual(mockFetch.mock.calls[0].arguments[1], {
method: "HEAD",
});
assert.strictEqual(result, '"new-packages-etag"');
});
it("should throw error after exhausting all retries", async () => {
mockFetch.mock.mockImplementation(() => {
throw new Error("Connection refused");
});
await assert.rejects(() => fetchNewPackagesListVersion(), {
message: "Connection refused",
});
assert.strictEqual(mockFetch.mock.calls.length, 4);
});
});
});

View file

@ -1,13 +1,10 @@
import { ui } from "../environment/userInteraction.js";
/** /**
* @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, malwareListBaseUrl: string | undefined}} * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, includePython: boolean}}
*/ */
const state = { const state = {
loggingLevel: undefined, loggingLevel: undefined,
skipMinimumPackageAge: undefined, skipMinimumPackageAge: undefined,
minimumPackageAgeHours: undefined, includePython: false,
malwareListBaseUrl: undefined,
}; };
const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-"; const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
@ -20,8 +17,6 @@ export function initializeCliArguments(args) {
// Reset state on each call // Reset state on each call
state.loggingLevel = undefined; state.loggingLevel = undefined;
state.skipMinimumPackageAge = undefined; state.skipMinimumPackageAge = undefined;
state.minimumPackageAgeHours = undefined;
state.malwareListBaseUrl = undefined;
const safeChainArgs = []; const safeChainArgs = [];
const remainingArgs = []; const remainingArgs = [];
@ -36,9 +31,8 @@ export function initializeCliArguments(args) {
setLoggingLevel(safeChainArgs); setLoggingLevel(safeChainArgs);
setSkipMinimumPackageAge(safeChainArgs); setSkipMinimumPackageAge(safeChainArgs);
setMinimumPackageAgeHours(safeChainArgs); setIncludePython(args);
setMalwareListBaseUrl(safeChainArgs);
checkDeprecatedPythonFlag(args);
return remainingArgs; return remainingArgs;
} }
@ -94,42 +88,16 @@ export function getSkipMinimumPackageAge() {
/** /**
* @param {string[]} args * @param {string[]} args
* @returns {void}
*/ */
function setMinimumPackageAgeHours(args) { function setIncludePython(args) {
const argName = SAFE_CHAIN_ARG_PREFIX + "minimum-package-age-hours="; // This flag doesn't have the --safe-chain- prefix because
// it is only used for the safe-chain command itself and
const value = getLastArgEqualsValue(args, argName); // not when wrapped around package manager commands.
if (value) { state.includePython = hasFlagArg(args, "--include-python");
state.minimumPackageAgeHours = value;
}
} }
/** export function includePython() {
* @returns {string | undefined} return state.includePython;
*/
export function getMinimumPackageAgeHours() {
return state.minimumPackageAgeHours;
}
/**
* @param {string[]} args
* @returns {void}
*/
function setMalwareListBaseUrl(args) {
const argName = SAFE_CHAIN_ARG_PREFIX + "malware-list-base-url=";
const value = getLastArgEqualsValue(args, argName);
if (value) {
state.malwareListBaseUrl = value;
}
}
/**
* @returns {string | undefined}
*/
export function getMalwareListBaseUrl() {
return state.malwareListBaseUrl;
} }
/** /**
@ -145,17 +113,3 @@ function hasFlagArg(args, flagName) {
} }
return false; return false;
} }
/**
* Emits a deprecation warning for legacy --include-python flag
*
* @param {string[]} args
* @returns {void}
*/
export function checkDeprecatedPythonFlag(args) {
if (hasFlagArg(args, "--include-python")) {
ui.writeWarning(
"--include-python is deprecated and ignored. Python tooling is included by default."
);
}
}

View file

@ -4,9 +4,7 @@ import {
initializeCliArguments, initializeCliArguments,
getLoggingLevel, getLoggingLevel,
getSkipMinimumPackageAge, getSkipMinimumPackageAge,
getMinimumPackageAgeHours,
} from "./cliArguments.js"; } from "./cliArguments.js";
import { ui } from "../environment/userInteraction.js";
describe("initializeCliArguments", () => { describe("initializeCliArguments", () => {
it("should return all args when no safe-chain args are present", () => { it("should return all args when no safe-chain args are present", () => {
@ -180,132 +178,4 @@ describe("initializeCliArguments", () => {
assert.deepEqual(result, ["install", "lodash"]); assert.deepEqual(result, ["install", "lodash"]);
assert.strictEqual(getSkipMinimumPackageAge(), true); assert.strictEqual(getSkipMinimumPackageAge(), true);
}); });
it("should return undefined when no minimum-package-age-hours argument is passed", () => {
const args = ["install", "express", "--save"];
initializeCliArguments(args);
assert.strictEqual(getMinimumPackageAgeHours(), undefined);
});
it("should parse minimum-package-age-hours value and set state", () => {
const args = [
"--safe-chain-minimum-package-age-hours=48",
"install",
"lodash",
];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "lodash"]);
assert.strictEqual(getMinimumPackageAgeHours(), "48");
});
it("should handle minimum-package-age-hours with zero value", () => {
const args = ["--safe-chain-minimum-package-age-hours=0", "install"];
initializeCliArguments(args);
assert.strictEqual(getMinimumPackageAgeHours(), "0");
});
it("should handle minimum-package-age-hours with decimal values", () => {
const args = ["--safe-chain-minimum-package-age-hours=1.5", "install"];
initializeCliArguments(args);
assert.strictEqual(getMinimumPackageAgeHours(), "1.5");
});
it("should handle minimum-package-age-hours case-insensitively", () => {
const args = ["--SAFE-CHAIN-MINIMUM-PACKAGE-AGE-HOURS=72", "install"];
initializeCliArguments(args);
assert.strictEqual(getMinimumPackageAgeHours(), "72");
});
it("should use the last minimum-package-age-hours argument when multiple are provided", () => {
const args = [
"--safe-chain-minimum-package-age-hours=12",
"--safe-chain-minimum-package-age-hours=36",
"install",
];
initializeCliArguments(args);
assert.strictEqual(getMinimumPackageAgeHours(), "36");
});
it("should filter out minimum-package-age-hours argument from returned args", () => {
const args = [
"install",
"--safe-chain-minimum-package-age-hours=48",
"express",
"--save",
];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "express", "--save"]);
});
it("should handle minimum-package-age-hours with other safe-chain arguments", () => {
const args = [
"--safe-chain-logging=verbose",
"--safe-chain-minimum-package-age-hours=96",
"install",
"lodash",
];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "lodash"]);
assert.strictEqual(getLoggingLevel(), "verbose");
assert.strictEqual(getMinimumPackageAgeHours(), "96");
});
it("should handle non-numeric values without validation (validation in settings.js)", () => {
const args = ["--safe-chain-minimum-package-age-hours=invalid", "install"];
initializeCliArguments(args);
// cliArguments.js just captures the value; validation is in settings.js
assert.strictEqual(getMinimumPackageAgeHours(), "invalid");
});
it("should handle negative values as strings (validation in settings.js)", () => {
const args = ["--safe-chain-minimum-package-age-hours=-24", "install"];
initializeCliArguments(args);
assert.strictEqual(getMinimumPackageAgeHours(), "-24");
});
it("should warn on deprecated --include-python for setup", () => {
const warnings = [];
const originalWriteWarning = ui.writeWarning;
ui.writeWarning = (msg, ..._rest) => {
warnings.push(String(msg));
};
try {
const argv = ["node", "safe-chain", "setup", "--include-python"];
initializeCliArguments(argv);
assert.ok(
warnings.some((m) => m.includes("--include-python is deprecated")),
"Expected a deprecation warning for --include-python in setup"
);
} finally {
ui.writeWarning = originalWriteWarning;
}
});
it("should warn on deprecated --include-python for setup-ci", () => {
const warnings = [];
const originalWriteWarning = ui.writeWarning;
ui.writeWarning = (msg, ..._rest) => {
warnings.push(String(msg));
};
try {
const argv = ["node", "safe-chain", "setup-ci", "--include-python"];
initializeCliArguments(argv);
assert.ok(
warnings.some((m) => m.includes("--include-python is deprecated")),
"Expected a deprecation warning for --include-python in setup-ci"
);
} finally {
ui.writeWarning = originalWriteWarning;
}
});
}); });

View file

@ -3,22 +3,13 @@ import path from "path";
import os from "os"; import os from "os";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import { getEcoSystem } from "./settings.js"; import { getEcoSystem } from "./settings.js";
import { getSafeChainBaseDir } from "./safeChainDir.js";
/** /**
* @typedef {Object} SafeChainConfig * @typedef {Object} SafeChainConfig
* *
* We cannot trust the input and should add the necessary validations * This should be a number, but can be anything because it is user-input.
* @property {unknown | Number} scanTimeout
* @property {unknown | Number} minimumPackageAgeHours
* @property {unknown | string} malwareListBaseUrl
* @property {unknown | SafeChainRegistryConfiguration} npm
* @property {unknown | SafeChainRegistryConfiguration} pip
*
* @typedef {Object} SafeChainRegistryConfiguration
* We cannot trust the input and should add the necessary validations. * We cannot trust the input and should add the necessary validations.
* @property {unknown | string[]} customRegistries * @property {any} scanTimeout
* @property {unknown | string[]} minimumPackageAgeExclusions
*/ */
/** /**
@ -57,115 +48,6 @@ function validateTimeout(value) {
return null; return null;
} }
/**
* @param {any} value
* @returns {number | undefined}
*/
function validateMinimumPackageAgeHours(value) {
const hours = Number(value);
if (!Number.isNaN(hours)) {
return hours;
}
return undefined;
}
/**
* Gets the minimum package age in hours from config file only
* @returns {number | undefined}
*/
export function getMinimumPackageAgeHours() {
const config = readConfigFile();
if (config.minimumPackageAgeHours !== undefined) {
const validated = validateMinimumPackageAgeHours(
config.minimumPackageAgeHours
);
if (validated !== undefined) {
return validated;
}
}
return undefined;
}
/**
* Gets the malware list base URL from config file only
* @returns {string | undefined}
*/
export function getMalwareListBaseUrl() {
const config = readConfigFile();
if (config.malwareListBaseUrl && typeof config.malwareListBaseUrl === "string") {
return config.malwareListBaseUrl;
}
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");
}
/**
* Gets the custom npm registries from the config file (format parsing only, no validation)
* @returns {string[]}
*/
export function getPipCustomRegistries() {
const config = readConfigFile();
if (!config || !config.pip) {
return [];
}
// TypeScript needs help understanding that config.pip exists and has customRegistries
const pipConfig = /** @type {SafeChainRegistryConfiguration} */ (config.pip);
const customRegistries = pipConfig.customRegistries;
if (!Array.isArray(customRegistries)) {
return [];
}
return customRegistries.filter((item) => typeof item === "string");
}
/**
* Gets the minimum package age exclusions from the config file for the current ecosystem
* @returns {string[]}
*/
export function getMinimumPackageAgeExclusions() {
const config = readConfigFile();
const ecosystem = getEcoSystem();
const registryConfig = ecosystem === "py" ? config.pip : config.npm;
if (!config || !registryConfig) {
return [];
}
const typedRegistryConfig =
/** @type {SafeChainRegistryConfiguration} */ (registryConfig);
const exclusions = typedRegistryConfig.minimumPackageAgeExclusions;
if (!Array.isArray(exclusions)) {
return [];
}
return exclusions.filter((item) => typeof item === "string");
}
/** /**
* @param {import("../api/aikido.js").MalwarePackage[]} data * @param {import("../api/aikido.js").MalwarePackage[]} data
* @param {string | number} version * @param {string | number} version
@ -224,30 +106,21 @@ export function readDatabaseFromLocalCache() {
* @returns {SafeChainConfig} * @returns {SafeChainConfig}
*/ */
function readConfigFile() { function readConfigFile() {
/** @type {SafeChainConfig} */
const emptyConfig = {
scanTimeout: undefined,
minimumPackageAgeHours: undefined,
malwareListBaseUrl: undefined,
npm: {
customRegistries: undefined,
},
pip: {
customRegistries: undefined,
},
};
const configFilePath = getConfigFilePath(); const configFilePath = getConfigFilePath();
if (!fs.existsSync(configFilePath)) { if (!fs.existsSync(configFilePath)) {
return emptyConfig; return {
scanTimeout: undefined,
};
} }
try { try {
const data = fs.readFileSync(configFilePath, "utf8"); const data = fs.readFileSync(configFilePath, "utf8");
return JSON.parse(data); return JSON.parse(data);
} catch { } catch {
return emptyConfig; return {
scanTimeout: undefined,
};
} }
} }
@ -266,51 +139,11 @@ function getDatabaseVersionPath() {
return path.join(aikidoDir, `version_${ecosystem}.txt`); return path.join(aikidoDir, `version_${ecosystem}.txt`);
} }
/**
* @returns {string}
*/
export function getNewPackagesListPath() {
const safeChainDir = getSafeChainDirectory();
const ecosystem = getEcoSystem();
return path.join(safeChainDir, `newPackagesList_${ecosystem}.json`);
}
/**
* @returns {string}
*/
export function getNewPackagesListVersionPath() {
const safeChainDir = getSafeChainDirectory();
const ecosystem = getEcoSystem();
return path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`);
}
/** /**
* @returns {string} * @returns {string}
*/ */
function getConfigFilePath() { function getConfigFilePath() {
const primaryPath = path.join(getSafeChainDirectory(), "config.json"); return path.join(getAikidoDirectory(), "config.json");
if (fs.existsSync(primaryPath)) {
return primaryPath;
}
const legacyPath = path.join(getAikidoDirectory(), "config.json");
if (fs.existsSync(legacyPath)) {
return legacyPath;
}
return primaryPath;
}
/**
* @returns {string}
*/
export function getSafeChainDirectory() {
const safeChainDir = getSafeChainBaseDir();
if (!fs.existsSync(safeChainDir)) {
fs.mkdirSync(safeChainDir, { recursive: true });
}
return safeChainDir;
} }
/** /**

View file

@ -1,43 +1,32 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test"; import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
import os from "os";
import path from "path";
const safeChainConfigPath = path.join(os.homedir(), ".safe-chain", "config.json"); describe("getScanTimeout", () => {
const aikidoConfigPath = path.join(os.homedir(), ".aikido", "config.json");
/** @type {Map<string, string>} */
let mockFiles = new Map();
mock.module("fs", {
namedExports: {
existsSync: (filePath) => mockFiles.has(filePath),
readFileSync: (filePath) => {
if (!mockFiles.has(filePath)) {
throw new Error(`ENOENT: no such file: ${filePath}`);
}
return mockFiles.get(filePath);
},
writeFileSync: (filePath, content) => mockFiles.set(filePath, content),
mkdirSync: () => {},
},
});
/**
* Helper to set config content at the primary (~/.safe-chain/) location.
* @param {string} content
*/
function setConfigContent(content) {
mockFiles.set(safeChainConfigPath, content);
}
describe("getScanTimeout", async () => {
let originalEnv; let originalEnv;
let fsMock;
const { getScanTimeout } = await import("./configFile.js"); let getScanTimeout;
beforeEach(async () => { beforeEach(async () => {
// Save original environment // Save original environment
originalEnv = process.env.AIKIDO_SCAN_TIMEOUT_MS; 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(() => { afterEach(() => {
@ -48,11 +37,14 @@ describe("getScanTimeout", async () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS; delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
} }
mockFiles.clear(); // Reset all mocks
mock.restoreAll();
}); });
it("should return default timeout of 10000ms when no config or env var is set", () => { it("should return default timeout of 10000ms when no config or env var is set", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS; delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
// Mock: config file doesn't exist
fsMock.existsSync.mock.mockImplementation(() => false);
const timeout = getScanTimeout(); const timeout = getScanTimeout();
@ -61,7 +53,11 @@ describe("getScanTimeout", async () => {
it("should return timeout from config file when set", () => { it("should return timeout from config file when set", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS; delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
setConfigContent(JSON.stringify({ scanTimeout: 5000 })); // Mock: config file exists with scanTimeout: 5000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 5000 })
);
const timeout = getScanTimeout(); const timeout = getScanTimeout();
@ -70,7 +66,11 @@ describe("getScanTimeout", async () => {
it("should prioritize environment variable over config file", () => { it("should prioritize environment variable over config file", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "20000"; process.env.AIKIDO_SCAN_TIMEOUT_MS = "20000";
setConfigContent(JSON.stringify({ scanTimeout: 5000 })); // Mock: config file exists with scanTimeout: 5000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 5000 })
);
const timeout = getScanTimeout(); const timeout = getScanTimeout();
@ -79,7 +79,11 @@ describe("getScanTimeout", async () => {
it("should handle invalid environment variable and fall back to config", () => { it("should handle invalid environment variable and fall back to config", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "invalid"; process.env.AIKIDO_SCAN_TIMEOUT_MS = "invalid";
setConfigContent(JSON.stringify({ scanTimeout: 7000 })); // Mock: config file exists with scanTimeout: 7000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 7000 })
);
const timeout = getScanTimeout(); const timeout = getScanTimeout();
@ -87,6 +91,9 @@ describe("getScanTimeout", async () => {
}); });
it("should ignore zero and negative values and fall back to default", () => { it("should ignore zero and negative values and fall back to default", () => {
// Mock: config file doesn't exist
fsMock.existsSync.mock.mockImplementation(() => false);
process.env.AIKIDO_SCAN_TIMEOUT_MS = "0"; process.env.AIKIDO_SCAN_TIMEOUT_MS = "0";
let timeout = getScanTimeout(); let timeout = getScanTimeout();
@ -100,7 +107,11 @@ describe("getScanTimeout", async () => {
it("should ignore textual non-numeric values in environment variable and fall back to config", () => { it("should ignore textual non-numeric values in environment variable and fall back to config", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "fast"; process.env.AIKIDO_SCAN_TIMEOUT_MS = "fast";
setConfigContent(JSON.stringify({ scanTimeout: 8000 })); // Mock: config file exists with scanTimeout: 8000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 8000 })
);
const timeout = getScanTimeout(); const timeout = getScanTimeout();
@ -109,7 +120,11 @@ describe("getScanTimeout", async () => {
it("should ignore textual non-numeric values in config file and fall back to default", () => { it("should ignore textual non-numeric values in config file and fall back to default", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS; delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
setConfigContent(JSON.stringify({ scanTimeout: "slow" })); // Mock: config file exists with scanTimeout: "slow"
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: "slow" })
);
const timeout = getScanTimeout(); const timeout = getScanTimeout();
@ -118,7 +133,11 @@ describe("getScanTimeout", async () => {
it("should ignore textual non-numeric values in both env and config, fall back to default", () => { it("should ignore textual non-numeric values in both env and config, fall back to default", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "quick"; process.env.AIKIDO_SCAN_TIMEOUT_MS = "quick";
setConfigContent(JSON.stringify({ scanTimeout: "medium" })); // Mock: config file exists with scanTimeout: "medium"
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: "medium" })
);
const timeout = getScanTimeout(); const timeout = getScanTimeout();
@ -127,7 +146,11 @@ describe("getScanTimeout", async () => {
it("should ignore mixed alphanumeric strings in environment variable", () => { it("should ignore mixed alphanumeric strings in environment variable", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "5000ms"; process.env.AIKIDO_SCAN_TIMEOUT_MS = "5000ms";
setConfigContent(JSON.stringify({ scanTimeout: 6000 })); // Mock: config file exists with scanTimeout: 6000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 6000 })
);
const timeout = getScanTimeout(); const timeout = getScanTimeout();
@ -136,245 +159,14 @@ describe("getScanTimeout", async () => {
it("should ignore mixed alphanumeric strings in config file", () => { it("should ignore mixed alphanumeric strings in config file", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS; delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
setConfigContent(JSON.stringify({ scanTimeout: "3000ms" })); // Mock: config file exists with scanTimeout: "3000ms"
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: "3000ms" })
);
const timeout = getScanTimeout(); const timeout = getScanTimeout();
assert.strictEqual(timeout, 10000); assert.strictEqual(timeout, 10000);
}); });
}); });
describe("getMinimumPackageAgeHours", async () => {
const { getMinimumPackageAgeHours } = await import("./configFile.js");
afterEach(() => {
mockFiles.clear();
});
it("should return null when config file doesn't exist", () => {
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, undefined);
});
it("should return null when config file exists but minimumPackageAgeHours is not set", () => {
setConfigContent(JSON.stringify({ scanTimeout: 5000 }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, undefined);
});
it("should return value from config file when set to valid number", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: 48 }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, 48);
});
it("should handle string numbers in config file", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "72" }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, 72);
});
it("should handle decimal values", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: 1.5 }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, 1.5);
});
it("should return null for non-numeric strings", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "invalid" }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, undefined);
});
it("should return undefined for values with units suffix", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "48h" }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, undefined);
});
it("should handle malformed JSON and return null", () => {
setConfigContent("{ invalid json");
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, undefined);
});
it("should return 0 when minimumPackageAgeHours is set to 0", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: 0 }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, 0);
});
it("should return 0 when minimumPackageAgeHours is set to string '0'", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "0" }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, 0);
});
it("should handle negative numeric values", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: -24 }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, -24);
});
it("should handle negative string values", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "-48" }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, -48);
});
});
const { getNpmCustomRegistries, getPipCustomRegistries } = await import(
"./configFile.js"
);
for (const { packageManager, getCustomRegistries } of [
{
packageManager: "npm",
getCustomRegistries: getNpmCustomRegistries,
},
{
packageManager: "pip",
getCustomRegistries: getPipCustomRegistries,
},
])
{
describe(getCustomRegistries.name, async () => {
afterEach(() => {
mockFiles.clear();
});
it("should return empty array when config file doesn't exist", () => {
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it(`should return empty array when ${packageManager} config is not set`, () => {
setConfigContent(JSON.stringify({ scanTimeout: 5000 }));
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should return empty array when customRegistries is not an array", () => {
setConfigContent(JSON.stringify({
[packageManager]: { customRegistries: "not-an-array" },
}));
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should return array of custom registries when set", () => {
setConfigContent(JSON.stringify({
[packageManager]: {
customRegistries: [`${packageManager}.company.com`, "registry.internal.net"],
},
}));
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"registry.internal.net",
]);
});
it("should filter out non-string values", () => {
setConfigContent(JSON.stringify({
[packageManager]: {
customRegistries: [
`${packageManager}.company.com`,
123,
null,
"registry.internal.net",
undefined,
{},
],
},
}));
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"registry.internal.net",
]);
});
it("should return empty array for empty customRegistries array", () => {
setConfigContent(JSON.stringify({
[packageManager]: { customRegistries: [] },
}));
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should handle malformed JSON and return empty array", () => {
setConfigContent("{ invalid json");
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
});
}
describe("config file location fallback", async () => {
const { getScanTimeout } = await import("./configFile.js");
afterEach(() => {
mockFiles.clear();
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
});
it("should read config from ~/.safe-chain/config.json when it exists", () => {
mockFiles.set(safeChainConfigPath, JSON.stringify({ scanTimeout: 3000 }));
assert.strictEqual(getScanTimeout(), 3000);
});
it("should fall back to ~/.aikido/config.json when primary does not exist", () => {
mockFiles.set(aikidoConfigPath, JSON.stringify({ scanTimeout: 4000 }));
assert.strictEqual(getScanTimeout(), 4000);
});
it("should prefer ~/.safe-chain/config.json when both exist", () => {
mockFiles.set(safeChainConfigPath, JSON.stringify({ scanTimeout: 3000 }));
mockFiles.set(aikidoConfigPath, JSON.stringify({ scanTimeout: 4000 }));
assert.strictEqual(getScanTimeout(), 3000);
});
it("should return default when neither config file exists", () => {
assert.strictEqual(getScanTimeout(), 10000);
});
});

View file

@ -1,57 +0,0 @@
/**
* Gets the minimum package age in hours from environment variable
* @returns {string | undefined}
*/
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() {
return process.env.SAFE_CHAIN_PIP_CUSTOM_REGISTRIES;
}
/**
* Gets the logging level from environment variable
* Valid values: "silent", "normal", "verbose"
* @returns {string | undefined}
*/
export function getLoggingLevel() {
return process.env.SAFE_CHAIN_LOGGING;
}
/**
* Gets the minimum package age exclusions from environment variable
* Expected format: comma-separated list of package names
* Example: "react,@aikidosec/safe-chain,lodash"
* @returns {string | undefined}
*/
export function getMinimumPackageAgeExclusions() {
return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS ||
process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS;
}
/**
* Gets the malware list base URL from environment variable
* Expected format: full URL without trailing slash
* Example: "https://malware-list.aikido.dev"
* @returns {string | undefined}
*/
export function getMalwareListBaseUrl() {
return process.env.SAFE_CHAIN_MALWARE_LIST_BASE_URL;
}

View file

@ -1,71 +0,0 @@
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { getInstalledSafeChainDir } from "../installLocation.js";
/**
* @returns {string}
*/
export function getSafeChainBaseDir() {
return getInstalledSafeChainDir() ?? path.join(os.homedir(), ".safe-chain");
}
/**
* @returns {string}
*/
export function getBinDir() {
return path.join(getSafeChainBaseDir(), "bin");
}
/**
* @returns {string}
*/
export function getShimsDir() {
return path.join(getSafeChainBaseDir(), "shims");
}
/**
* @returns {string}
*/
export function getScriptsDir() {
return path.join(getSafeChainBaseDir(), "scripts");
}
/**
* @returns {string}
*/
export function getCertsDir() {
return path.join(getSafeChainBaseDir(), "certs");
}
/**
* Resolves the directory of the calling module.
* Falls back to __dirname when import.meta.url is unavailable (pkg CJS binary).
* @param {string | undefined} moduleUrl
* @returns {string}
*/
function resolveModuleDir(moduleUrl) {
if (moduleUrl) {
return path.dirname(fileURLToPath(moduleUrl));
}
// eslint-disable-next-line no-undef
return __dirname;
}
/**
* @param {string | undefined} moduleUrl
* @param {string} fileName
* @returns {string}
*/
export function getStartupScriptSourcePath(moduleUrl, fileName) {
return path.join(resolveModuleDir(moduleUrl), "startup-scripts", fileName);
}
/**
* @param {string | undefined} moduleUrl
* @param {string} fileName
* @returns {string}
*/
export function getPathWrapperTemplatePath(moduleUrl, fileName) {
return path.join(resolveModuleDir(moduleUrl), "path-wrappers", "templates", fileName);
}

View file

@ -1,27 +1,18 @@
import * as cliArguments from "./cliArguments.js"; import * as cliArguments from "./cliArguments.js";
import * as configFile from "./configFile.js";
import * as environmentVariables from "./environmentVariables.js";
import { ui } from "../environment/userInteraction.js";
export const LOGGING_SILENT = "silent"; export const LOGGING_SILENT = "silent";
export const LOGGING_NORMAL = "normal"; export const LOGGING_NORMAL = "normal";
export const LOGGING_VERBOSE = "verbose"; export const LOGGING_VERBOSE = "verbose";
export function getLoggingLevel() { export function getLoggingLevel() {
// Priority 1: CLI argument const level = cliArguments.getLoggingLevel();
const cliLevel = cliArguments.getLoggingLevel();
if (cliLevel === LOGGING_SILENT || cliLevel === LOGGING_VERBOSE) { if (level === LOGGING_SILENT) {
return cliLevel; return LOGGING_SILENT;
}
if (cliLevel) {
// CLI arg was set but invalid, default to normal for backwards compatibility.
return LOGGING_NORMAL;
} }
// Priority 2: Environment variable if (level === LOGGING_VERBOSE) {
const envLevel = environmentVariables.getLoggingLevel()?.toLowerCase(); return LOGGING_VERBOSE;
if (envLevel === LOGGING_SILENT || envLevel === LOGGING_VERBOSE) {
return envLevel;
} }
return LOGGING_NORMAL; return LOGGING_NORMAL;
@ -46,55 +37,11 @@ export function setEcoSystem(setting) {
ecosystemSettings.ecoSystem = setting; ecosystemSettings.ecoSystem = setting;
} }
const defaultMinimumPackageAge = 48; const defaultMinimumPackageAge = 24;
/** @returns {number} */
export function getMinimumPackageAgeHours() { export function getMinimumPackageAgeHours() {
// Priority 1: CLI argument
const cliValue = validateMinimumPackageAgeHours(
cliArguments.getMinimumPackageAgeHours()
);
if (cliValue !== undefined) {
return cliValue;
}
// Priority 2: Environment variable
const envValue = validateMinimumPackageAgeHours(
environmentVariables.getMinimumPackageAgeHours()
);
if (envValue !== undefined) {
return envValue;
}
// Priority 3: Config file
const configValue = configFile.getMinimumPackageAgeHours();
if (configValue !== undefined) {
return configValue;
}
return defaultMinimumPackageAge; return defaultMinimumPackageAge;
} }
/**
* @param {string | undefined} value
* @returns {number | undefined}
*/
function validateMinimumPackageAgeHours(value) {
if (!value) {
return undefined;
}
const numericValue = Number(value);
if (Number.isNaN(numericValue)) {
return undefined;
}
if (numericValue >= 0) {
return numericValue;
}
return undefined;
}
const defaultSkipMinimumPackageAge = false; const defaultSkipMinimumPackageAge = false;
export function skipMinimumPackageAge() { export function skipMinimumPackageAge() {
const cliValue = cliArguments.getSkipMinimumPackageAge(); const cliValue = cliArguments.getSkipMinimumPackageAge();
@ -105,143 +52,3 @@ export function skipMinimumPackageAge() {
return defaultSkipMinimumPackageAge; return defaultSkipMinimumPackageAge;
} }
/**
* 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?:\/\//, "");
}
/**
* Parses comma-separated registries from environment variable
* @param {string | undefined} envValue
* @returns {string[]}
*/
function parseRegistriesFromEnv(envValue) {
if (!envValue || typeof envValue !== "string") {
return [];
}
// 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 uniqueRegistries = [...new Set(allRegistries)];
// Normalize each registry (remove protocol if any)
return uniqueRegistries.map(normalizeRegistry);
}
/**
* Parses comma-separated exclusions from environment variable
* @param {string | undefined} envValue
* @returns {string[]}
*/
function parseExclusionsFromEnv(envValue) {
if (!envValue || typeof envValue !== "string") {
return [];
}
return envValue
.split(",")
.map((exclusion) => exclusion.trim())
.filter((exclusion) => exclusion.length > 0);
}
/**
* Gets the minimum package age exclusions from both environment variable and config file (merged)
* @returns {string[]}
*/
export function getMinimumPackageAgeExclusions() {
const envExclusions = parseExclusionsFromEnv(
environmentVariables.getMinimumPackageAgeExclusions()
);
const configExclusions = configFile.getMinimumPackageAgeExclusions();
// Merge both sources and remove duplicates
const allExclusions = [...envExclusions, ...configExclusions];
return [...new Set(allExclusions)];
}
/**
* Gets the malware list base URL with priority: CLI argument > environment variable > config file > default
* @returns {string}
*/
export function getMalwareListBaseUrl() {
// Priority 1: CLI argument
const cliValue = cliArguments.getMalwareListBaseUrl();
if (cliValue) {
const url = removeTrailingSlashes(cliValue);
ui.writeVerbose(`Fetching malware lists from ${url} as defined by CLI argument --safe-chain-malware-list-base-url`);
return url;
}
// Priority 2: Environment variable
const envValue = environmentVariables.getMalwareListBaseUrl();
if (envValue) {
const url = removeTrailingSlashes(envValue);
ui.writeVerbose(`Fetching malware lists from ${url} as defined by environment variable SAFE_CHAIN_MALWARE_LIST_BASE_URL`);
return url;
}
// Priority 3: Config file
const configValue = configFile.getMalwareListBaseUrl();
if (configValue) {
const url = removeTrailingSlashes(configValue);
ui.writeVerbose(`Fetching malware lists from ${url} as defined by config file (malwareListBaseUrl)`);
return url;
}
// Default
return removeTrailingSlashes("https://malware-list.aikido.dev");
}
/**
* Removes trailing slashes from a URL-like string.
* @param {string} value
* @returns {string}
*/
function removeTrailingSlashes(value) {
if (!value || typeof value !== "string") {
return value;
}
return value.replace(/\/+$/, "");
}

View file

@ -1,647 +0,0 @@
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: () => {},
},
});
const {
getNpmCustomRegistries,
getPipCustomRegistries,
getMinimumPackageAgeExclusions,
getMalwareListBaseUrl,
setEcoSystem,
ECOSYSTEM_JS,
ECOSYSTEM_PY,
getLoggingLevel,
LOGGING_SILENT,
LOGGING_NORMAL,
LOGGING_VERBOSE,
} = await import("./settings.js");
const { initializeCliArguments } = await import("./cliArguments.js");
for (const { packageManager, getCustomRegistries, envVarName } of [
{
packageManager: "npm",
getCustomRegistries: getNpmCustomRegistries,
envVarName: "SAFE_CHAIN_NPM_CUSTOM_REGISTRIES",
},
{
packageManager: "pip",
getCustomRegistries: getPipCustomRegistries,
envVarName: "SAFE_CHAIN_PIP_CUSTOM_REGISTRIES",
},
]) {
describe(getCustomRegistries.name, async () => {
let originalEnv;
beforeEach(() => {
originalEnv = process.env[envVarName];
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env[envVarName] = originalEnv;
} else {
delete process.env[envVarName];
}
configFileContent = undefined;
});
it("should return empty array when no registries configured", () => {
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should return registries without protocol", () => {
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: [
`${packageManager}.company.com`,
"registry.internal.net",
],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"registry.internal.net",
]);
});
it("should strip https:// protocol from registries", () => {
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: [
`https://${packageManager}.company.com`,
"https://registry.internal.net",
],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"registry.internal.net",
]);
});
it("should strip http:// protocol from registries", () => {
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: [
`http://${packageManager}.company.com`,
"http://registry.internal.net",
],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"registry.internal.net",
]);
});
it("should handle mixed protocols and no protocol", () => {
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: [
`https://${packageManager}.company.com`,
"registry.internal.net",
"http://private.registry.io",
],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"registry.internal.net",
"private.registry.io",
]);
});
it("should preserve registry path after stripping protocol", () => {
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: [
`https://${packageManager}.company.com/custom/path`,
`registry.internal.net/${packageManager}`,
],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com/custom/path`,
`registry.internal.net/${packageManager}`,
]);
});
it("should parse comma-separated registries from environment variable", () => {
delete process.env[envVarName];
process.env[envVarName] = "env1.registry.com,env2.registry.net";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"env2.registry.net",
]);
});
it("should trim whitespace from environment variable registries", () => {
delete process.env[envVarName];
process.env[envVarName] = " env1.registry.com , env2.registry.net ";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"env2.registry.net",
]);
});
it("should merge environment variable and config file registries", () => {
delete process.env[envVarName];
process.env[envVarName] = "env1.registry.com";
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: ["config1.registry.net"],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"config1.registry.net",
]);
});
it("should remove duplicate registries when merging env and config", () => {
delete process.env[envVarName];
process.env[
envVarName
] = `${packageManager}.company.com,env.registry.com`;
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: [
`${packageManager}.company.com`,
"config.registry.net",
],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"env.registry.com",
"config.registry.net",
]);
});
it("should normalize protocols from environment variable registries", () => {
delete process.env[envVarName];
process.env[envVarName] =
"https://env1.registry.com,http://env2.registry.net";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"env2.registry.net",
]);
});
it("should handle empty strings in comma-separated list", () => {
delete process.env[envVarName];
process.env[envVarName] = "env1.registry.com,,env2.registry.net,";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"env2.registry.net",
]);
});
it("should handle single registry in environment variable", () => {
delete process.env[envVarName];
process.env[envVarName] = "single.registry.com";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, ["single.registry.com"]);
});
it("should return empty array for empty environment variable", () => {
delete process.env[envVarName];
process.env[envVarName] = "";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should return empty array for whitespace-only environment variable", () => {
delete process.env[envVarName];
process.env[envVarName] = " , , ";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
});
}
describe("getLoggingLevel", () => {
let originalEnv;
beforeEach(() => {
originalEnv = process.env.SAFE_CHAIN_LOGGING;
delete process.env.SAFE_CHAIN_LOGGING;
// Reset CLI arguments state
initializeCliArguments([]);
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.SAFE_CHAIN_LOGGING = originalEnv;
} else {
delete process.env.SAFE_CHAIN_LOGGING;
}
});
it("should return normal by default when nothing is configured", () => {
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_NORMAL);
});
it("should return silent from environment variable", () => {
process.env.SAFE_CHAIN_LOGGING = "silent";
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_SILENT);
});
it("should return verbose from environment variable", () => {
process.env.SAFE_CHAIN_LOGGING = "verbose";
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_VERBOSE);
});
it("should handle uppercase environment variable values", () => {
process.env.SAFE_CHAIN_LOGGING = "VERBOSE";
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_VERBOSE);
});
it("should handle mixed case environment variable values", () => {
process.env.SAFE_CHAIN_LOGGING = "Silent";
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_SILENT);
});
it("should return normal for invalid environment variable values", () => {
process.env.SAFE_CHAIN_LOGGING = "invalid";
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_NORMAL);
});
it("should prioritize CLI argument over environment variable", () => {
process.env.SAFE_CHAIN_LOGGING = "verbose";
initializeCliArguments(["--safe-chain-logging=silent"]);
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_SILENT);
});
it("should use environment variable when CLI argument is not set", () => {
process.env.SAFE_CHAIN_LOGGING = "silent";
initializeCliArguments(["install", "express"]);
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_SILENT);
});
it("should return normal when CLI argument is invalid (even if env var is valid)", () => {
process.env.SAFE_CHAIN_LOGGING = "verbose";
initializeCliArguments(["--safe-chain-logging=invalid"]);
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_NORMAL);
});
});
describe("getMinimumPackageAgeExclusions", () => {
let originalEnv;
let originalLegacyEnv;
const envVarName = "SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS";
const legacyEnvVarName = "SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS";
beforeEach(() => {
originalEnv = process.env[envVarName];
originalLegacyEnv = process.env[legacyEnvVarName];
delete process.env[envVarName];
delete process.env[legacyEnvVarName];
setEcoSystem(ECOSYSTEM_JS);
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env[envVarName] = originalEnv;
} else {
delete process.env[envVarName];
}
if (originalLegacyEnv !== undefined) {
process.env[legacyEnvVarName] = originalLegacyEnv;
} else {
delete process.env[legacyEnvVarName];
}
configFileContent = undefined;
});
it("should return empty array when no exclusions configured", () => {
configFileContent = undefined;
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, []);
});
it("should return exclusions from config file", () => {
configFileContent = JSON.stringify({
npm: {
minimumPackageAgeExclusions: ["react", "@aikidosec/safe-chain"],
},
});
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["react", "@aikidosec/safe-chain"]);
});
it("should parse comma-separated exclusions from environment variable", () => {
process.env[envVarName] = "lodash,express,@types/node";
configFileContent = undefined;
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "express", "@types/node"]);
});
it("should merge environment variable and config file exclusions", () => {
process.env[envVarName] = "lodash";
configFileContent = JSON.stringify({
npm: {
minimumPackageAgeExclusions: ["react"],
},
});
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]);
});
it("should remove duplicate exclusions when merging", () => {
process.env[envVarName] = "lodash,react";
configFileContent = JSON.stringify({
npm: {
minimumPackageAgeExclusions: ["react", "express"],
},
});
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react", "express"]);
});
it("should trim whitespace from environment variable exclusions", () => {
process.env[envVarName] = " lodash , react ";
configFileContent = undefined;
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]);
});
it("should handle scoped packages", () => {
configFileContent = JSON.stringify({
npm: {
minimumPackageAgeExclusions: ["@babel/core", "@types/react"],
},
});
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["@babel/core", "@types/react"]);
});
it("should handle empty strings in comma-separated list", () => {
process.env[envVarName] = "lodash,,react,";
configFileContent = undefined;
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]);
});
it("should return empty array for empty environment variable", () => {
process.env[envVarName] = "";
configFileContent = undefined;
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, []);
});
it("should return empty array for whitespace-only environment variable", () => {
process.env[envVarName] = " , , ";
configFileContent = undefined;
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, []);
});
it("should filter non-string values from config file", () => {
configFileContent = JSON.stringify({
npm: {
minimumPackageAgeExclusions: ["react", 123, null, "lodash", undefined],
},
});
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["react", "lodash"]);
});
it("should fall back to the legacy npm environment variable", () => {
process.env[legacyEnvVarName] = "lodash,react";
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]);
});
it("should read exclusions from the python config when the current ecosystem is py", () => {
setEcoSystem(ECOSYSTEM_PY);
configFileContent = JSON.stringify({
pip: {
minimumPackageAgeExclusions: ["requests", "urllib3"],
},
});
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["requests", "urllib3"]);
});
});
describe("getMalwareListBaseUrl", () => {
let originalEnv;
const envVarName = "SAFE_CHAIN_MALWARE_LIST_BASE_URL";
beforeEach(() => {
originalEnv = process.env[envVarName];
delete process.env[envVarName];
// Reset CLI arguments state
initializeCliArguments([]);
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env[envVarName] = originalEnv;
} else {
delete process.env[envVarName];
}
configFileContent = undefined;
});
it("should return default URL when nothing is configured", () => {
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://malware-list.aikido.dev");
});
it("should trim trailing slash from CLI argument", () => {
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com/"]);
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://cli-mirror.com");
});
it("should trim trailing slash from environment variable", () => {
process.env[envVarName] = "https://env-mirror.com/";
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://env-mirror.com");
});
it("should trim trailing slash from config file value", () => {
configFileContent = JSON.stringify({
malwareListBaseUrl: "https://config-mirror.com/",
});
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://config-mirror.com");
});
it("should return CLI argument value with highest priority", () => {
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]);
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://cli-mirror.com");
});
it("should return environment variable value when no CLI argument", () => {
process.env[envVarName] = "https://env-mirror.com";
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://env-mirror.com");
});
it("should return config file value when no CLI or env", () => {
configFileContent = JSON.stringify({
malwareListBaseUrl: "https://config-mirror.com",
});
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://config-mirror.com");
});
it("should prioritize CLI over environment variable", () => {
process.env[envVarName] = "https://env-mirror.com";
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]);
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://cli-mirror.com");
});
it("should prioritize environment variable over config file", () => {
process.env[envVarName] = "https://env-mirror.com";
configFileContent = JSON.stringify({
malwareListBaseUrl: "https://config-mirror.com",
});
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://env-mirror.com");
});
it("should prioritize CLI over config file", () => {
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]);
configFileContent = JSON.stringify({
malwareListBaseUrl: "https://config-mirror.com",
});
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://cli-mirror.com");
});
});

View file

@ -1,5 +1,6 @@
// oxlint-disable no-console // oxlint-disable no-console
import chalk from "chalk"; import chalk from "chalk";
import ora from "ora";
import { isCi } from "./environment.js"; import { isCi } from "./environment.js";
import { import {
getLoggingLevel, getLoggingLevel,
@ -97,6 +98,61 @@ function writeOrBuffer(messageFunction) {
} }
} }
/**
* @typedef {Object} Spinner
* @property {(message: string) => void} succeed
* @property {(message: string) => void} fail
* @property {() => void} stop
* @property {(message: string) => void} setText
*/
/**
* @param {string} message
*
* @returns {Spinner}
*/
function startProcess(message) {
if (isSilentMode()) {
return {
succeed: () => {},
fail: () => {},
stop: () => {},
setText: () => {},
};
}
if (isCi()) {
return {
succeed: (message) => {
writeInformation(message);
},
fail: (message) => {
writeError(message);
},
stop: () => {},
setText: (message) => {
writeInformation(message);
},
};
} else {
const spinner = ora(message).start();
return {
succeed: (message) => {
spinner.succeed(message);
},
fail: (message) => {
spinner.fail(message);
},
stop: () => {
spinner.stop();
},
setText: (message) => {
spinner.text = message;
},
};
}
}
function startBufferingLogs() { function startBufferingLogs() {
state.bufferOutput = true; state.bufferOutput = true;
state.bufferedMessages = []; state.bufferedMessages = [];
@ -117,6 +173,7 @@ export const ui = {
writeError, writeError,
writeExitWithoutInstallingMaliciousPackages, writeExitWithoutInstallingMaliciousPackages,
emptyLine, emptyLine,
startProcess,
startBufferingLogs, startBufferingLogs,
writeBufferedLogsAndStopBuffering, writeBufferedLogsAndStopBuffering,
}; };

View file

@ -1,42 +0,0 @@
import path from "path";
/** @type {NodeJS.Process & { pkg?: unknown }} */
const processWithPkg = process;
/**
* @param {string} executablePath
* @returns {string | undefined}
*/
export function deriveInstallDirFromExecutablePath(executablePath) {
if (!executablePath) {
return undefined;
}
const pathLibrary = executablePath.includes("\\") ? path.win32 : path.posix;
const executableDir = pathLibrary.dirname(executablePath);
if (pathLibrary.basename(executableDir) !== "bin") {
return undefined;
}
return pathLibrary.dirname(executableDir);
}
/**
* Returns the install directory for a packaged safe-chain binary.
* Custom installation directories only apply to packaged binary installs.
* For npm/global/dev-script executions this intentionally returns undefined,
* which causes callers to fall back to the default ~/.safe-chain layout.
*
* @param {{ isPackaged?: boolean, executablePath?: string }} [options]
* @returns {string | undefined}
*/
export function getInstalledSafeChainDir(options = {}) {
const isPackaged = options.isPackaged ?? Boolean(processWithPkg.pkg);
if (!isPackaged) {
return undefined;
}
return deriveInstallDirFromExecutablePath(
options.executablePath ?? process.execPath,
);
}

View file

@ -1,51 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import {
deriveInstallDirFromExecutablePath,
getInstalledSafeChainDir,
} from "./installLocation.js";
describe("deriveInstallDirFromExecutablePath", () => {
it("derives the install dir from a Unix binary path", () => {
assert.strictEqual(
deriveInstallDirFromExecutablePath("/usr/local/.safe-chain/bin/safe-chain"),
"/usr/local/.safe-chain",
);
});
it("derives the install dir from a Windows binary path", () => {
assert.strictEqual(
deriveInstallDirFromExecutablePath("C:\\ProgramData\\safe-chain\\bin\\safe-chain.exe"),
"C:\\ProgramData\\safe-chain",
);
});
it("returns undefined when the executable is not inside a bin directory", () => {
assert.strictEqual(
deriveInstallDirFromExecutablePath("/usr/local/.safe-chain/safe-chain"),
undefined,
);
});
});
describe("getInstalledSafeChainDir", () => {
it("returns undefined for non-packaged executions", () => {
assert.strictEqual(
getInstalledSafeChainDir({
isPackaged: false,
executablePath: "/usr/local/.safe-chain/bin/safe-chain",
}),
undefined,
);
});
it("returns the install dir for packaged executions", () => {
assert.strictEqual(
getInstalledSafeChainDir({
isPackaged: true,
executablePath: "/usr/local/.safe-chain/bin/safe-chain",
}),
"/usr/local/.safe-chain",
);
});
});

View file

@ -13,10 +13,6 @@ import { getAuditStats } from "./scanning/audit/index.js";
* @returns {Promise<number>} * @returns {Promise<number>}
*/ */
export async function main(args) { export async function main(args) {
if (isSafeChainVerify(args)) {
return 0;
}
process.on("SIGINT", handleProcessTermination); process.on("SIGINT", handleProcessTermination);
process.on("SIGTERM", handleProcessTermination); process.on("SIGTERM", handleProcessTermination);
@ -27,7 +23,6 @@ export async function main(args) {
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
ui.writeError(`Safe-chain: Uncaught exception: ${error.message}`); ui.writeError(`Safe-chain: Uncaught exception: ${error.message}`);
ui.writeVerbose(`Stack trace: ${error.stack}`); ui.writeVerbose(`Stack trace: ${error.stack}`);
ui.writeBufferedLogsAndStopBuffering();
process.exit(1); process.exit(1);
}); });
@ -36,7 +31,6 @@ export async function main(args) {
if (reason instanceof Error) { if (reason instanceof Error) {
ui.writeVerbose(`Stack trace: ${reason.stack}`); ui.writeVerbose(`Stack trace: ${reason.stack}`);
} }
ui.writeBufferedLogsAndStopBuffering();
process.exit(1); process.exit(1);
}); });
@ -64,33 +58,30 @@ export async function main(args) {
// Write all buffered logs // Write all buffered logs
ui.writeBufferedLogsAndStopBuffering(); ui.writeBufferedLogsAndStopBuffering();
if (proxy.hasBlockedMaliciousPackages()) { if (!proxy.verifyNoMaliciousPackages()) {
return 1;
}
if (proxy.hasBlockedMinimumAgeRequests()) {
return 1; return 1;
} }
const auditStats = getAuditStats(); const auditStats = getAuditStats();
if (auditStats.totalPackages > 0) { if (auditStats.totalPackages > 0) {
ui.writeVerbose( ui.emptyLine();
ui.writeInformation(
`${chalk.green("✔")} Safe-chain: Scanned ${ `${chalk.green("✔")} Safe-chain: Scanned ${
auditStats.totalPackages auditStats.totalPackages
} packages, no malware found.`, } packages, no malware found.`
); );
} }
if (proxy.hasSuppressedVersions()) { if (proxy.hasSuppressedVersions()) {
ui.writeInformation( ui.writeInformation(
`${chalk.yellow( `${chalk.yellow(
"", ""
)} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age.`, )} Safe-chain: Some package versions were suppressed due to minimum age requirement.`
); );
ui.writeInformation( ui.writeInformation(
` To disable this check, use: ${chalk.cyan( ` To disable this check, use: ${chalk.cyan(
"--safe-chain-skip-minimum-package-age", "--safe-chain-skip-minimum-package-age"
)}`, )}`
); );
} }
@ -99,7 +90,6 @@ export async function main(args) {
return packageManagerResult.status; return packageManagerResult.status;
} catch (/** @type any */ error) { } catch (/** @type any */ error) {
ui.writeError("Failed to check for malicious packages:", error.message); ui.writeError("Failed to check for malicious packages:", error.message);
ui.writeBufferedLogsAndStopBuffering();
// Returning the exit code back to the caller allows the promise // Returning the exit code back to the caller allows the promise
// to be awaited in the bin files and return the correct exit code // to be awaited in the bin files and return the correct exit code
@ -112,12 +102,3 @@ export async function main(args) {
function handleProcessTermination() { function handleProcessTermination() {
ui.writeBufferedLogsAndStopBuffering(); ui.writeBufferedLogsAndStopBuffering();
} }
/** @param {string[]} args */
function isSafeChainVerify(args) {
const safeChainCheckCommand = "safe-chain-verify";
if (args.length > 0 && args[0] === safeChainCheckCommand) {
ui.writeInformation("OK: Safe-chain works!");
return true;
}
}

View file

@ -1,17 +0,0 @@
import { ui } from "../../environment/userInteraction.js";
/**
* Centralized logging for package-manager command launch failures.
*
* @param {any} error - Error thrown by safeSpawn while preparing/running the command.
* @param {string} command - Command name that failed to execute.
* @returns {{status: number}}
*/
export function reportCommandExecutionFailure(error, command) {
const message = typeof error?.message === "string" ? error.message : "Unknown error";
ui.writeError(`Error executing command: ${message}`);
ui.writeError(`Is '${command}' installed and available on your system?`);
return { status: typeof error?.status === "number" ? error.status : 1 };
}

View file

@ -1,59 +0,0 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("reportCommandExecutionFailure", () => {
let errorLines;
beforeEach(async () => {
errorLines = [];
mock.module("../../environment/userInteraction.js", {
namedExports: {
ui: {
writeError: (...args) => {
errorLines.push(args.join(" "));
},
},
},
});
});
afterEach(() => {
mock.reset();
});
it("reports command errors while preserving exit status", async () => {
const { reportCommandExecutionFailure } = await import("./commandErrors.js");
const result = reportCommandExecutionFailure(
{
status: 127,
message: "Command failed: command -v bun",
},
"bun",
);
assert.deepStrictEqual(result, { status: 127 });
assert.deepStrictEqual(errorLines, [
"Error executing command: Command failed: command -v bun",
"Is 'bun' installed and available on your system?",
]);
});
it("falls back to exit code 1 when status is missing", async () => {
const { reportCommandExecutionFailure } = await import("./commandErrors.js");
const result = reportCommandExecutionFailure(
{
message: "Network error",
},
"npm",
);
assert.deepStrictEqual(result, { status: 1 });
assert.deepStrictEqual(errorLines, [
"Error executing command: Network error",
"Is 'npm' installed and available on your system?",
]);
});
});

View file

@ -1,6 +1,6 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/** /**
* @returns {import("../currentPackageManager.js").PackageManager} * @returns {import("../currentPackageManager.js").PackageManager}
@ -43,6 +43,11 @@ async function runBunCommand(command, args) {
}); });
return { status: result.status }; return { status: result.status };
} catch (/** @type any */ error) { } catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, command); if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
} }
} }

View file

@ -10,13 +10,6 @@ import {
} from "./pnpm/createPackageManager.js"; } from "./pnpm/createPackageManager.js";
import { createYarnPackageManager } from "./yarn/createPackageManager.js"; import { createYarnPackageManager } from "./yarn/createPackageManager.js";
import { createPipPackageManager } from "./pip/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";
import { createPdmPackageManager } from "./pdm/createPdmPackageManager.js";
import { createRushPackageManager } from "./rush/createRushPackageManager.js";
import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js";
import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js";
/** /**
* @type {{packageManagerName: PackageManager | null}} * @type {{packageManagerName: PackageManager | null}}
@ -41,11 +34,10 @@ const state = {
/** /**
* @param {string} packageManagerName * @param {string} packageManagerName
* @param {{ tool: string, args: string[] }} [context] - Optional tool context for package managers like pip
* *
* @return {PackageManager} * @return {PackageManager}
*/ */
export function initializePackageManager(packageManagerName, context) { export function initializePackageManager(packageManagerName) {
if (packageManagerName === "npm") { if (packageManagerName === "npm") {
state.packageManagerName = createNpmPackageManager(); state.packageManagerName = createNpmPackageManager();
} else if (packageManagerName === "npx") { } else if (packageManagerName === "npx") {
@ -61,21 +53,7 @@ export function initializePackageManager(packageManagerName, context) {
} else if (packageManagerName === "bunx") { } else if (packageManagerName === "bunx") {
state.packageManagerName = createBunxPackageManager(); state.packageManagerName = createBunxPackageManager();
} else if (packageManagerName === "pip") { } else if (packageManagerName === "pip") {
state.packageManagerName = createPipPackageManager(context); state.packageManagerName = createPipPackageManager();
} else if (packageManagerName === "uv") {
state.packageManagerName = createUvPackageManager();
} else if (packageManagerName === "uvx") {
state.packageManagerName = createUvxPackageManager();
} else if (packageManagerName === "poetry") {
state.packageManagerName = createPoetryPackageManager();
} else if (packageManagerName === "pipx") {
state.packageManagerName = createPipXPackageManager();
} else if (packageManagerName === "pdm") {
state.packageManagerName = createPdmPackageManager();
} else if (packageManagerName === "rush") {
state.packageManagerName = createRushPackageManager();
} else if (packageManagerName === "rushx") {
state.packageManagerName = createRushxPackageManager();
} else { } else {
throw new Error("Unsupported package manager: " + packageManagerName); throw new Error("Unsupported package manager: " + packageManagerName);
} }

View file

@ -1,6 +1,6 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/** /**
* @param {string[]} args * @param {string[]} args
@ -15,6 +15,11 @@ export async function runNpm(args) {
}); });
return { status: result.status }; return { status: result.status };
} catch (/** @type any */ error) { } catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, "npm"); if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
} }
} }

View file

@ -1,6 +1,6 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/** /**
* @param {string[]} args * @param {string[]} args
@ -15,6 +15,11 @@ export async function runNpx(args) {
}); });
return { status: result.status }; return { status: result.status };
} catch (/** @type any */ error) { } catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, "npx"); if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
} }
} }

View file

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

View file

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

View file

@ -1,21 +1,17 @@
import { runPip } from "./runPipCommand.js"; import { runPip } from "./runPipCommand.js";
import { PIP_COMMAND } from "./pipSettings.js"; import { getCurrentPipInvocation } from "./pipSettings.js";
/** /**
* @param {{ tool: string, args: string[] }} [context] - Optional context with tool name and args
* @returns {import("../currentPackageManager.js").PackageManager} * @returns {import("../currentPackageManager.js").PackageManager}
*/ */
export function createPipPackageManager(context) { export function createPipPackageManager() {
const tool = context?.tool || PIP_COMMAND;
return { return {
/** /**
* @param {string[]} args * @param {string[]} args
*/ */
runCommand: (args) => { runCommand: (args) => {
// Args from main.js are already stripped of --safe-chain-* flags const invocation = getCurrentPipInvocation();
// We just pass the tool (e.g. "python3") and the args (e.g. ["-m", "pip", "install", ...]) const fullArgs = [...invocation.args, ...args];
return runPip(tool, args); return runPip(invocation.command, fullArgs);
}, },
// For pip, rely solely on MITM proxy to detect/deny downloads from known registries. // For pip, rely solely on MITM proxy to detect/deny downloads from known registries.
isSupportedCommand: () => false, isSupportedCommand: () => false,

View file

@ -1,6 +1,30 @@
export const PIP_PACKAGE_MANAGER = "pip"; export const PIP_PACKAGE_MANAGER = "pip";
export const PIP_COMMAND = "pip"; // All supported python/pip invocations for Safe Chain interception
export const PIP3_COMMAND = "pip3"; export const PIP_INVOCATIONS = {
export const PYTHON_COMMAND = "python"; PIP: { command: "pip", args: [] },
export const PYTHON3_COMMAND = "python3"; PIP3: { command: "pip3", args: [] },
PY_PIP: { command: "python", args: ["-m", "pip"] },
PY3_PIP: { command: "python3", args: ["-m", "pip"] },
PY_PIP3: { command: "python", args: ["-m", "pip3"] },
PY3_PIP3: { command: "python3", args: ["-m", "pip3"] }
};
/**
* @type {{ command: string, args: string[] }}
*/
let currentInvocation = PIP_INVOCATIONS.PY3_PIP; // Default to python3 -m pip
/**
* @param {{ command: string, args: string[] }} invocation
*/
export function setCurrentPipInvocation(invocation) {
currentInvocation = invocation;
}
/**
* @returns {{ command: string, args: string[] }}
*/
export function getCurrentPipInvocation() {
return currentInvocation;
}

View file

@ -2,32 +2,11 @@ import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
import { PIP_COMMAND, PIP3_COMMAND, PYTHON_COMMAND, PYTHON3_COMMAND } from "./pipSettings.js";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import fsSync from "node:fs"; import fsSync from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import ini from "ini"; import ini from "ini";
import { spawn } from "child_process";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* Checks if this pip invocation should bypass safe-chain and spawn directly.
* Returns true if the tool is python/python3 but NOT being run with -m pip/pip3.
* @param {string} command - The command executable
* @param {string[]} args - The arguments
* @returns {boolean}
*/
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)) {
return false;
}
return true;
}
return false;
}
/** /**
* Sets fallback CA bundle environment variables used by Python libraries. * Sets fallback CA bundle environment variables used by Python libraries.
@ -67,47 +46,19 @@ function setFallbackCaBundleEnvironmentVariables(env, combinedCaPath) {
* If the user has an existing PIP_CONFIG_FILE, a new temporary config is created that merges * If the user has an existing PIP_CONFIG_FILE, a new temporary config is created that merges
* their settings with safe-chain's, leaving the original file unchanged. * their settings with safe-chain's, leaving the original file unchanged.
* *
* Special handling for commands that modify config/cache/state: PIP_CONFIG_FILE is NOT overridden to allow * @param {string} command - The pip command to execute (e.g., 'pip3')
* users to read/write persistent config. Only CA environment variables are set for these commands.
*
* @param {string} command - The pip command executable (e.g., 'pip3' or 'python3')
* @param {string[]} args - Command line arguments to pass to pip * @param {string[]} args - Command line arguments to pass to pip
* @returns {Promise<{status: number}>} Exit status of the pip command * @returns {Promise<{status: number}>} Exit status of the pip command
*/ */
export async function runPip(command, args) { export async function runPip(command, args) {
// Check if we should bypass safe-chain (python/python3 without -m pip)
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
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);
});
});
}
try { try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env); const env = mergeSafeChainProxyEnvironmentVariables(process.env);
// Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots + user certs) // Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots)
// so that any network request made by pip, including those outside explicit CLI args, // so that any network request made by pip, including those outside explicit CLI args,
// validates correctly under both MITM'd and tunneled HTTPS. // validates correctly under both MITM'd and tunneled HTTPS.
const combinedCaPath = getCombinedCaBundlePath(); const combinedCaPath = getCombinedCaBundlePath();
// Commands that need access to persistent config/cache/state files
// These should not have PIP_CONFIG_FILE overridden as it would prevent them from
// reading/writing to the user's actual pip configuration and cache directories
const configRelatedCommands = ['config', 'cache', 'debug', 'completion'];
const isConfigRelatedCommand = args.length > 0 && configRelatedCommands.includes(args[0]);
// https://pip.pypa.io/en/stable/topics/https-certificates/ explains that the 'cert' param (which we're providing via INI file) // https://pip.pypa.io/en/stable/topics/https-certificates/ explains that the 'cert' param (which we're providing via INI file)
// will tell pip to use the provided CA bundle for HTTPS verification. // will tell pip to use the provided CA bundle for HTTPS verification.
@ -119,22 +70,6 @@ export async function runPip(command, args) {
const pipConfigPath = path.join(tmpDir, `safe-chain-pip-${Date.now()}.ini`); const pipConfigPath = path.join(tmpDir, `safe-chain-pip-${Date.now()}.ini`);
let cleanupConfigPath = null; // Track temp file for cleanup let cleanupConfigPath = null; // Track temp file for cleanup
if (isConfigRelatedCommand) {
ui.writeVerbose(`Safe-chain: Skipping PIP_CONFIG_FILE override for 'pip ${args[0]}' command to allow persistent config/cache access.`);
// Still set the fallback CA bundle environment variables to avoid edge cases where a
// plugin or extension triggers a network call during config introspection
// This can do no harm
setFallbackCaBundleEnvironmentVariables(env, combinedCaPath);
const result = await safeSpawn(command, args, {
stdio: "inherit",
env,
});
return { status: result.status };
}
// Note: Setting PIP_CONFIG_FILE overrides all pip config levels (Global/User/Site) per pip's loading order // Note: Setting PIP_CONFIG_FILE overrides all pip config levels (Global/User/Site) per pip's loading order
if (!env.PIP_CONFIG_FILE) { if (!env.PIP_CONFIG_FILE) {
/** @type {{ global: { cert: string, proxy?: string } }} */ /** @type {{ global: { cert: string, proxy?: string } }} */
@ -204,6 +139,12 @@ export async function runPip(command, args) {
return { status: result.status }; return { status: result.status };
} catch (/** @type any */ error) { } catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, command); if (error.status) {
return { status: error.status };
} else {
ui.writeError(`Error executing command: ${error.message}`);
ui.writeError(`Is '${command}' installed and available on your system?`);
return { status: 1 };
}
} }
} }

View file

@ -7,7 +7,6 @@ import ini from "ini";
describe("runPipCommand environment variable handling", () => { describe("runPipCommand environment variable handling", () => {
let runPip; let runPip;
let shouldBypassSafeChain;
let capturedArgs = null; let capturedArgs = null;
let customEnv = null; let customEnv = null;
let capturedConfigContent = null; // Capture config file content before cleanup let capturedConfigContent = null; // Capture config file content before cleanup
@ -57,110 +56,12 @@ describe("runPipCommand environment variable handling", () => {
const mod = await import("./runPipCommand.js"); const mod = await import("./runPipCommand.js");
runPip = mod.runPip; runPip = mod.runPip;
shouldBypassSafeChain = mod.shouldBypassSafeChain;
}); });
afterEach(() => { afterEach(() => {
mock.reset(); mock.reset();
}); });
it("should NOT set PIP_CONFIG_FILE for 'pip config' commands to allow persistent config access", async () => {
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,
"/tmp/test-combined-ca.pem",
"REQUESTS_CA_BUNDLE should still be set"
);
assert.strictEqual(
capturedArgs.options.env.SSL_CERT_FILE,
"/tmp/test-combined-ca.pem",
"SSL_CERT_FILE should still be set"
);
assert.strictEqual(
capturedArgs.options.env.PIP_CERT,
"/tmp/test-combined-ca.pem",
"PIP_CERT should still be set"
);
});
it("should NOT set PIP_CONFIG_FILE for 'pip config get' commands", async () => {
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,
"PIP_CONFIG_FILE should NOT be set for pip config get"
);
});
it("should NOT set PIP_CONFIG_FILE for 'pip config list' commands", async () => {
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,
"PIP_CONFIG_FILE should NOT be set for pip config list"
);
});
it("should NOT set PIP_CONFIG_FILE for 'pip cache' commands", async () => {
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,
"/tmp/test-combined-ca.pem",
"SSL_CERT_FILE should still be set"
);
});
it("should NOT set PIP_CONFIG_FILE for 'pip debug' commands", async () => {
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,
"PIP_CONFIG_FILE should NOT be set for pip debug"
);
});
it("should NOT set PIP_CONFIG_FILE for 'pip completion' commands", async () => {
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,
"PIP_CONFIG_FILE should NOT be set for pip completion"
);
});
it("should set PIP_CERT env var and create config file", async () => { it("should set PIP_CERT env var and create config file", async () => {
const res = await runPip("pip3", ["install", "requests"]); const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0); assert.strictEqual(res.status, 0);
@ -183,7 +84,7 @@ describe("runPipCommand environment variable handling", () => {
assert.strictEqual(res.status, 0); assert.strictEqual(res.status, 0);
assert.ok(capturedArgs, "safeSpawn should have been called"); assert.ok(capturedArgs, "safeSpawn should have been called");
// Check environment variables are set // Check environment variables are set
assert.strictEqual( assert.strictEqual(
capturedArgs.options.env.REQUESTS_CA_BUNDLE, capturedArgs.options.env.REQUESTS_CA_BUNDLE,
@ -220,7 +121,7 @@ describe("runPipCommand environment variable handling", () => {
// For default PyPI, we still set env vars; pip CLI --cert takes precedence // For default PyPI, we still set env vars; pip CLI --cert takes precedence
const res = await runPip("pip3", ["install", "requests"]); const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0); assert.strictEqual(res.status, 0);
// Environment variables still set (pip CLI --cert takes precedence) // Environment variables still set (pip CLI --cert takes precedence)
assert.strictEqual( assert.strictEqual(
capturedArgs.options.env.REQUESTS_CA_BUNDLE, capturedArgs.options.env.REQUESTS_CA_BUNDLE,
@ -235,7 +136,7 @@ describe("runPipCommand environment variable handling", () => {
it("should preserve HTTPS_PROXY from proxy merge", async () => { it("should preserve HTTPS_PROXY from proxy merge", async () => {
const res = await runPip("pip3", ["install", "requests"]); const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0); assert.strictEqual(res.status, 0);
assert.strictEqual( assert.strictEqual(
capturedArgs.options.env.HTTPS_PROXY, capturedArgs.options.env.HTTPS_PROXY,
"http://localhost:8080", "http://localhost:8080",
@ -382,7 +283,7 @@ describe("runPipCommand environment variable handling", () => {
await fs.writeFile(cfgPath, initialIni, "utf-8"); await fs.writeFile(cfgPath, initialIni, "utf-8");
customEnv = { PIP_CONFIG_FILE: cfgPath }; customEnv = { PIP_CONFIG_FILE: cfgPath };
// Capture stdout/stderr // Capture stdout/stderr
let output = ""; let output = "";
const originalWrite = process.stdout.write; const originalWrite = process.stdout.write;
@ -399,21 +300,4 @@ describe("runPipCommand environment variable handling", () => {
assert.ok(output.includes("proxy found in PIP_CONFIG_FILE"), "Should warn about proxy overwrite in output"); assert.ok(output.includes("proxy found in PIP_CONFIG_FILE"), "Should warn about proxy overwrite in output");
customEnv = null; customEnv = null;
}); });
it("should bypass safe-chain for python correctly", async () => {
assert.strictEqual(shouldBypassSafeChain("python", []), true);
assert.strictEqual(shouldBypassSafeChain("python3", []), true);
assert.strictEqual(shouldBypassSafeChain("python", ["--version"]), true);
assert.strictEqual(shouldBypassSafeChain("python3", ["--version"]), true);
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "http.server"]), true);
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "http.server"]), true);
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip"]), false);
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip"]), false);
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip3"]), false);
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip3"]), false);
});
}); });

View file

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

View file

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

View file

@ -1,60 +0,0 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* 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) {
return reportCommandExecutionFailure(error, command);
}
}

View file

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

View file

@ -1,6 +1,6 @@
import { ui } from "../../environment/userInteraction.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/** /**
* @param {string[]} args * @param {string[]} args
@ -26,7 +26,11 @@ export async function runPnpmCommand(args, toolName = "pnpm") {
return { status: result.status }; return { status: result.status };
} catch (/** @type any */ error) { } catch (/** @type any */ error) {
const target = toolName === "pnpm" ? "pnpm" : "pnpx"; if (error.status) {
return reportCommandExecutionFailure(error, target); return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
} }
} }

View file

@ -1,72 +0,0 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createPoetryPackageManager() {
return {
runCommand: (args) => runPoetryCommand(args),
// MITM only approach for Poetry
isSupportedCommand: () => false,
getDependencyUpdatesForCommand: () => [],
};
}
/**
* Sets CA bundle environment variables used by Poetry and Python libraries.
* Poetry uses the Python requests library which respects these environment variables.
*
* @param {NodeJS.ProcessEnv} env - Environment object to modify
* @param {string} combinedCaPath - Path to the combined CA bundle
*/
function setPoetryCaBundleEnvironmentVariables(env, combinedCaPath) {
// SSL_CERT_FILE: Used by Python SSL libraries and requests
if (env.SSL_CERT_FILE) {
ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.");
}
env.SSL_CERT_FILE = combinedCaPath;
// REQUESTS_CA_BUNDLE: Used by the requests library (which Poetry uses)
if (env.REQUESTS_CA_BUNDLE) {
ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.");
}
env.REQUESTS_CA_BUNDLE = combinedCaPath;
// PIP_CERT: Poetry may use pip internally
if (env.PIP_CERT) {
ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.");
}
env.PIP_CERT = combinedCaPath;
}
/**
* Runs a poetry command with safe-chain's certificate bundle and proxy configuration.
*
* Poetry respects standard HTTP_PROXY/HTTPS_PROXY environment variables through
* the Python requests library.
*
* @param {string[]} args - Command line arguments to pass to poetry
* @returns {Promise<{status: number}>} Exit status of the poetry command
*/
async function runPoetryCommand(args) {
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
const combinedCaPath = getCombinedCaBundlePath();
setPoetryCaBundleEnvironmentVariables(env, combinedCaPath);
const result = await safeSpawn("poetry", args, {
stdio: "inherit",
env,
});
return { status: result.status };
} catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, "poetry");
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,66 +0,0 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* Sets CA bundle environment variables used by Python libraries and uv.
*
* @param {NodeJS.ProcessEnv} env - Env object
* @param {string} combinedCaPath - Path to the combined CA bundle
*/
function setUvCaBundleEnvironmentVariables(env, combinedCaPath) {
// SSL_CERT_FILE: Used by Python SSL libraries and underlying HTTP clients
if (env.SSL_CERT_FILE) {
ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.");
}
env.SSL_CERT_FILE = combinedCaPath;
// REQUESTS_CA_BUNDLE: Used by the requests library (which uv may use internally)
if (env.REQUESTS_CA_BUNDLE) {
ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.");
}
env.REQUESTS_CA_BUNDLE = combinedCaPath;
// PIP_CERT: Some underlying pip operations may respect this
if (env.PIP_CERT) {
ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.");
}
env.PIP_CERT = combinedCaPath;
}
/**
* Runs a uv command with safe-chain's certificate bundle and proxy configuration.
*
* uv respects standard environment variables for proxy and TLS configuration:
* - HTTP_PROXY / HTTPS_PROXY: Proxy settings
* - SSL_CERT_FILE / REQUESTS_CA_BUNDLE: CA bundle for TLS verification
*
* Unlike pip (which requires a temporary config file for cert configuration), uv directly
* honors environment variables, so no config/ini file is needed.
*
* @param {string} command - The uv command to execute (typically 'uv')
* @param {string[]} args - Command line arguments to pass to uv
* @returns {Promise<{status: number}>} Exit status of the uv command
*/
export async function runUv(command, args) {
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
const combinedCaPath = getCombinedCaBundlePath();
setUvCaBundleEnvironmentVariables(env, combinedCaPath);
// Note: uv 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,
});
return { status: result.status };
} catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, command);
}
}

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/** /**
* @param {string[]} args * @param {string[]} args
@ -18,7 +18,12 @@ export async function runYarnCommand(args) {
}); });
return { status: result.status }; return { status: result.status };
} catch (/** @type any */ error) { } catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, "yarn"); if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
} }
} }

View file

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

View file

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

View file

@ -1,25 +1,15 @@
import forge from "node-forge"; import forge from "node-forge";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import { getCertsDir } from "../config/safeChainDir.js"; import os from "os";
const certFolder = path.join(os.homedir(), ".safe-chain", "certs");
const ca = loadCa(); const ca = loadCa();
const certCache = new Map(); const certCache = new Map();
/**
* @param {forge.pki.PublicKey} publicKey
* @returns {string}
*/
function createKeyIdentifier(publicKey) {
return forge.pki.getPublicKeyFingerprint(publicKey, {
encoding: "binary",
md: forge.md.sha1.create(),
});
}
export function getCaCertPath() { export function getCaCertPath() {
return path.join(getCertsDir(), "ca-cert.pem"); return path.join(certFolder, "ca-cert.pem");
} }
/** /**
@ -43,7 +33,6 @@ export function generateCertForHost(hostname) {
const attrs = [{ name: "commonName", value: hostname }]; const attrs = [{ name: "commonName", value: hostname }];
cert.setSubject(attrs); cert.setSubject(attrs);
cert.setIssuer(ca.certificate.subject.attributes); cert.setIssuer(ca.certificate.subject.attributes);
const authorityKeyIdentifier = createKeyIdentifier(ca.certificate.publicKey);
cert.setExtensions([ cert.setExtensions([
{ {
name: "subjectAltName", name: "subjectAltName",
@ -61,42 +50,14 @@ export function generateCertForHost(hostname) {
}, },
{ {
/* /*
Extended Key Usage (EKU) serverAuth extension extKeyUsage serverAuth is required for TLS server authentication.
This is especially important for Python venv environments, which use their own
Needed for TLS server authentication. This extension indicates the certificate's certificate validation logic and will reject certificates lacking the serverAuth EKU.
public key may be used for TLS WWW server authentication. Adding serverAuth does not impact other usages
Python virtualenv environments (like pipx-installed Poetry) enforce this strictly
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.12
*/ */
name: "extKeyUsage", name: "extKeyUsage",
serverAuth: true, serverAuth: true,
}, },
{
/*
Subject Key Identifier (SKI)
Needed for Python virtualenv SSL validation and certificate chain building.
This extension provides a means of identifying certificates containing a particular public key.
Python virtualenv environments require this for proper certificate chain validation.
System Python installations may be more lenient.
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.2
*/
name: "subjectKeyIdentifier",
subjectKeyIdentifier: createKeyIdentifier(cert.publicKey),
},
{
/*
Authority Key Identifier (AKI)
Needed for Python virtualenv SSL validation and certificate path validation.
This extension identifies the public key corresponding to the private key used to sign
this certificate. It links this certificate to its issuing CA certificate.
Without this, Python virtualenv certificate validation might fail (for instance for Poetry)
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.1
*/
name: "authorityKeyIdentifier",
keyIdentifier: authorityKeyIdentifier,
},
]); ]);
cert.sign(ca.privateKey, forge.md.sha256.create()); cert.sign(ca.privateKey, forge.md.sha256.create());
@ -111,7 +72,6 @@ export function generateCertForHost(hostname) {
} }
function loadCa() { function loadCa() {
const certFolder = getCertsDir();
const keyPath = path.join(certFolder, "ca-key.pem"); const keyPath = path.join(certFolder, "ca-key.pem");
const certPath = path.join(certFolder, "ca-cert.pem"); const certPath = path.join(certFolder, "ca-cert.pem");
@ -146,13 +106,11 @@ function generateCa() {
const attrs = [{ name: "commonName", value: "safe-chain proxy" }]; const attrs = [{ name: "commonName", value: "safe-chain proxy" }];
cert.setSubject(attrs); cert.setSubject(attrs);
cert.setIssuer(attrs); // Self-signed: issuer === subject cert.setIssuer(attrs);
const keyIdentifier = createKeyIdentifier(cert.publicKey);
cert.setExtensions([ cert.setExtensions([
{ {
name: "basicConstraints", name: "basicConstraints",
cA: true, cA: true,
critical: true, // Marking basicConstraints as critical is required for CA certificates so clients must process it to trust the cert as a CA
}, },
{ {
name: "keyUsage", name: "keyUsage",
@ -160,14 +118,6 @@ function generateCa() {
digitalSignature: true, digitalSignature: true,
keyEncipherment: true, keyEncipherment: true,
}, },
{
name: "subjectKeyIdentifier",
subjectKeyIdentifier: keyIdentifier,
},
{
name: "authorityKeyIdentifier",
keyIdentifier,
},
]); ]);
cert.sign(keys.privateKey, forge.md.sha256.create()); cert.sign(keys.privateKey, forge.md.sha256.create());

View file

@ -1,71 +0,0 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("certUtils", () => {
let installedSafeChainDir;
beforeEach(() => {
installedSafeChainDir = undefined;
mock.module("../config/safeChainDir.js", {
namedExports: {
getSafeChainBaseDir: () => installedSafeChainDir ?? "/home/test/.safe-chain",
getCertsDir: () => `${installedSafeChainDir ?? "/home/test/.safe-chain"}/certs`,
},
});
});
afterEach(() => {
mock.reset();
});
it("stores CA certificates in the packaged install dir when available", async () => {
installedSafeChainDir = "/custom/safe-chain";
mock.module("fs", {
defaultExport: {
existsSync: () => false,
mkdirSync: () => {},
writeFileSync: () => {},
},
});
mock.module("node-forge", {
defaultExport: {
pki: {
getPublicKeyFingerprint: () => "fingerprint",
rsa: {
generateKeyPair: () => ({
publicKey: "public-key",
privateKey: "private-key",
}),
},
createCertificate: () => ({
publicKey: null,
serialNumber: "",
validity: {
notBefore: new Date(),
notAfter: new Date(),
},
setSubject: () => {},
setIssuer: () => {},
setExtensions: () => {},
sign: () => {},
}),
privateKeyToPem: () => "private-key-pem",
certificateToPem: () => "certificate-pem",
},
md: {
sha1: { create: () => "sha1" },
sha256: { create: () => "sha256" },
},
},
});
const { getCaCertPath } = await import("./certUtils.js");
assert.strictEqual(
getCaCertPath(),
"/custom/safe-chain/certs/ca-cert.pem",
);
});
});

View file

@ -1,13 +0,0 @@
import { isImdsEndpoint } from "./isImdsEndpoint.js";
/**
* Returns appropriate connection timeout for a host.
* - IMDS endpoints: 3s (fail fast when outside cloud, reduce 5min delay to ~20s)
* - Other endpoints: 30s (allow for slow networks while preventing indefinite hangs)
*/
export function getConnectTimeout(/** @type {string} */ host) {
if (isImdsEndpoint(host)) {
return 3000;
}
return 30000;
}

View file

@ -15,66 +15,3 @@ export function getHeaderValueAsString(headers, headerName) {
return header; return header;
} }
/**
* Returns a copy of headers without the provided header names, matched
* either exactly or case-insensitively.
*
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @param {string[]} headerNames
* @param {{ caseInsensitive?: boolean }} [options]
* @returns {NodeJS.Dict<string | string[]> | undefined}
*/
export function omitHeaders(headers, headerNames, options = {}) {
if (!headers) {
return headers;
}
const omittedHeaderNames = new Set(
options.caseInsensitive
? headerNames.map((name) => name.toLowerCase())
: headerNames
);
/** @type {NodeJS.Dict<string | string[]>} */
const filteredHeaders = {};
for (const [headerName, value] of Object.entries(headers)) {
const comparableHeaderName = options.caseInsensitive
? headerName.toLowerCase()
: headerName;
if (!omittedHeaderNames.has(comparableHeaderName)) {
filteredHeaders[headerName] = value;
}
}
return filteredHeaders;
}
/**
* Remove headers that become stale when the response body is modified.
*
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {void}
*/
export function clearCachingHeaders(headers) {
if (!headers) {
return;
}
const filteredHeaders = omitHeaders(headers, [
"etag",
"last-modified",
"cache-control",
"content-length",
]);
if (!filteredHeaders) {
return;
}
for (const key of Object.keys(headers)) {
delete headers[key];
}
Object.assign(headers, filteredHeaders);
}

View file

@ -4,7 +4,7 @@ import {
getEcoSystem, getEcoSystem,
} from "../../config/settings.js"; } from "../../config/settings.js";
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js"; import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
import { pipInterceptorForUrl } from "./pip/pipInterceptor.js"; import { pipInterceptorForUrl } from "./pipInterceptor.js";
/** /**
* @param {string} url * @param {string} url

Some files were not shown because too many files have changed in this diff Show more