mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Compare commits
No commits in common. "main" and "0.0.6-release-script-beta" have entirely different histories.
main
...
0.0.6-rele
156 changed files with 1085 additions and 10930 deletions
151
.github/workflows/build-and-release.yml
vendored
151
.github/workflows/build-and-release.yml
vendored
|
|
@ -4,8 +4,6 @@ on:
|
|||
push:
|
||||
tags:
|
||||
- "*"
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
|
|
@ -13,9 +11,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
set-version:
|
||||
name: Set version number
|
||||
if: github.event_name == 'push'
|
||||
runs-on: open-source-releaser
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.tag }}
|
||||
steps:
|
||||
|
|
@ -26,21 +22,52 @@ jobs:
|
|||
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'
|
||||
build:
|
||||
needs: [set-version, create-binaries]
|
||||
runs-on: open-source-releaser
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org/"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
||||
|
||||
- name: Setup safe-chain
|
||||
run: |
|
||||
npm i -g @aikidosec/safe-chain
|
||||
safe-chain setup-ci
|
||||
|
||||
- name: Set the version in safe-chain package
|
||||
run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Copy documentation files to package
|
||||
run: |
|
||||
cp README.md packages/safe-chain/
|
||||
cp LICENSE packages/safe-chain/
|
||||
cp -r docs packages/safe-chain/
|
||||
|
||||
# - name: Publish to npm
|
||||
# run: |
|
||||
# echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM"
|
||||
# npm publish --workspace=packages/safe-chain --access public --provenance
|
||||
|
||||
- name: Download all binary artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
|
|
@ -55,122 +82,30 @@ jobs:
|
|||
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
|
||||
- name: Move install scripts and hard-code version
|
||||
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
|
||||
|
||||
sed "s/\$(fetch_latest_version)/${VERSION}/" install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh
|
||||
sed "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1
|
||||
cp install-scripts/uninstall-safe-chain.sh release-artifacts/uninstall-safe-chain.sh
|
||||
cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1
|
||||
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
|
||||
- name: Upload binaries to existing GitHub Release
|
||||
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 \
|
||||
gh release upload ${{ needs.set-version.outputs.version }} \
|
||||
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
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org/"
|
||||
|
||||
- name: Setup safe-chain
|
||||
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
|
||||
- name: Set the version in safe-chain package
|
||||
run: npm --no-git-tag-version version ${{ github.event.release.tag_name }} --workspace=packages/safe-chain
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Copy documentation files to package
|
||||
run: |
|
||||
cp README.md packages/safe-chain/
|
||||
cp LICENSE packages/safe-chain/
|
||||
cp -r docs packages/safe-chain/
|
||||
cp npm-shrinkwrap.json packages/safe-chain/
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
echo "Publishing version $VERSION to NPM"
|
||||
if [[ "$VERSION" == *"-"* ]]; then
|
||||
PRERELEASE_TAG=$(echo "$VERSION" | sed 's/.*-\([^-]*\)$/\1/')
|
||||
npm publish --workspace=packages/safe-chain --access public --provenance --tag "$PRERELEASE_TAG"
|
||||
else
|
||||
npm publish --workspace=packages/safe-chain --access public --provenance
|
||||
fi
|
||||
release-artifacts/uninstall-safe-chain.ps1
|
||||
|
|
|
|||
82
.github/workflows/bump-endpoint.yml
vendored
82
.github/workflows/bump-endpoint.yml
vendored
|
|
@ -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}\"}"
|
||||
20
.github/workflows/create-artifact.yml
vendored
20
.github/workflows/create-artifact.yml
vendored
|
|
@ -39,16 +39,6 @@ jobs:
|
|||
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
|
||||
|
|
@ -70,18 +60,16 @@ jobs:
|
|||
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
|
||||
run: |
|
||||
npm i -g @aikidosec/safe-chain
|
||||
safe-chain setup-ci
|
||||
|
||||
- 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
|
||||
run: npm --no-git-tag-version version ${{ inputs.version }} --workspace=packages/safe-chain --ignore-scripts
|
||||
|
||||
- name: Create binary
|
||||
run: |
|
||||
|
|
|
|||
18
.github/workflows/test-on-pr.yml
vendored
18
.github/workflows/test-on-pr.yml
vendored
|
|
@ -23,8 +23,9 @@ jobs:
|
|||
node-version: "lts/*"
|
||||
|
||||
- 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
|
||||
run: |
|
||||
npm i -g @aikidosec/safe-chain
|
||||
safe-chain setup-ci
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
|
@ -77,7 +78,7 @@ jobs:
|
|||
- node_version: "20"
|
||||
npm_version: "9.0.0"
|
||||
yarn_version: "latest"
|
||||
pnpm_version: "10.0.0"
|
||||
pnpm_version: "latest"
|
||||
# Version pinning scenario
|
||||
- node_version: "22"
|
||||
npm_version: "10.2.0"
|
||||
|
|
@ -87,12 +88,17 @@ jobs:
|
|||
- node_version: "18"
|
||||
npm_version: "latest"
|
||||
yarn_version: "latest"
|
||||
pnpm_version: "10.0.0"
|
||||
pnpm_version: "latest"
|
||||
# Future compatibility (becomes LTS October 2025)
|
||||
- node_version: "24"
|
||||
npm_version: "latest"
|
||||
yarn_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:
|
||||
- name: Checkout code
|
||||
|
|
@ -104,7 +110,9 @@ jobs:
|
|||
node-version: "lts/*"
|
||||
|
||||
- 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)
|
||||
run: npm ci
|
||||
|
|
|
|||
394
README.md
394
README.md
|
|
@ -7,17 +7,9 @@
|
|||
|
||||
- ✅ **Block malware on developer laptops and CI/CD**
|
||||
- ✅ **Supports npm and PyPI** more package managers coming
|
||||
- ✅ **Blocks packages newer than 48 hours** without breaking your build
|
||||
- ✅ **Blocks packages newer than 24 hours** without breaking your build
|
||||
- ✅ **Tokenless, free, no build data shared**
|
||||
|
||||
## Need protection beyond npm & PyPI?
|
||||
|
||||
[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection?utm_source=github.com&utm_medium=referral&utm_campaign=safechain) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more.
|
||||
|
||||
Get centralized policy management, request-and-approval workflows, and visibility across every developer workstation in your org. Powered by the same Aikido Intel feed. Deploy it manually or manage it through your MDM tool (Jamf, Fleet, or Iru).
|
||||
|
||||
---
|
||||
|
||||
Aikido Safe Chain supports the following package managers:
|
||||
|
||||
- 📦 **npm**
|
||||
|
|
@ -25,75 +17,40 @@ Aikido Safe Chain supports the following package managers:
|
|||
- 📦 **yarn**
|
||||
- 📦 **pnpm**
|
||||
- 📦 **pnpx**
|
||||
- 📦 **rush**
|
||||
- 📦 **rushx**
|
||||
- 📦 **bun**
|
||||
- 📦 **bunx**
|
||||
- 📦 **pip**
|
||||
- 📦 **pip3**
|
||||
- 📦 **uv**
|
||||
- 📦 **poetry**
|
||||
- 📦 **uvx**
|
||||
- 📦 **pipx**
|
||||
- 📦 **pdm**
|
||||
|
||||
# Usage
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
Installing the Aikido Safe Chain is easy with our one-line installer.
|
||||
|
||||
> ⚠️ **Already installed via npm?** See the [migration guide](https://github.com/AikidoSec/safe-chain/blob/main/docs/npm-to-binary-migration.md) to switch to the binary version.
|
||||
|
||||
### Unix/Linux/macOS
|
||||
|
||||
```shell
|
||||
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1" -UseBasicParsing)
|
||||
iex (iwr "https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/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:
|
||||
- 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.
|
||||
|
||||
```shell
|
||||
npm safe-chain-verify
|
||||
pnpm safe-chain-verify
|
||||
pip safe-chain-verify
|
||||
uv safe-chain-verify
|
||||
|
||||
# Any other supported package manager: {packagemanager} safe-chain-verify
|
||||
```
|
||||
|
||||
- The output should display "OK: Safe-chain works!" confirming that Aikido Safe Chain is properly installed and running.
|
||||
|
||||
3. **(Optional) Test malware blocking** by attempting to install a test package:
|
||||
2. **Verify the installation** by running one of the following commands:
|
||||
|
||||
For JavaScript/Node.js:
|
||||
|
||||
|
|
@ -109,7 +66,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
|
|||
|
||||
- The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware.
|
||||
|
||||
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `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`, `uv`, `pip`, `pip3` or `poetry` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
|
||||
|
||||
You can check the installed version by running:
|
||||
|
||||
|
|
@ -121,26 +78,17 @@ safe-chain --version
|
|||
|
||||
### Malware Blocking
|
||||
|
||||
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, 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, uv, pip, pip3 or poetry commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
|
||||
|
||||
### 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 (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below.
|
||||
|
||||
Current enforcement differs by ecosystem:
|
||||
|
||||
- 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.
|
||||
⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3, poetry).
|
||||
|
||||
### 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 Python package managers (uv, pip). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
|
||||
|
||||
- ✅ **Bash**
|
||||
- ✅ **Zsh**
|
||||
|
|
@ -157,13 +105,13 @@ To uninstall the Aikido Safe Chain, use our one-line uninstaller:
|
|||
### Unix/Linux/macOS
|
||||
|
||||
```shell
|
||||
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/uninstall-safe-chain.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/uninstall-safe-chain.sh | sh
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/uninstall-safe-chain.ps1" -UseBasicParsing)
|
||||
iex (iwr "https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/uninstall-safe-chain.ps1" -UseBasicParsing)
|
||||
```
|
||||
|
||||
**❗Restart your terminal** after uninstalling to ensure all aliases are removed.
|
||||
|
|
@ -172,49 +120,27 @@ iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/unins
|
|||
|
||||
## 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):
|
||||
|
||||
1. **CLI Argument** (highest priority):
|
||||
- `--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.
|
||||
Example usage:
|
||||
|
||||
```shell
|
||||
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.
|
||||
- `--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
|
||||
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.
|
||||
You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 24 hours old before they can be installed through npm-based package managers.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
|
|
@ -233,7 +159,7 @@ You can set the minimum package age through multiple sources (in order of priori
|
|||
npm install express
|
||||
```
|
||||
|
||||
3. **Config File** (`~/.safe-chain/config.json`):
|
||||
3. **Config File** (`~/.aikido/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -241,117 +167,6 @@ You can set the minimum package age through multiple sources (in order of priori
|
|||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
|
@ -363,23 +178,19 @@ Use the `--ci` flag to automatically configure Aikido Safe Chain for CI/CD envir
|
|||
### Unix/Linux/macOS (GitHub Actions, Azure Pipelines, etc.)
|
||||
|
||||
```shell
|
||||
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci
|
||||
```
|
||||
|
||||
### Windows (Azure Pipelines, etc.)
|
||||
|
||||
```powershell
|
||||
iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci"
|
||||
iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -ci"
|
||||
```
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
- ✅ **GitHub Actions**
|
||||
- ✅ **Azure Pipelines**
|
||||
- ✅ **CircleCI**
|
||||
- ✅ **Jenkins**
|
||||
- ✅ **Bitbucket Pipelines**
|
||||
- ✅ **GitLab Pipelines**
|
||||
|
||||
## GitHub Actions Example
|
||||
|
||||
|
|
@ -391,7 +202,7 @@ iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download
|
|||
cache: "npm"
|
||||
|
||||
- name: Install safe-chain
|
||||
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
|
@ -405,162 +216,11 @@ iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download
|
|||
versionSpec: "22.x"
|
||||
displayName: "Install Node.js"
|
||||
|
||||
- script: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
- script: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci
|
||||
displayName: "Install safe-chain"
|
||||
|
||||
- script: npm ci
|
||||
displayName: "Install dependencies"
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## 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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
89
docs/npm-to-binary-migration.md
Normal file
89
docs/npm-to-binary-migration.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# Migrating from npm global tool to binary installation
|
||||
|
||||
If you previously installed safe-chain as an npm global package, you need to migrate to the binary installation.
|
||||
|
||||
Depending on the version manager you're using, the uninstall process differs:
|
||||
|
||||
### Standard npm (no version manager)
|
||||
|
||||
1. **Clean up shell aliases:**
|
||||
|
||||
```bash
|
||||
safe-chain teardown
|
||||
```
|
||||
|
||||
2. **Restart your terminal**
|
||||
|
||||
3. **Uninstall the npm package:**
|
||||
|
||||
```bash
|
||||
npm uninstall -g @aikidosec/safe-chain
|
||||
```
|
||||
|
||||
4. **Install the binary version** (see [Installation](https://github.com/AikidoSec/safe-chain/blob/main/README.md#installation))
|
||||
|
||||
### nvm (Node Version Manager)
|
||||
|
||||
**Important:** nvm installs global packages separately for each Node version, so safe-chain must be uninstalled from each version where it was installed.
|
||||
|
||||
1. **Clean up shell aliases:**
|
||||
|
||||
```bash
|
||||
safe-chain teardown
|
||||
```
|
||||
|
||||
2. **Restart your terminal**
|
||||
|
||||
3. **Uninstall from all Node versions:**
|
||||
|
||||
**Option A** - Automated script (recommended):
|
||||
|
||||
```bash
|
||||
for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do nvm use $version && npm uninstall -g @aikidosec/safe-chain; done
|
||||
```
|
||||
|
||||
**Option B** - Manual per version:
|
||||
|
||||
```bash
|
||||
nvm use <version>
|
||||
npm uninstall -g @aikidosec/safe-chain
|
||||
```
|
||||
|
||||
Repeat for each Node version where safe-chain was installed.
|
||||
|
||||
4. **Install the binary version** (see [Installation](https://github.com/AikidoSec/safe-chain/blob/main/README.md#installation))
|
||||
|
||||
### Volta
|
||||
|
||||
1. **Clean up shell aliases:**
|
||||
|
||||
```bash
|
||||
safe-chain teardown
|
||||
```
|
||||
|
||||
2. **Restart your terminal**
|
||||
|
||||
3. **Uninstall the Volta package:**
|
||||
|
||||
```bash
|
||||
volta uninstall @aikidosec/safe-chain
|
||||
```
|
||||
|
||||
4. **Install the binary version** (see [Installation](https://github.com/AikidoSec/safe-chain/blob/main/README.md#installation))
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Shell aliases still present after migration
|
||||
|
||||
1. Run `safe-chain teardown` (if the binary is installed)
|
||||
2. Manually remove any safe-chain entries from your shell config files:
|
||||
- Bash: `~/.bashrc`
|
||||
- Zsh: `~/.zshrc`
|
||||
- Fish: `~/.config/fish/config.fish`
|
||||
- PowerShell: `$PROFILE`
|
||||
3. Restart your terminal
|
||||
4. Re-run the install script
|
||||
|
||||
### "command not found: safe-chain" after migration
|
||||
|
||||
The binary installation directory (`~/.safe-chain/bin`) may not be in your PATH. Restart your terminal. If the problem persists: re-run the installation of safe-chain.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Overview
|
||||
|
||||
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `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
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ This command:
|
|||
|
||||
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
|
||||
- Detects all supported shells on your system
|
||||
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `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
|
||||
|
||||
❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
|
||||
|
|
@ -78,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts:
|
|||
This means the shell functions are working but the Aikido commands aren't installed or available in your PATH:
|
||||
|
||||
- Make sure Aikido Safe Chain is properly installed on your system
|
||||
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-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
|
||||
|
||||
### 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -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: $_"
|
||||
}
|
||||
|
|
@ -3,69 +3,13 @@
|
|||
# Usage with "iex (iwr {url} -UseBasicParsing)" --> See README.md
|
||||
|
||||
param(
|
||||
[switch]$ci,
|
||||
[switch]$includepython,
|
||||
[string]$InstallDir
|
||||
[switch]$ci
|
||||
)
|
||||
|
||||
# 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"
|
||||
$InstallDir = Join-Path $env:USERPROFILE ".safe-chain\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
|
||||
|
||||
|
|
@ -86,46 +30,6 @@ function Write-Error-Custom {
|
|||
exit 1
|
||||
}
|
||||
|
||||
# Get currently installed version of safe-chain
|
||||
function Get-InstalledVersion {
|
||||
# Check if safe-chain command exists
|
||||
if (-not (Get-Command safe-chain -ErrorAction SilentlyContinue)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
# Execute safe-chain -v and capture output
|
||||
$output = & safe-chain -v 2>&1
|
||||
|
||||
# Extract version from "Current safe-chain version: X.Y.Z" output
|
||||
if ($output -match "Current safe-chain version:\s*(.+)") {
|
||||
return $matches[1].Trim()
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Check if the requested version is already installed
|
||||
function Test-VersionInstalled {
|
||||
param([string]$RequestedVersion)
|
||||
|
||||
$installedVersion = Get-InstalledVersion
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($installedVersion)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
# Strip leading 'v' from versions if present for comparison
|
||||
$requestedClean = $RequestedVersion -replace '^v', ''
|
||||
$installedClean = $installedVersion -replace '^v', ''
|
||||
|
||||
return $requestedClean -eq $installedClean
|
||||
}
|
||||
|
||||
# Fetch latest release version tag from GitHub
|
||||
function Get-LatestVersion {
|
||||
try {
|
||||
|
|
@ -153,91 +57,6 @@ function Get-Architecture {
|
|||
}
|
||||
}
|
||||
|
||||
# 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
|
||||
|
|
@ -289,28 +108,17 @@ function Remove-VoltaInstallation {
|
|||
|
||||
# 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
|
||||
|
||||
|
|
@ -320,7 +128,7 @@ function Install-SafeChain {
|
|||
|
||||
# Detect platform
|
||||
$arch = Get-Architecture
|
||||
$binaryName = Get-BinaryName -Architecture $arch
|
||||
$binaryName = "safe-chain-win-$arch.exe"
|
||||
|
||||
Write-Info "Detected architecture: $arch"
|
||||
|
||||
|
|
@ -351,9 +159,6 @@ function Install-SafeChain {
|
|||
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 {
|
||||
|
|
@ -369,7 +174,31 @@ function Install-SafeChain {
|
|||
|
||||
Write-Info "Binary installed to: $finalFile"
|
||||
|
||||
Invoke-SafeChainSetup -BinaryPath $finalFile -InstallDirectory $InstallDir
|
||||
# Build setup command based on parameters
|
||||
$setupCmd = if ($ci) { "setup-ci" } else { "setup" }
|
||||
$setupArgs = @()
|
||||
|
||||
# Execute safe-chain setup
|
||||
Write-Info "Running safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })..."
|
||||
try {
|
||||
$env:Path = "$env:Path;$InstallDir"
|
||||
|
||||
if ($setupArgs) {
|
||||
& $finalFile $setupCmd $setupArgs
|
||||
}
|
||||
else {
|
||||
& $finalFile $setupCmd
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warn "safe-chain was installed but setup encountered issues."
|
||||
Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warn "safe-chain was installed but setup encountered issues: $_"
|
||||
Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later."
|
||||
}
|
||||
}
|
||||
|
||||
# Run installation
|
||||
|
|
|
|||
|
|
@ -6,67 +6,11 @@
|
|||
|
||||
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"
|
||||
INSTALL_DIR="${HOME}/.safe-chain/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'
|
||||
|
|
@ -88,18 +32,10 @@ error() {
|
|||
}
|
||||
|
||||
# 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
|
||||
;;
|
||||
Linux*) echo "linux" ;;
|
||||
Darwin*) echo "macos" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) echo "win" ;;
|
||||
*) error "Unsupported operating system: $(uname -s)" ;;
|
||||
esac
|
||||
}
|
||||
|
|
@ -118,38 +54,6 @@ 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
|
||||
|
|
@ -168,57 +72,6 @@ fetch_latest_version() {
|
|||
echo "$latest_version"
|
||||
}
|
||||
|
||||
# Returns the expected SHA256 for the detected platform, or empty if the
|
||||
# release pipeline has not baked one in (i.e. running the source from main).
|
||||
get_expected_sha256() {
|
||||
os="$1"; arch="$2"
|
||||
case "${os}-${arch}" in
|
||||
macos-x64) echo "$SHA256_MACOS_X64" ;;
|
||||
macos-arm64) echo "$SHA256_MACOS_ARM64" ;;
|
||||
linux-x64) echo "$SHA256_LINUX_X64" ;;
|
||||
linux-arm64) echo "$SHA256_LINUX_ARM64" ;;
|
||||
linuxstatic-x64) echo "$SHA256_LINUXSTATIC_X64" ;;
|
||||
linuxstatic-arm64) echo "$SHA256_LINUXSTATIC_ARM64" ;;
|
||||
win-x64) echo "$SHA256_WIN_X64" ;;
|
||||
win-arm64) echo "$SHA256_WIN_ARM64" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
compute_sha256() {
|
||||
file="$1"
|
||||
if command_exists sha256sum; then
|
||||
sha256sum "$file" | awk '{print $1}'
|
||||
elif command_exists shasum; then
|
||||
shasum -a 256 "$file" | awk '{print $1}'
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Verifies the downloaded binary against the expected hash baked in by the release pipeline.
|
||||
# No-op when no expected hash is set (running the script from main).
|
||||
verify_checksum() {
|
||||
file="$1"; expected="$2"
|
||||
|
||||
if [ -z "$expected" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
actual=$(compute_sha256 "$file")
|
||||
if [ -z "$actual" ]; then
|
||||
rm -f "$file"
|
||||
error "Cannot verify checksum: neither sha256sum nor shasum is available. Install one and re-run."
|
||||
fi
|
||||
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
rm -f "$file"
|
||||
error "Checksum verification failed. Expected: $expected, Got: $actual"
|
||||
fi
|
||||
|
||||
info "Checksum verified."
|
||||
}
|
||||
|
||||
# Download file
|
||||
download() {
|
||||
url="$1"
|
||||
|
|
@ -233,75 +86,6 @@ download() {
|
|||
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
|
||||
|
|
@ -343,101 +127,18 @@ remove_volta_installation() {
|
|||
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
|
||||
for arg in "$@"; do
|
||||
case "$arg" 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"
|
||||
error "Unknown argument: $arg"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
validate_install_dir "${SAFE_CHAIN_BASE}"
|
||||
INSTALL_DIR="${SAFE_CHAIN_BASE}/bin"
|
||||
}
|
||||
|
||||
# Main installation
|
||||
|
|
@ -448,14 +149,10 @@ main() {
|
|||
# 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
|
||||
# Fetch latest version if VERSION is not set
|
||||
if [ -z "$VERSION" ]; then
|
||||
info "Fetching latest release version..."
|
||||
VERSION=$(fetch_latest_version)
|
||||
fi
|
||||
|
||||
# Build installation message
|
||||
|
|
@ -466,15 +163,14 @@ main() {
|
|||
|
||||
info "$INSTALL_MSG"
|
||||
|
||||
# Check for existing safe-chain installation through nvm, volta, or npm
|
||||
# Check for existing safe-chain installation through npm or volta
|
||||
remove_npm_installation
|
||||
remove_volta_installation
|
||||
remove_nvm_installation
|
||||
|
||||
# Detect platform
|
||||
OS=$(detect_os)
|
||||
ARCH=$(detect_arch)
|
||||
BINARY_NAME=$(get_binary_name "$OS" "$ARCH")
|
||||
BINARY_NAME="safe-chain-${OS}-${ARCH}"
|
||||
|
||||
info "Detected platform: ${OS}-${ARCH}"
|
||||
|
||||
|
|
@ -491,19 +187,27 @@ main() {
|
|||
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")
|
||||
FINAL_FILE="${INSTALL_DIR}/safe-chain"
|
||||
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"
|
||||
# Build setup command based on arguments
|
||||
SETUP_CMD="setup"
|
||||
SETUP_ARGS=""
|
||||
|
||||
if [ "$USE_CI_SETUP" = "true" ]; then
|
||||
SETUP_CMD="setup-ci"
|
||||
fi
|
||||
|
||||
# Execute safe-chain setup
|
||||
info "Running safe-chain $SETUP_CMD $SETUP_ARGS..."
|
||||
if ! "$FINAL_FILE" $SETUP_CMD $SETUP_ARGS; then
|
||||
warn "safe-chain was installed but setup encountered issues."
|
||||
warn "You can run 'safe-chain $SETUP_CMD $SETUP_ARGS' manually later."
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
@ -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: $_"
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
# Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform)
|
||||
$HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
|
||||
$InstallDir = Join-Path $HomeDir ".safe-chain/bin"
|
||||
|
||||
# Helper functions
|
||||
function Write-Info {
|
||||
|
|
@ -22,146 +23,6 @@ function Write-Error-Custom {
|
|||
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
|
||||
|
|
@ -214,27 +75,82 @@ function Remove-VoltaInstallation {
|
|||
# Main uninstallation
|
||||
function Uninstall-SafeChain {
|
||||
Write-Info "Uninstalling safe-chain..."
|
||||
$DotSafeChain = Get-SafeChainInstallDir
|
||||
$safeChainPath = Find-SafeChainBinary -DotSafeChain $DotSafeChain
|
||||
Invoke-SafeChainTeardown -SafeChainPath $safeChainPath
|
||||
|
||||
# Run teardown if safe-chain is available
|
||||
# Check for both safe-chain.exe (Windows) and safe-chain (Unix) since PowerShell Core runs on all platforms
|
||||
$safeChainExe = Join-Path $InstallDir "safe-chain.exe"
|
||||
$safeChainBin = Join-Path $InstallDir "safe-chain"
|
||||
|
||||
$safeChainPath = $null
|
||||
if (Test-Path $safeChainExe) {
|
||||
$safeChainPath = $safeChainExe
|
||||
}
|
||||
elseif (Test-Path $safeChainBin) {
|
||||
$safeChainPath = $safeChainBin
|
||||
}
|
||||
|
||||
if ($safeChainPath) {
|
||||
Write-Info "Running safe-chain teardown..."
|
||||
try {
|
||||
& $safeChainPath teardown
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warn "safe-chain teardown encountered issues: $_"
|
||||
Write-Warn "Continuing with uninstallation..."
|
||||
}
|
||||
}
|
||||
elseif (Get-Command safe-chain -ErrorAction SilentlyContinue) {
|
||||
Write-Info "Running safe-chain teardown..."
|
||||
try {
|
||||
safe-chain teardown
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warn "safe-chain teardown encountered issues: $_"
|
||||
Write-Warn "Continuing with uninstallation..."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warn "safe-chain command not found. Proceeding with uninstallation."
|
||||
}
|
||||
|
||||
# Remove npm and Volta installations
|
||||
Remove-NpmInstallation
|
||||
Remove-VoltaInstallation
|
||||
|
||||
# Remove .safe-chain directory
|
||||
if (Test-Path $DotSafeChain) {
|
||||
Write-Info "Removing installation directory: $DotSafeChain"
|
||||
# Remove installation directory
|
||||
if (Test-Path $InstallDir) {
|
||||
Write-Info "Removing installation directory: $InstallDir"
|
||||
try {
|
||||
Remove-Item -Path $DotSafeChain -Recurse -Force
|
||||
Remove-Item -Path $InstallDir -Recurse -Force
|
||||
Write-Info "Successfully removed installation directory"
|
||||
}
|
||||
catch {
|
||||
Write-Error-Custom "Failed to remove $DotSafeChain : $_"
|
||||
Write-Error-Custom "Failed to remove $InstallDir : $_"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Info "Installation directory $DotSafeChain does not exist. Nothing to remove."
|
||||
Write-Info "Installation directory $InstallDir does not exist. Nothing to remove."
|
||||
}
|
||||
|
||||
# Also try to remove the parent .safe-chain directory if it's empty
|
||||
$parentDir = Split-Path $InstallDir -Parent
|
||||
if (Test-Path $parentDir) {
|
||||
$items = Get-ChildItem -Path $parentDir -Force
|
||||
if ($items.Count -eq 0) {
|
||||
Write-Info "Removing empty parent directory: $parentDir"
|
||||
try {
|
||||
Remove-Item -Path $parentDir -Force
|
||||
}
|
||||
catch {
|
||||
Write-Warn "Could not remove empty parent directory: $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Info "safe-chain has been uninstalled successfully!"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
set -e # Exit on error
|
||||
|
||||
# Configuration
|
||||
INSTALL_DIR="${HOME}/.safe-chain/bin"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
|
|
@ -33,159 +34,6 @@ 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
|
||||
|
|
@ -227,85 +75,29 @@ remove_volta_installation() {
|
|||
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"
|
||||
SAFE_CHAIN_LOCATION="$INSTALL_DIR/safe-chain"
|
||||
|
||||
if [ -x "$SAFE_CHAIN_LOCATION" ]; then
|
||||
info "Running safe-chain teardown..."
|
||||
"$SAFE_CHAIN_LOCATION" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
||||
elif command_exists safe-chain; then
|
||||
info "Running safe-chain teardown..."
|
||||
safe-chain teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
||||
else
|
||||
warn "safe-chain command not found. Proceeding with uninstallation."
|
||||
fi
|
||||
|
||||
# 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"
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
info "Removing installation directory $INSTALL_DIR"
|
||||
rm -rf "$INSTALL_DIR" || error "Failed to remove $INSTALL_DIR"
|
||||
else
|
||||
info "Installation directory $DOT_SAFE_CHAIN does not exist. Nothing to remove."
|
||||
info "Installation directory $INSTALL_DIR does not exist. Nothing to remove."
|
||||
fi
|
||||
}
|
||||
|
||||
|
|
|
|||
5
npm-shrinkwrap.json → package-lock.json
generated
5
npm-shrinkwrap.json → package-lock.json
generated
|
|
@ -3129,19 +3129,14 @@
|
|||
"aikido-bunx": "bin/aikido-bunx.js",
|
||||
"aikido-npm": "bin/aikido-npm.js",
|
||||
"aikido-npx": "bin/aikido-npx.js",
|
||||
"aikido-pdm": "bin/aikido-pdm.js",
|
||||
"aikido-pip": "bin/aikido-pip.js",
|
||||
"aikido-pip3": "bin/aikido-pip3.js",
|
||||
"aikido-pipx": "bin/aikido-pipx.js",
|
||||
"aikido-pnpm": "bin/aikido-pnpm.js",
|
||||
"aikido-pnpx": "bin/aikido-pnpx.js",
|
||||
"aikido-poetry": "bin/aikido-poetry.js",
|
||||
"aikido-python": "bin/aikido-python.js",
|
||||
"aikido-python3": "bin/aikido-python3.js",
|
||||
"aikido-rush": "bin/aikido-rush.js",
|
||||
"aikido-rushx": "bin/aikido-rushx.js",
|
||||
"aikido-uv": "bin/aikido-uv.js",
|
||||
"aikido-uvx": "bin/aikido-uvx.js",
|
||||
"aikido-yarn": "bin/aikido-yarn.js",
|
||||
"safe-chain": "bin/safe-chain.js"
|
||||
},
|
||||
|
|
@ -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);
|
||||
})();
|
||||
|
|
@ -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);
|
||||
})();
|
||||
|
|
@ -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);
|
||||
})();
|
||||
|
|
@ -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);
|
||||
})();
|
||||
|
|
@ -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);
|
||||
})();
|
||||
|
|
@ -1,18 +1,9 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Strip PKG_EXECPATH from the environment so any child process safe-chain
|
||||
// spawns (npm, uv, pip, …) doesn't inherit it. If it leaks into a subsequent
|
||||
// safe-chain invocation (e.g. via a shim) the yao-pkg bootstrap would treat
|
||||
// argv[1] as a script path and fail with MODULE_NOT_FOUND.
|
||||
delete process.env.PKG_EXECPATH;
|
||||
|
||||
import chalk from "chalk";
|
||||
import { ui } from "../src/environment/userInteraction.js";
|
||||
import { setup } from "../src/shell-integration/setup.js";
|
||||
import {
|
||||
teardown,
|
||||
teardownDirectories,
|
||||
} from "../src/shell-integration/teardown.js";
|
||||
import { teardown, teardownDirectories } from "../src/shell-integration/teardown.js";
|
||||
import { setupCi } from "../src/shell-integration/setup-ci.js";
|
||||
import { initializeCliArguments } from "../src/config/cliArguments.js";
|
||||
import { setEcoSystem } from "../src/config/settings.js";
|
||||
|
|
@ -21,8 +12,7 @@ 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";
|
||||
import { knownAikidoTools } from "../src/shell-integration/helpers.js";
|
||||
|
||||
/** @type {string} */
|
||||
// This checks the current file's dirname in a way that's compatible with:
|
||||
|
|
@ -70,21 +60,10 @@ if (tool) {
|
|||
} else if (command === "setup") {
|
||||
setup();
|
||||
} else if (command === "teardown") {
|
||||
teardown();
|
||||
teardownDirectories();
|
||||
teardown();
|
||||
} else if (command === "setup-ci") {
|
||||
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") {
|
||||
(async () => {
|
||||
ui.writeInformation(`Current safe-chain version: ${await getVersion()}`);
|
||||
|
|
@ -100,41 +79,36 @@ if (tool) {
|
|||
|
||||
function writeHelp() {
|
||||
ui.writeInformation(
|
||||
chalk.bold("Usage: ") + chalk.cyan("safe-chain <command>"),
|
||||
chalk.bold("Usage: ") + chalk.cyan("safe-chain <command>")
|
||||
);
|
||||
ui.emptyLine();
|
||||
ui.writeInformation(
|
||||
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
|
||||
"teardown",
|
||||
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("get-install-dir")}, ${chalk.cyan("help")}, ${chalk.cyan(
|
||||
"--version",
|
||||
)}`,
|
||||
"teardown"
|
||||
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan(
|
||||
"--version"
|
||||
)}`
|
||||
);
|
||||
ui.emptyLine();
|
||||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
"safe-chain setup",
|
||||
)}: This will setup your shell to wrap safe-chain around ${getPackageManagerList()}.`,
|
||||
"safe-chain setup"
|
||||
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`
|
||||
);
|
||||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
"safe-chain teardown",
|
||||
)}: This will remove safe-chain aliases from your shell configuration.`,
|
||||
"safe-chain teardown"
|
||||
)}: This will remove safe-chain aliases from your shell configuration.`
|
||||
);
|
||||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
"safe-chain setup-ci",
|
||||
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`,
|
||||
);
|
||||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
"safe-chain get-install-dir",
|
||||
)}: Print the install directory for packaged safe-chain binaries.`,
|
||||
"safe-chain setup-ci"
|
||||
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`
|
||||
);
|
||||
ui.writeInformation(
|
||||
`- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan(
|
||||
"-v",
|
||||
)}): Display the current version of safe-chain.`,
|
||||
"-v"
|
||||
)}): Display the current version of safe-chain.`
|
||||
);
|
||||
ui.emptyLine();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,19 +13,14 @@
|
|||
"aikido-yarn": "bin/aikido-yarn.js",
|
||||
"aikido-pnpm": "bin/aikido-pnpm.js",
|
||||
"aikido-pnpx": "bin/aikido-pnpx.js",
|
||||
"aikido-rush": "bin/aikido-rush.js",
|
||||
"aikido-rushx": "bin/aikido-rushx.js",
|
||||
"aikido-bun": "bin/aikido-bun.js",
|
||||
"aikido-bunx": "bin/aikido-bunx.js",
|
||||
"aikido-uv": "bin/aikido-uv.js",
|
||||
"aikido-uvx": "bin/aikido-uvx.js",
|
||||
"aikido-pip": "bin/aikido-pip.js",
|
||||
"aikido-pip3": "bin/aikido-pip3.js",
|
||||
"aikido-python": "bin/aikido-python.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"
|
||||
},
|
||||
"type": "module",
|
||||
|
|
@ -40,7 +35,7 @@
|
|||
"keywords": [],
|
||||
"author": "Aikido Security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [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/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, or pip/pip3 from downloading or running the malware.",
|
||||
"dependencies": {
|
||||
"certifi": "14.5.15",
|
||||
"chalk": "5.4.1",
|
||||
|
|
|
|||
|
|
@ -1,24 +1,11 @@
|
|||
import fetch from "make-fetch-happen";
|
||||
import {
|
||||
getEcoSystem,
|
||||
ECOSYSTEM_JS,
|
||||
ECOSYSTEM_PY,
|
||||
getMalwareListBaseUrl,
|
||||
} from "../config/settings.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
|
||||
const malwareDatabasePaths = {
|
||||
[ECOSYSTEM_JS]: "malware_predictions.json",
|
||||
[ECOSYSTEM_PY]: "malware_pypi.json",
|
||||
const malwareDatabaseUrls = {
|
||||
[ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.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
|
||||
* @property {string} package_name
|
||||
|
|
@ -26,31 +13,15 @@ const DEFAULT_FETCH_RETRY_ATTEMPTS = 4;
|
|||
* @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}>}
|
||||
*/
|
||||
export async function fetchMalwareDatabase() {
|
||||
return retry(async () => {
|
||||
const ecosystem = getEcoSystem();
|
||||
const baseUrl = getMalwareListBaseUrl();
|
||||
const path = malwareDatabasePaths[
|
||||
/** @type {keyof typeof malwareDatabasePaths} */ (ecosystem)
|
||||
];
|
||||
const malwareDatabaseUrl = `${baseUrl}/${path}`;
|
||||
const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)];
|
||||
const response = await fetch(malwareDatabaseUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Error fetching ${ecosystem} malware database: ${response.statusText}`
|
||||
);
|
||||
throw new Error(`Error fetching ${ecosystem} malware database: ${response.statusText}`);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -62,20 +33,14 @@ export async function fetchMalwareDatabase() {
|
|||
} catch (/** @type {any} */ error) {
|
||||
throw new Error(`Error parsing malware database: ${error.message}`);
|
||||
}
|
||||
}, DEFAULT_FETCH_RETRY_ATTEMPTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
export async function fetchMalwareDatabaseVersion() {
|
||||
return retry(async () => {
|
||||
const ecosystem = getEcoSystem();
|
||||
const baseUrl = getMalwareListBaseUrl();
|
||||
const path = malwareDatabasePaths[
|
||||
/** @type {keyof typeof malwareDatabasePaths} */ (ecosystem)
|
||||
];
|
||||
const malwareDatabaseUrl = `${baseUrl}/${path}`;
|
||||
const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)];
|
||||
const response = await fetch(malwareDatabaseUrl, {
|
||||
method: "HEAD",
|
||||
});
|
||||
|
|
@ -86,102 +51,4 @@ export async function fetchMalwareDatabaseVersion() {
|
|||
);
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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, minimumPackageAgeHours: string | undefined}}
|
||||
*/
|
||||
const state = {
|
||||
loggingLevel: undefined,
|
||||
skipMinimumPackageAge: undefined,
|
||||
minimumPackageAgeHours: undefined,
|
||||
malwareListBaseUrl: undefined,
|
||||
};
|
||||
|
||||
const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
|
||||
|
|
@ -21,7 +18,6 @@ export function initializeCliArguments(args) {
|
|||
state.loggingLevel = undefined;
|
||||
state.skipMinimumPackageAge = undefined;
|
||||
state.minimumPackageAgeHours = undefined;
|
||||
state.malwareListBaseUrl = undefined;
|
||||
|
||||
const safeChainArgs = [];
|
||||
const remainingArgs = [];
|
||||
|
|
@ -37,8 +33,7 @@ export function initializeCliArguments(args) {
|
|||
setLoggingLevel(safeChainArgs);
|
||||
setSkipMinimumPackageAge(safeChainArgs);
|
||||
setMinimumPackageAgeHours(safeChainArgs);
|
||||
setMalwareListBaseUrl(safeChainArgs);
|
||||
checkDeprecatedPythonFlag(args);
|
||||
|
||||
return remainingArgs;
|
||||
}
|
||||
|
||||
|
|
@ -112,26 +107,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} args
|
||||
* @param {string} flagName
|
||||
|
|
@ -145,17 +120,3 @@ function hasFlagArg(args, flagName) {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a deprecation warning for legacy --include-python flag
|
||||
*
|
||||
* @param {string[]} args
|
||||
* @returns {void}
|
||||
*/
|
||||
export function checkDeprecatedPythonFlag(args) {
|
||||
if (hasFlagArg(args, "--include-python")) {
|
||||
ui.writeWarning(
|
||||
"--include-python is deprecated and ignored. Python tooling is included by default."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
getSkipMinimumPackageAge,
|
||||
getMinimumPackageAgeHours,
|
||||
} from "./cliArguments.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
|
||||
describe("initializeCliArguments", () => {
|
||||
it("should return all args when no safe-chain args are present", () => {
|
||||
|
|
@ -272,40 +271,4 @@ describe("initializeCliArguments", () => {
|
|||
|
||||
assert.strictEqual(getMinimumPackageAgeHours(), "-24");
|
||||
});
|
||||
|
||||
it("should warn on deprecated --include-python for setup", () => {
|
||||
const warnings = [];
|
||||
const originalWriteWarning = ui.writeWarning;
|
||||
ui.writeWarning = (msg, ..._rest) => {
|
||||
warnings.push(String(msg));
|
||||
};
|
||||
try {
|
||||
const argv = ["node", "safe-chain", "setup", "--include-python"];
|
||||
initializeCliArguments(argv);
|
||||
assert.ok(
|
||||
warnings.some((m) => m.includes("--include-python is deprecated")),
|
||||
"Expected a deprecation warning for --include-python in setup"
|
||||
);
|
||||
} finally {
|
||||
ui.writeWarning = originalWriteWarning;
|
||||
}
|
||||
});
|
||||
|
||||
it("should warn on deprecated --include-python for setup-ci", () => {
|
||||
const warnings = [];
|
||||
const originalWriteWarning = ui.writeWarning;
|
||||
ui.writeWarning = (msg, ..._rest) => {
|
||||
warnings.push(String(msg));
|
||||
};
|
||||
try {
|
||||
const argv = ["node", "safe-chain", "setup-ci", "--include-python"];
|
||||
initializeCliArguments(argv);
|
||||
assert.ok(
|
||||
warnings.some((m) => m.includes("--include-python is deprecated")),
|
||||
"Expected a deprecation warning for --include-python in setup-ci"
|
||||
);
|
||||
} finally {
|
||||
ui.writeWarning = originalWriteWarning;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,22 +3,14 @@ import path from "path";
|
|||
import os from "os";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { getEcoSystem } from "./settings.js";
|
||||
import { getSafeChainBaseDir } from "./safeChainDir.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} SafeChainConfig
|
||||
*
|
||||
* We cannot trust the input and should add the necessary validations
|
||||
* @property {unknown | Number} scanTimeout
|
||||
* @property {unknown | Number} minimumPackageAgeHours
|
||||
* @property {unknown | string} malwareListBaseUrl
|
||||
* @property {unknown | SafeChainRegistryConfiguration} npm
|
||||
* @property {unknown | SafeChainRegistryConfiguration} pip
|
||||
*
|
||||
* @typedef {Object} SafeChainRegistryConfiguration
|
||||
* This should be a number, but can be anything because it is user-input.
|
||||
* We cannot trust the input and should add the necessary validations.
|
||||
* @property {unknown | string[]} customRegistries
|
||||
* @property {unknown | string[]} minimumPackageAgeExclusions
|
||||
* @property {unknown} scanTimeout
|
||||
* @property {unknown} minimumPackageAgeHours
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -86,86 +78,6 @@ export function getMinimumPackageAgeHours() {
|
|||
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 {string | number} version
|
||||
|
|
@ -224,30 +136,23 @@ export function readDatabaseFromLocalCache() {
|
|||
* @returns {SafeChainConfig}
|
||||
*/
|
||||
function readConfigFile() {
|
||||
/** @type {SafeChainConfig} */
|
||||
const emptyConfig = {
|
||||
scanTimeout: undefined,
|
||||
minimumPackageAgeHours: undefined,
|
||||
malwareListBaseUrl: undefined,
|
||||
npm: {
|
||||
customRegistries: undefined,
|
||||
},
|
||||
pip: {
|
||||
customRegistries: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const configFilePath = getConfigFilePath();
|
||||
|
||||
if (!fs.existsSync(configFilePath)) {
|
||||
return emptyConfig;
|
||||
return {
|
||||
scanTimeout: undefined,
|
||||
minimumPackageAgeHours: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(configFilePath, "utf8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return emptyConfig;
|
||||
return {
|
||||
scanTimeout: undefined,
|
||||
minimumPackageAgeHours: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -266,51 +171,11 @@ function getDatabaseVersionPath() {
|
|||
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}
|
||||
*/
|
||||
function getConfigFilePath() {
|
||||
const primaryPath = path.join(getSafeChainDirectory(), "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;
|
||||
return path.join(getAikidoDirectory(), "config.json");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,35 +1,16 @@
|
|||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
const safeChainConfigPath = path.join(os.homedir(), ".safe-chain", "config.json");
|
||||
const aikidoConfigPath = path.join(os.homedir(), ".aikido", "config.json");
|
||||
|
||||
/** @type {Map<string, string>} */
|
||||
let mockFiles = new Map();
|
||||
let configFileContent = undefined;
|
||||
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),
|
||||
existsSync: () => configFileContent !== undefined,
|
||||
readFileSync: () => configFileContent,
|
||||
writeFileSync: (content) => (configFileContent = 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;
|
||||
|
||||
|
|
@ -48,11 +29,12 @@ describe("getScanTimeout", async () => {
|
|||
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
mockFiles.clear();
|
||||
configFileContent = undefined;
|
||||
});
|
||||
|
||||
it("should return default timeout of 10000ms when no config or env var is set", () => {
|
||||
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
|
||||
configFileContent = undefined;
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -61,7 +43,7 @@ describe("getScanTimeout", async () => {
|
|||
|
||||
it("should return timeout from config file when set", () => {
|
||||
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
|
||||
setConfigContent(JSON.stringify({ scanTimeout: 5000 }));
|
||||
configFileContent = JSON.stringify({ scanTimeout: 5000 });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -70,7 +52,7 @@ describe("getScanTimeout", async () => {
|
|||
|
||||
it("should prioritize environment variable over config file", () => {
|
||||
process.env.AIKIDO_SCAN_TIMEOUT_MS = "20000";
|
||||
setConfigContent(JSON.stringify({ scanTimeout: 5000 }));
|
||||
configFileContent = JSON.stringify({ scanTimeout: 5000 });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -79,7 +61,7 @@ describe("getScanTimeout", async () => {
|
|||
|
||||
it("should handle invalid environment variable and fall back to config", () => {
|
||||
process.env.AIKIDO_SCAN_TIMEOUT_MS = "invalid";
|
||||
setConfigContent(JSON.stringify({ scanTimeout: 7000 }));
|
||||
configFileContent = JSON.stringify({ scanTimeout: 7000 });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -87,6 +69,8 @@ describe("getScanTimeout", async () => {
|
|||
});
|
||||
|
||||
it("should ignore zero and negative values and fall back to default", () => {
|
||||
configFileContent = undefined;
|
||||
|
||||
process.env.AIKIDO_SCAN_TIMEOUT_MS = "0";
|
||||
|
||||
let timeout = getScanTimeout();
|
||||
|
|
@ -100,7 +84,7 @@ describe("getScanTimeout", async () => {
|
|||
|
||||
it("should ignore textual non-numeric values in environment variable and fall back to config", () => {
|
||||
process.env.AIKIDO_SCAN_TIMEOUT_MS = "fast";
|
||||
setConfigContent(JSON.stringify({ scanTimeout: 8000 }));
|
||||
configFileContent = JSON.stringify({ scanTimeout: 8000 });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -109,7 +93,7 @@ describe("getScanTimeout", async () => {
|
|||
|
||||
it("should ignore textual non-numeric values in config file and fall back to default", () => {
|
||||
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
|
||||
setConfigContent(JSON.stringify({ scanTimeout: "slow" }));
|
||||
configFileContent = JSON.stringify({ scanTimeout: "slow" });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -118,7 +102,7 @@ describe("getScanTimeout", async () => {
|
|||
|
||||
it("should ignore textual non-numeric values in both env and config, fall back to default", () => {
|
||||
process.env.AIKIDO_SCAN_TIMEOUT_MS = "quick";
|
||||
setConfigContent(JSON.stringify({ scanTimeout: "medium" }));
|
||||
configFileContent = JSON.stringify({ scanTimeout: "medium" });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -127,7 +111,7 @@ describe("getScanTimeout", async () => {
|
|||
|
||||
it("should ignore mixed alphanumeric strings in environment variable", () => {
|
||||
process.env.AIKIDO_SCAN_TIMEOUT_MS = "5000ms";
|
||||
setConfigContent(JSON.stringify({ scanTimeout: 6000 }));
|
||||
configFileContent = JSON.stringify({ scanTimeout: 6000 });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -136,7 +120,7 @@ describe("getScanTimeout", async () => {
|
|||
|
||||
it("should ignore mixed alphanumeric strings in config file", () => {
|
||||
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
|
||||
setConfigContent(JSON.stringify({ scanTimeout: "3000ms" }));
|
||||
configFileContent = JSON.stringify({ scanTimeout: "3000ms" });
|
||||
|
||||
const timeout = getScanTimeout();
|
||||
|
||||
|
|
@ -148,17 +132,19 @@ describe("getMinimumPackageAgeHours", async () => {
|
|||
const { getMinimumPackageAgeHours } = await import("./configFile.js");
|
||||
|
||||
afterEach(() => {
|
||||
mockFiles.clear();
|
||||
configFileContent = undefined;
|
||||
});
|
||||
|
||||
it("should return null when config file doesn't exist", () => {
|
||||
configFileContent = undefined;
|
||||
|
||||
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 }));
|
||||
configFileContent = JSON.stringify({ scanTimeout: 5000 });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -166,7 +152,7 @@ describe("getMinimumPackageAgeHours", async () => {
|
|||
});
|
||||
|
||||
it("should return value from config file when set to valid number", () => {
|
||||
setConfigContent(JSON.stringify({ minimumPackageAgeHours: 48 }));
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: 48 });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -174,7 +160,7 @@ describe("getMinimumPackageAgeHours", async () => {
|
|||
});
|
||||
|
||||
it("should handle string numbers in config file", () => {
|
||||
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "72" }));
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: "72" });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -182,7 +168,7 @@ describe("getMinimumPackageAgeHours", async () => {
|
|||
});
|
||||
|
||||
it("should handle decimal values", () => {
|
||||
setConfigContent(JSON.stringify({ minimumPackageAgeHours: 1.5 }));
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: 1.5 });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -190,7 +176,7 @@ describe("getMinimumPackageAgeHours", async () => {
|
|||
});
|
||||
|
||||
it("should return null for non-numeric strings", () => {
|
||||
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "invalid" }));
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: "invalid" });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -198,7 +184,7 @@ describe("getMinimumPackageAgeHours", async () => {
|
|||
});
|
||||
|
||||
it("should return undefined for values with units suffix", () => {
|
||||
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "48h" }));
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: "48h" });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -206,7 +192,7 @@ describe("getMinimumPackageAgeHours", async () => {
|
|||
});
|
||||
|
||||
it("should handle malformed JSON and return null", () => {
|
||||
setConfigContent("{ invalid json");
|
||||
configFileContent = "{ invalid json";
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -214,7 +200,7 @@ describe("getMinimumPackageAgeHours", async () => {
|
|||
});
|
||||
|
||||
it("should return 0 when minimumPackageAgeHours is set to 0", () => {
|
||||
setConfigContent(JSON.stringify({ minimumPackageAgeHours: 0 }));
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: 0 });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -222,7 +208,7 @@ describe("getMinimumPackageAgeHours", async () => {
|
|||
});
|
||||
|
||||
it("should return 0 when minimumPackageAgeHours is set to string '0'", () => {
|
||||
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "0" }));
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: "0" });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -230,7 +216,7 @@ describe("getMinimumPackageAgeHours", async () => {
|
|||
});
|
||||
|
||||
it("should handle negative numeric values", () => {
|
||||
setConfigContent(JSON.stringify({ minimumPackageAgeHours: -24 }));
|
||||
configFileContent = JSON.stringify({ minimumPackageAgeHours: -24 });
|
||||
|
||||
const hours = getMinimumPackageAgeHours();
|
||||
|
||||
|
|
@ -238,143 +224,10 @@ describe("getMinimumPackageAgeHours", async () => {
|
|||
});
|
||||
|
||||
it("should handle negative string values", () => {
|
||||
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "-48" }));
|
||||
configFileContent = 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,53 +5,3 @@
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,27 +1,20 @@
|
|||
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_NORMAL = "normal";
|
||||
export const LOGGING_VERBOSE = "verbose";
|
||||
|
||||
export function getLoggingLevel() {
|
||||
// Priority 1: CLI argument
|
||||
const cliLevel = cliArguments.getLoggingLevel();
|
||||
if (cliLevel === LOGGING_SILENT || cliLevel === LOGGING_VERBOSE) {
|
||||
return cliLevel;
|
||||
}
|
||||
if (cliLevel) {
|
||||
// CLI arg was set but invalid, default to normal for backwards compatibility.
|
||||
return LOGGING_NORMAL;
|
||||
const level = cliArguments.getLoggingLevel();
|
||||
|
||||
if (level === LOGGING_SILENT) {
|
||||
return LOGGING_SILENT;
|
||||
}
|
||||
|
||||
// Priority 2: Environment variable
|
||||
const envLevel = environmentVariables.getLoggingLevel()?.toLowerCase();
|
||||
if (envLevel === LOGGING_SILENT || envLevel === LOGGING_VERBOSE) {
|
||||
return envLevel;
|
||||
if (level === LOGGING_VERBOSE) {
|
||||
return LOGGING_VERBOSE;
|
||||
}
|
||||
|
||||
return LOGGING_NORMAL;
|
||||
|
|
@ -46,7 +39,7 @@ export function setEcoSystem(setting) {
|
|||
ecosystemSettings.ecoSystem = setting;
|
||||
}
|
||||
|
||||
const defaultMinimumPackageAge = 48;
|
||||
const defaultMinimumPackageAge = 24;
|
||||
/** @returns {number} */
|
||||
export function getMinimumPackageAgeHours() {
|
||||
// Priority 1: CLI argument
|
||||
|
|
@ -105,143 +98,3 @@ export function skipMinimumPackageAge() {
|
|||
|
||||
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(/\/+$/, "");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -13,10 +13,6 @@ import { getAuditStats } from "./scanning/audit/index.js";
|
|||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function main(args) {
|
||||
if (isSafeChainVerify(args)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
process.on("SIGINT", handleProcessTermination);
|
||||
process.on("SIGTERM", handleProcessTermination);
|
||||
|
||||
|
|
@ -64,11 +60,7 @@ export async function main(args) {
|
|||
// Write all buffered logs
|
||||
ui.writeBufferedLogsAndStopBuffering();
|
||||
|
||||
if (proxy.hasBlockedMaliciousPackages()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (proxy.hasBlockedMinimumAgeRequests()) {
|
||||
if (!proxy.verifyNoMaliciousPackages()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -77,20 +69,20 @@ export async function main(args) {
|
|||
ui.writeVerbose(
|
||||
`${chalk.green("✔")} Safe-chain: Scanned ${
|
||||
auditStats.totalPackages
|
||||
} packages, no malware found.`,
|
||||
} packages, no malware found.`
|
||||
);
|
||||
}
|
||||
|
||||
if (proxy.hasSuppressedVersions()) {
|
||||
ui.writeInformation(
|
||||
`${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(
|
||||
` To disable this check, use: ${chalk.cyan(
|
||||
"--safe-chain-skip-minimum-package-age",
|
||||
)}`,
|
||||
"--safe-chain-skip-minimum-package-age"
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -112,12 +104,3 @@ export async function main(args) {
|
|||
function handleProcessTermination() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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?",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
||||
|
||||
/**
|
||||
* @returns {import("../currentPackageManager.js").PackageManager}
|
||||
|
|
@ -43,6 +43,11 @@ async function runBunCommand(command, args) {
|
|||
});
|
||||
return { status: result.status };
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,6 @@ import { createYarnPackageManager } from "./yarn/createPackageManager.js";
|
|||
import { createPipPackageManager } from "./pip/createPackageManager.js";
|
||||
import { createUvPackageManager } from "./uv/createUvPackageManager.js";
|
||||
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
|
||||
import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js";
|
||||
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}}
|
||||
|
|
@ -64,18 +59,8 @@ export function initializePackageManager(packageManagerName, context) {
|
|||
state.packageManagerName = createPipPackageManager(context);
|
||||
} 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 {
|
||||
throw new Error("Unsupported package manager: " + packageManagerName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
||||
|
||||
/**
|
||||
* @param {string[]} args
|
||||
|
|
@ -15,6 +15,11 @@ export async function runNpm(args) {
|
|||
});
|
||||
return { status: result.status };
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
||||
|
||||
/**
|
||||
* @param {string[]} args
|
||||
|
|
@ -15,6 +15,11 @@ export async function runNpx(args) {
|
|||
});
|
||||
return { status: result.status };
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -9,7 +9,6 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
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.
|
||||
|
|
@ -204,6 +203,12 @@ export async function runPip(command, args) {
|
|||
|
||||
return { status: result.status };
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () => [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
||||
|
||||
/**
|
||||
* @param {string[]} args
|
||||
|
|
@ -26,7 +26,11 @@ export async function runPnpmCommand(args, toolName = "pnpm") {
|
|||
|
||||
return { status: result.status };
|
||||
} catch (/** @type any */ error) {
|
||||
const target = toolName === "pnpm" ? "pnpm" : "pnpx";
|
||||
return reportCommandExecutionFailure(error, target);
|
||||
if (error.status) {
|
||||
return { status: error.status };
|
||||
} else {
|
||||
ui.writeError("Error executing command:", error.message);
|
||||
return { status: 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ 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}
|
||||
|
|
@ -67,6 +66,12 @@ async function runPoetryCommand(args) {
|
|||
|
||||
return { status: result.status };
|
||||
} catch (/** @type any */ error) {
|
||||
return reportCommandExecutionFailure(error, "poetry");
|
||||
if (error.status) {
|
||||
return { status: error.status };
|
||||
} else {
|
||||
ui.writeError("Error executing command:", error.message);
|
||||
ui.writeError("Is 'poetry' installed and available on your system?");
|
||||
return { status: 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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: () => [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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(), []);
|
||||
});
|
||||
|
|
@ -2,7 +2,6 @@ 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.
|
||||
|
|
@ -61,6 +60,12 @@ export async function runUv(command, args) {
|
|||
|
||||
return { status: result.status };
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () => [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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(), []);
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
||||
|
||||
/**
|
||||
* @param {string[]} args
|
||||
|
|
@ -18,7 +18,12 @@ export async function runYarnCommand(args) {
|
|||
});
|
||||
return { status: result.status };
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@ import { X509Certificate } from "node:crypto";
|
|||
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.
|
||||
* @param {string} pem - PEM-encoded certificate string
|
||||
|
|
@ -57,11 +54,6 @@ function isParsable(pem) {
|
|||
* @returns {string} Path to the combined CA bundle PEM file
|
||||
*/
|
||||
export function getCombinedCaBundlePath() {
|
||||
if (bundlePath)
|
||||
{
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
|
||||
// 1) Safe Chain CA (for MITM'd registries)
|
||||
|
|
@ -107,23 +99,9 @@ export function getCombinedCaBundlePath() {
|
|||
}
|
||||
|
||||
const combined = parts.filter(Boolean).join("\n");
|
||||
bundlePath = path.join(os.tmpdir(), `safe-chain-ca-bundle-${Date.now()}.pem`);
|
||||
fs.writeFileSync(bundlePath, combined, { encoding: "utf8" });
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
const target = path.join(os.tmpdir(), `safe-chain-ca-bundle-${Date.now()}.pem`);
|
||||
fs.writeFileSync(target, combined, { encoding: "utf8" });
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import forge from "node-forge";
|
||||
import path from "path";
|
||||
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 certCache = new Map();
|
||||
|
|
@ -19,7 +20,7 @@ function createKeyIdentifier(publicKey) {
|
|||
}
|
||||
|
||||
export function getCaCertPath() {
|
||||
return path.join(getCertsDir(), "ca-cert.pem");
|
||||
return path.join(certFolder, "ca-cert.pem");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -111,7 +112,6 @@ export function generateCertForHost(hostname) {
|
|||
}
|
||||
|
||||
function loadCa() {
|
||||
const certFolder = getCertsDir();
|
||||
const keyPath = path.join(certFolder, "ca-key.pem");
|
||||
const certPath = path.join(certFolder, "ca-cert.pem");
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -15,66 +15,3 @@ export function getHeaderValueAsString(headers, headerName) {
|
|||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
getEcoSystem,
|
||||
} from "../../config/settings.js";
|
||||
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
|
||||
import { pipInterceptorForUrl } from "./pip/pipInterceptor.js";
|
||||
import { pipInterceptorForUrl } from "./pipInterceptor.js";
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { EventEmitter } from "events";
|
|||
* @typedef {Object} RequestInterceptionContext
|
||||
* @property {string} targetUrl
|
||||
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
|
||||
* @property {(packageName: string, version: string, message: string) => void} blockMinimumAgeRequest
|
||||
* @property {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>) => void} modifyRequestHeaders
|
||||
* @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer) => void} modifyBody
|
||||
* @property {() => RequestInterceptionHandler} build
|
||||
|
|
@ -27,12 +26,6 @@ import { EventEmitter } from "events";
|
|||
* @property {string} version
|
||||
* @property {string} targetUrl
|
||||
* @property {number} timestamp
|
||||
*
|
||||
* @typedef {Object} MinimumAgeRequestBlockedEvent
|
||||
* @property {string} packageName
|
||||
* @property {string} version
|
||||
* @property {string} targetUrl
|
||||
* @property {number} timestamp
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -88,7 +81,10 @@ function createRequestContext(targetUrl, eventEmitter) {
|
|||
* @param {string | undefined} version
|
||||
*/
|
||||
function blockMalwareSetup(packageName, version) {
|
||||
blockResponse = createBlockResponse("Forbidden - blocked by safe-chain");
|
||||
blockResponse = {
|
||||
statusCode: 403,
|
||||
message: "Forbidden - blocked by safe-chain",
|
||||
};
|
||||
|
||||
// Emit the malwareBlocked event
|
||||
eventEmitter.emit("malwareBlocked", {
|
||||
|
|
@ -99,34 +95,6 @@ function createRequestContext(targetUrl, eventEmitter) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
*/
|
||||
function blockMinimumAgeRequestSetup(
|
||||
/** @type {string} */ packageName,
|
||||
/** @type {string} */ version,
|
||||
/** @type {string} */ message
|
||||
) {
|
||||
blockResponse = createBlockResponse(message);
|
||||
eventEmitter.emit("minimumAgeRequestBlocked", {
|
||||
packageName,
|
||||
version,
|
||||
targetUrl,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @returns {{statusCode: number, message: string}}
|
||||
*/
|
||||
function createBlockResponse(message) {
|
||||
return {
|
||||
statusCode: 403,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns {RequestInterceptionHandler} */
|
||||
function build() {
|
||||
/**
|
||||
|
|
@ -171,7 +139,6 @@ function createRequestContext(targetUrl, eventEmitter) {
|
|||
return {
|
||||
targetUrl,
|
||||
blockMalware: blockMalwareSetup,
|
||||
blockMinimumAgeRequest: blockMinimumAgeRequestSetup,
|
||||
modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func),
|
||||
modifyBody: (func) => modifyBodyFuncs.push(func),
|
||||
build,
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
import { getMinimumPackageAgeExclusions, getEcoSystem } from "../../config/settings.js";
|
||||
import { getEquivalentPackageNames } from "../../scanning/packageNameVariants.js";
|
||||
|
||||
/**
|
||||
* Checks if a package name matches an exclusion pattern.
|
||||
* Supports trailing wildcard (*) for prefix matching.
|
||||
* @param {string} packageName
|
||||
* @param {string} pattern
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function matchesExclusionPattern(packageName, pattern) {
|
||||
if (pattern.endsWith("/*")) {
|
||||
return packageName.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
return packageName === pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} packageName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isExcludedFromMinimumPackageAge(packageName) {
|
||||
if (!packageName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const exclusions = getMinimumPackageAgeExclusions();
|
||||
const candidateNames = getEquivalentPackageNames(packageName, getEcoSystem());
|
||||
|
||||
return exclusions.some((pattern) =>
|
||||
candidateNames.some((name) => matchesExclusionPattern(name, pattern))
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
|
||||
import { ui } from "../../../environment/userInteraction.js";
|
||||
import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js";
|
||||
import { recordSuppressedVersion } from "../suppressedVersionsState.js";
|
||||
import { getHeaderValueAsString } from "../../http-utils.js";
|
||||
|
||||
const state = {
|
||||
hasSuppressedVersions: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {NodeJS.Dict<string | string[]>} headers
|
||||
|
|
@ -79,7 +82,15 @@ export function modifyNpmInfoResponse(body, headers) {
|
|||
const timestampValue = new Date(timestamp);
|
||||
if (timestampValue > cutOff) {
|
||||
deleteVersionFromJson(bodyJson, version);
|
||||
clearCachingHeaders(headers);
|
||||
if (headers) {
|
||||
// When modifying the response, the etag and last-modified headers
|
||||
// no longer match the content so they needs to be removed before sending the response.
|
||||
delete headers["etag"];
|
||||
delete headers["last-modified"];
|
||||
// Removing the cache-control header will prevent the package manager from caching
|
||||
// the modified response.
|
||||
delete headers["cache-control"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -103,12 +114,10 @@ export function modifyNpmInfoResponse(body, headers) {
|
|||
* @param {string} version
|
||||
*/
|
||||
function deleteVersionFromJson(json, version) {
|
||||
recordSuppressedVersion();
|
||||
|
||||
const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
|
||||
state.hasSuppressedVersions = true;
|
||||
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
|
||||
`Safe-chain: ${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
|
||||
);
|
||||
|
||||
delete json.time[version];
|
||||
|
|
@ -161,20 +170,8 @@ function getMostRecentTag(tagList) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} body
|
||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||
* @returns {string | undefined}
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function getPackageNameFromMetadataResponse(body, headers) {
|
||||
try {
|
||||
const contentType = getHeaderValueAsString(headers, "content-type");
|
||||
if (!contentType?.toLowerCase().includes("application/json")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bodyJson = JSON.parse(body.toString("utf8"));
|
||||
return typeof bodyJson.name === "string" ? bodyJson.name : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
export function getHasSuppressedVersions() {
|
||||
return state.hasSuppressedVersions;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,21 @@
|
|||
import {
|
||||
getNpmCustomRegistries,
|
||||
skipMinimumPackageAge,
|
||||
} from "../../../config/settings.js";
|
||||
import { skipMinimumPackageAge } from "../../../config/settings.js";
|
||||
import { isMalwarePackage } from "../../../scanning/audit/index.js";
|
||||
import { interceptRequests } from "../interceptorBuilder.js";
|
||||
import {
|
||||
getPackageNameFromMetadataResponse,
|
||||
isPackageInfoUrl,
|
||||
modifyNpmInfoRequestHeaders,
|
||||
modifyNpmInfoResponse,
|
||||
} from "./modifyNpmInfo.js";
|
||||
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
|
||||
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
|
||||
import {
|
||||
isExcludedFromMinimumPackageAge,
|
||||
} from "../minimumPackageAgeExclusions.js";
|
||||
|
||||
const knownJsRegistries = [
|
||||
"registry.npmjs.org",
|
||||
"registry.yarnpkg.com",
|
||||
"registry.npmjs.com",
|
||||
];
|
||||
const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
export function npmInterceptorForUrl(url) {
|
||||
const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find(
|
||||
(reg) => url.includes(reg)
|
||||
);
|
||||
const registry = knownJsRegistries.find((reg) => url.includes(reg));
|
||||
|
||||
if (registry) {
|
||||
return buildNpmInterceptor(registry);
|
||||
|
|
@ -48,54 +34,14 @@ function buildNpmInterceptor(registry) {
|
|||
reqContext.targetUrl,
|
||||
registry
|
||||
);
|
||||
const minimumAgeChecksEnabled = !skipMinimumPackageAge();
|
||||
|
||||
if (await isMalwarePackage(packageName, version)) {
|
||||
reqContext.blockMalware(packageName, version);
|
||||
return;
|
||||
}
|
||||
|
||||
if (minimumAgeChecksEnabled && isPackageInfoUrl(reqContext.targetUrl)) {
|
||||
if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) {
|
||||
reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders);
|
||||
reqContext.modifyBody(modifyNpmInfoResponseUnlessExcluded);
|
||||
return;
|
||||
}
|
||||
|
||||
// For tarball requests the metadata check above is skipped, so we check the
|
||||
// new packages list as a fallback (covers e.g. frozen-lockfile installs).
|
||||
if (
|
||||
minimumAgeChecksEnabled &&
|
||||
packageName &&
|
||||
version &&
|
||||
!isExcludedFromMinimumPackageAge(packageName)
|
||||
) {
|
||||
const newPackagesDatabase = await openNewPackagesDatabase();
|
||||
|
||||
if (newPackagesDatabase.isNewlyReleasedPackage(packageName, version)) {
|
||||
reqContext.blockMinimumAgeRequest(
|
||||
packageName,
|
||||
version,
|
||||
`Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})`
|
||||
);
|
||||
}
|
||||
reqContext.modifyBody(modifyNpmInfoResponse);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} body
|
||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
function modifyNpmInfoResponseUnlessExcluded(body, headers) {
|
||||
const metadataPackageName = getPackageNameFromMetadataResponse(body, headers);
|
||||
|
||||
if (
|
||||
metadataPackageName &&
|
||||
isExcludedFromMinimumPackageAge(metadataPackageName)
|
||||
) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return modifyNpmInfoResponse(body, headers);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,26 +4,11 @@ import assert from "node:assert";
|
|||
describe("npmInterceptor minimum package age", async () => {
|
||||
let minimumPackageAgeSettings = 48;
|
||||
let skipMinimumPackageAgeSetting = false;
|
||||
let minimumPackageAgeExclusionsSetting = [];
|
||||
let newlyReleasedPackages = new Set();
|
||||
|
||||
mock.module("../../../config/settings.js", {
|
||||
namedExports: {
|
||||
ECOSYSTEM_JS: "js",
|
||||
ECOSYSTEM_PY: "py",
|
||||
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
|
||||
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
||||
getNpmCustomRegistries: () => [],
|
||||
getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
|
||||
getEcoSystem: () => "js",
|
||||
},
|
||||
});
|
||||
mock.module("../../../scanning/newPackagesListCache.js", {
|
||||
namedExports: {
|
||||
openNewPackagesDatabase: async () => ({
|
||||
isNewlyReleasedPackage: (name, version) =>
|
||||
newlyReleasedPackages.has(`${name}@${version}`),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -371,274 +356,6 @@ describe("npmInterceptor minimum package age", async () => {
|
|||
assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0");
|
||||
});
|
||||
|
||||
it("Should suppress too-young versions on metadata requests without directly blocking the request", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(packageUrl);
|
||||
const requestHandler = await interceptor.handleRequest(packageUrl);
|
||||
|
||||
assert.equal(requestHandler.blockResponse, undefined);
|
||||
assert.equal(requestHandler.modifiesResponse(), true);
|
||||
});
|
||||
|
||||
it("Should directly block tarball requests when the new packages list marks them as too young", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
|
||||
const packageUrl =
|
||||
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(packageUrl);
|
||||
const requestHandler = await interceptor.handleRequest(packageUrl);
|
||||
|
||||
assert.ok(requestHandler.blockResponse);
|
||||
assert.equal(requestHandler.modifiesResponse(), false);
|
||||
assert.equal(requestHandler.blockResponse.statusCode, 403);
|
||||
assert.equal(
|
||||
requestHandler.blockResponse.message,
|
||||
"Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)"
|
||||
);
|
||||
});
|
||||
|
||||
it("Should not block tarball requests when skipMinimumPackageAge is enabled", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = true;
|
||||
minimumPackageAgeExclusionsSetting = [];
|
||||
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
|
||||
const packageUrl =
|
||||
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(packageUrl);
|
||||
const requestHandler = await interceptor.handleRequest(packageUrl);
|
||||
|
||||
assert.equal(requestHandler.blockResponse, undefined);
|
||||
assert.equal(requestHandler.modifiesResponse(), false);
|
||||
});
|
||||
|
||||
it("Should not block tarball requests when the package is excluded from minimum age", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = ["lodash"];
|
||||
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
|
||||
const packageUrl =
|
||||
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(packageUrl);
|
||||
const requestHandler = await interceptor.handleRequest(packageUrl);
|
||||
|
||||
assert.equal(requestHandler.blockResponse, undefined);
|
||||
assert.equal(requestHandler.modifiesResponse(), false);
|
||||
});
|
||||
|
||||
it("Should not filter packages when package is in exclusion list", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = ["lodash"];
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const originalBody = JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: {
|
||||
latest: "3.0.0",
|
||||
},
|
||||
versions: {
|
||||
["1.0.0"]: {},
|
||||
["2.0.0"]: {},
|
||||
["3.0.0"]: {},
|
||||
},
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-3),
|
||||
["1.0.0"]: getDate(-7),
|
||||
// cutoff-date here
|
||||
["2.0.0"]: getDate(-4),
|
||||
["3.0.0"]: getDate(-3), // Would normally be filtered
|
||||
},
|
||||
});
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody);
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// All versions should remain unchanged since lodash is excluded
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 3);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0"));
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("3.0.0"));
|
||||
assert.equal(modifiedJson["dist-tags"]["latest"], "3.0.0");
|
||||
});
|
||||
|
||||
it("Should filter packages when package is NOT in exclusion list", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = ["react"]; // Different package
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: { latest: "3.0.0" },
|
||||
versions: { ["1.0.0"]: {}, ["3.0.0"]: {} },
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-3),
|
||||
["1.0.0"]: getDate(-7),
|
||||
["3.0.0"]: getDate(-3),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// lodash should still be filtered since it's not in exclusions
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 1);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0"));
|
||||
});
|
||||
|
||||
it("Should handle scoped packages in exclusion list", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = ["@babel/core"];
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/@babel/core";
|
||||
|
||||
const originalBody = JSON.stringify({
|
||||
name: "@babel/core",
|
||||
["dist-tags"]: { latest: "7.0.0" },
|
||||
versions: { ["6.0.0"]: {}, ["7.0.0"]: {} },
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-1),
|
||||
["6.0.0"]: getDate(-100),
|
||||
["7.0.0"]: getDate(-1), // Would normally be filtered
|
||||
},
|
||||
});
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody);
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// All versions should remain for excluded scoped package
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 2);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("6.0.0"));
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("7.0.0"));
|
||||
});
|
||||
|
||||
it("Should handle multiple packages in exclusion list", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = ["react", "lodash", "@types/node"];
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const originalBody = JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: { latest: "2.0.0" },
|
||||
versions: { ["1.0.0"]: {}, ["2.0.0"]: {} },
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-1),
|
||||
["1.0.0"]: getDate(-100),
|
||||
["2.0.0"]: getDate(-1),
|
||||
},
|
||||
});
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody);
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// All versions should remain since lodash is in the exclusion list
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 2);
|
||||
});
|
||||
|
||||
it("Should exclude packages matching wildcard pattern @scope/*", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = ["@aikidosec/*"];
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/@aikidosec/safe-chain";
|
||||
|
||||
const originalBody = JSON.stringify({
|
||||
name: "@aikidosec/safe-chain",
|
||||
["dist-tags"]: { latest: "2.0.0" },
|
||||
versions: { ["1.0.0"]: {}, ["2.0.0"]: {} },
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-1),
|
||||
["1.0.0"]: getDate(-100),
|
||||
["2.0.0"]: getDate(-1), // Would normally be filtered
|
||||
},
|
||||
});
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody);
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// All versions should remain since @aikidosec/* matches @aikidosec/safe-chain
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 2);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0"));
|
||||
});
|
||||
|
||||
it("Should NOT exclude packages that don't match wildcard pattern", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = ["@aikidosec/*"];
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/@other/package";
|
||||
|
||||
const originalBody = JSON.stringify({
|
||||
name: "@other/package",
|
||||
["dist-tags"]: { latest: "2.0.0" },
|
||||
versions: { ["1.0.0"]: {}, ["2.0.0"]: {} },
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-1),
|
||||
["1.0.0"]: getDate(-100),
|
||||
["2.0.0"]: getDate(-1),
|
||||
},
|
||||
});
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody);
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// Version 2.0.0 should be filtered since @other/package doesn't match @aikidosec/*
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 1);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
});
|
||||
|
||||
it("Should reset exclusions between tests", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = []; // Reset to empty
|
||||
newlyReleasedPackages = new Set();
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: { latest: "2.0.0" },
|
||||
versions: { ["1.0.0"]: {}, ["2.0.0"]: {} },
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-1),
|
||||
["1.0.0"]: getDate(-100),
|
||||
["2.0.0"]: getDate(-1),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// Version 2.0.0 should be filtered since exclusions are empty
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 1);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
});
|
||||
|
||||
function getDate(plusHours) {
|
||||
const date = new Date();
|
||||
date.setHours(date.getHours() + plusHours);
|
||||
|
|
|
|||
|
|
@ -1,57 +1,21 @@
|
|||
import { describe, it, mock, beforeEach } from "node:test";
|
||||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
let lastPackage;
|
||||
let malwareResponse = false;
|
||||
let customRegistries = [];
|
||||
let newlyReleasedPackages = new Set();
|
||||
let skipMinimumPackageAgeSetting = false;
|
||||
describe("npmInterceptor", async () => {
|
||||
let lastPackage;
|
||||
let malwareResponse = false;
|
||||
|
||||
mock.module("../../../scanning/audit/index.js", {
|
||||
mock.module("../../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async (packageName, version) => {
|
||||
lastPackage = { packageName, version };
|
||||
return malwareResponse;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../config/settings.js", {
|
||||
namedExports: {
|
||||
LOGGING_SILENT: "silent",
|
||||
LOGGING_NORMAL: "normal",
|
||||
LOGGING_VERBOSE: "verbose",
|
||||
ECOSYSTEM_JS: "js",
|
||||
ECOSYSTEM_PY: "py",
|
||||
getLoggingLevel: () => "normal",
|
||||
getEcoSystem: () => "js",
|
||||
setEcoSystem: () => {},
|
||||
getMinimumPackageAgeHours: () => 24,
|
||||
getNpmCustomRegistries: () => customRegistries,
|
||||
getMinimumPackageAgeExclusions: () => [],
|
||||
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
||||
},
|
||||
});
|
||||
mock.module("../../../scanning/newPackagesListCache.js", {
|
||||
namedExports: {
|
||||
openNewPackagesDatabase: async () => ({
|
||||
isNewlyReleasedPackage: (name, version) =>
|
||||
newlyReleasedPackages.has(`${name}@${version}`),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
describe("npmInterceptor", async () => {
|
||||
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
|
||||
|
||||
beforeEach(() => {
|
||||
lastPackage = undefined;
|
||||
malwareResponse = false;
|
||||
customRegistries = [];
|
||||
newlyReleasedPackages = new Set();
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
});
|
||||
|
||||
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
|
||||
|
||||
const parserCases = [
|
||||
// Regular packages
|
||||
{
|
||||
|
|
@ -127,10 +91,6 @@ describe("npmInterceptor", async () => {
|
|||
url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz",
|
||||
expected: { packageName: "@babel/core", version: "7.21.4" },
|
||||
},
|
||||
{
|
||||
url: "https://registry.yarnpkg.com/@music-i18n%2fverovio/-/verovio-1.4.1.tgz",
|
||||
expected: { packageName: "@music-i18n/verovio", version: "1.4.1" },
|
||||
},
|
||||
// URL to get package info, not tarball
|
||||
{
|
||||
url: "https://registry.npmjs.org/lodash",
|
||||
|
|
@ -200,121 +160,4 @@ describe("npmInterceptor", async () => {
|
|||
"Block response should have correct status message"
|
||||
);
|
||||
});
|
||||
|
||||
it("should block direct tarball downloads for newly released packages", async () => {
|
||||
const url =
|
||||
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123";
|
||||
malwareResponse = false;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.ok(result.blockResponse);
|
||||
assert.equal(result.blockResponse.statusCode, 403);
|
||||
assert.equal(
|
||||
result.blockResponse.message,
|
||||
"Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)"
|
||||
);
|
||||
});
|
||||
|
||||
it("should not block direct tarball downloads when minimum age checks are skipped", async () => {
|
||||
const url = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
|
||||
malwareResponse = false;
|
||||
skipMinimumPackageAgeSetting = true;
|
||||
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.equal(result.blockResponse, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("npmInterceptor with custom registries", async () => {
|
||||
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
|
||||
|
||||
it("should create interceptor for custom registry", async () => {
|
||||
// Set custom registries for this test
|
||||
customRegistries = ["npm.company.com", "registry.internal.net"];
|
||||
const url = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
|
||||
assert.ok(interceptor, "Interceptor should be created for custom registry");
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "lodash",
|
||||
version: "4.17.21",
|
||||
});
|
||||
});
|
||||
|
||||
it("should create interceptor for custom registry with scoped packages", async () => {
|
||||
// Set custom registries for this test
|
||||
customRegistries = ["npm.company.com", "registry.internal.net"];
|
||||
malwareResponse = false;
|
||||
|
||||
const url =
|
||||
"https://registry.internal.net/@company/package/-/package-1.0.0.tgz";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
|
||||
assert.ok(
|
||||
interceptor,
|
||||
"Interceptor should be created for custom registry with scoped package"
|
||||
);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "@company/package",
|
||||
version: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple custom registries", async () => {
|
||||
// Set custom registries for this test
|
||||
customRegistries = ["npm.company.com", "registry.internal.net"];
|
||||
malwareResponse = false;
|
||||
|
||||
const url1 = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz";
|
||||
const url2 = "https://registry.internal.net/express/-/express-4.18.2.tgz";
|
||||
|
||||
const interceptor1 = npmInterceptorForUrl(url1);
|
||||
const interceptor2 = npmInterceptorForUrl(url2);
|
||||
|
||||
assert.ok(interceptor1, "Should create interceptor for first registry");
|
||||
assert.ok(interceptor2, "Should create interceptor for second registry");
|
||||
|
||||
await interceptor1.handleRequest(url1);
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "lodash",
|
||||
version: "4.17.21",
|
||||
});
|
||||
|
||||
await interceptor2.handleRequest(url2);
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "express",
|
||||
version: "4.18.2",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create interceptor for non-custom registry", () => {
|
||||
// Set custom registries for this test
|
||||
customRegistries = ["npm.company.com", "registry.internal.net"];
|
||||
malwareResponse = false;
|
||||
|
||||
const url = "https://unknown.registry.com/package/-/package-1.0.0.tgz";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
|
||||
assert.equal(
|
||||
interceptor,
|
||||
undefined,
|
||||
"Should not create interceptor for unknown registry"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,29 +5,12 @@
|
|||
*/
|
||||
export function parseNpmPackageUrl(url, registry) {
|
||||
let packageName, version;
|
||||
let parsedUrl;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
if (!registry || !url.endsWith(".tgz")) {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
const pathname = parsedUrl.pathname;
|
||||
|
||||
if (!registry || !pathname.endsWith(".tgz")) {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
const registryPrefix = `${registry}/`;
|
||||
const urlAfterProtocol = `${parsedUrl.host}${pathname}`;
|
||||
if (!urlAfterProtocol.startsWith(registryPrefix)) {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
const afterRegistry = decodeURIComponent(
|
||||
urlAfterProtocol.substring(registryPrefix.length)
|
||||
);
|
||||
const registryIndex = url.indexOf(registry);
|
||||
const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
|
||||
|
||||
const separatorIndex = afterRegistry.indexOf("/-/");
|
||||
if (separatorIndex === -1) {
|
||||
|
|
|
|||
|
|
@ -1,184 +0,0 @@
|
|||
import { ui } from "../../../environment/userInteraction.js";
|
||||
import { clearCachingHeaders } from "../../http-utils.js";
|
||||
import { normalizePipPackageName } from "../../../scanning/packageNameVariants.js";
|
||||
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
|
||||
export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.js";
|
||||
import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js";
|
||||
import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js";
|
||||
|
||||
/**
|
||||
* Strip conditional GET headers so PyPI always returns a full 200 response
|
||||
* with a body we can rewrite. Without this, pip sends If-None-Match /
|
||||
* If-Modified-Since, PyPI responds 304 Not Modified (empty body), and
|
||||
* safe-chain cannot rewrite it — leaving pip with a cached index that still
|
||||
* lists too-young versions. Those versions are then blocked at direct-download
|
||||
* time with a hard 403, preventing dependency resolution from completing.
|
||||
*
|
||||
* @param {NodeJS.Dict<string | string[]>} headers
|
||||
* @returns {NodeJS.Dict<string | string[]>}
|
||||
*/
|
||||
export function modifyPipInfoRequestHeaders(headers) {
|
||||
delete headers["if-none-match"];
|
||||
delete headers["if-modified-since"];
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Match simple-index anchor tags and capture their href so we can suppress
|
||||
// individual distribution links from PyPI HTML metadata responses.
|
||||
const HTML_ANCHOR_HREF_RE =
|
||||
/<a\b[^>]*href\s*=\s*(["'])([^"']+)\1[^>]*>[\s\S]*?<\/a>/gi;
|
||||
|
||||
/**
|
||||
* @param {Buffer} body
|
||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||
* @param {string} metadataUrl
|
||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||
* @param {string} packageName
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function modifyPipInfoResponse(
|
||||
body,
|
||||
headers,
|
||||
metadataUrl,
|
||||
isNewlyReleasedPackage,
|
||||
packageName
|
||||
) {
|
||||
try {
|
||||
const contentType = getPipMetadataContentType(headers);
|
||||
|
||||
if (!contentType || body.byteLength === 0) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (
|
||||
contentType.includes("html") ||
|
||||
contentType.includes("application/vnd.pypi.simple.v1+html")
|
||||
) {
|
||||
return modifyHtmlSimpleResponse(
|
||||
body,
|
||||
headers,
|
||||
metadataUrl,
|
||||
isNewlyReleasedPackage,
|
||||
packageName
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
contentType.includes("json") ||
|
||||
contentType.includes("application/vnd.pypi.simple.v1+json")
|
||||
) {
|
||||
return modifyJsonResponse(
|
||||
body,
|
||||
headers,
|
||||
metadataUrl,
|
||||
isNewlyReleasedPackage,
|
||||
packageName
|
||||
);
|
||||
}
|
||||
|
||||
return body;
|
||||
} catch (/** @type {any} */ err) {
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: PyPI package metadata not in expected format - bypassing modification. Error: ${err.message}`
|
||||
);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} body
|
||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||
* @param {string} metadataUrl
|
||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||
* @param {string} packageName
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
function modifyHtmlSimpleResponse(
|
||||
body,
|
||||
headers,
|
||||
metadataUrl,
|
||||
isNewlyReleasedPackage,
|
||||
packageName
|
||||
) {
|
||||
const html = body.toString("utf8");
|
||||
let modified = false;
|
||||
const rewriteHtmlAnchor = createHtmlAnchorRewriter(
|
||||
metadataUrl,
|
||||
isNewlyReleasedPackage,
|
||||
packageName,
|
||||
() => {
|
||||
modified = true;
|
||||
}
|
||||
);
|
||||
const updatedHtml = html.replace(HTML_ANCHOR_HREF_RE, rewriteHtmlAnchor);
|
||||
|
||||
if (!modified) return body;
|
||||
const modifiedBuffer = Buffer.from(updatedHtml);
|
||||
clearCachingHeaders(headers);
|
||||
return modifiedBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} metadataUrl
|
||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||
* @param {string} packageName
|
||||
* @param {() => void} onModified
|
||||
* @returns {(anchor: string, quote: string, href: string) => string}
|
||||
*/
|
||||
function createHtmlAnchorRewriter(
|
||||
metadataUrl,
|
||||
isNewlyReleasedPackage,
|
||||
packageName,
|
||||
onModified
|
||||
) {
|
||||
return (anchor, _quote, href) => {
|
||||
const resolvedHref = new URL(href, metadataUrl).toString();
|
||||
const { packageName: hrefPackageName, version } = parsePipPackageFromUrl(
|
||||
resolvedHref,
|
||||
new URL(resolvedHref).host
|
||||
);
|
||||
|
||||
if (
|
||||
hrefPackageName &&
|
||||
normalizePipPackageName(hrefPackageName) ===
|
||||
normalizePipPackageName(packageName) &&
|
||||
version &&
|
||||
isNewlyReleasedPackage(packageName, version)
|
||||
) {
|
||||
onModified();
|
||||
logSuppressedVersion(packageName, version);
|
||||
return "";
|
||||
}
|
||||
|
||||
return anchor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} body
|
||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||
* @param {string} metadataUrl
|
||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||
* @param {string} packageName
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
function modifyJsonResponse(
|
||||
body,
|
||||
headers,
|
||||
metadataUrl,
|
||||
isNewlyReleasedPackage,
|
||||
packageName
|
||||
) {
|
||||
const json = JSON.parse(body.toString("utf8"));
|
||||
const modified = modifyPipJsonResponse(
|
||||
json,
|
||||
metadataUrl,
|
||||
isNewlyReleasedPackage,
|
||||
packageName
|
||||
);
|
||||
|
||||
if (!modified) return body;
|
||||
const modifiedBuffer = Buffer.from(JSON.stringify(json));
|
||||
clearCachingHeaders(headers);
|
||||
return modifiedBuffer;
|
||||
}
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("modifyPipInfo", async () => {
|
||||
mock.module("../../../config/settings.js", {
|
||||
namedExports: {
|
||||
getMinimumPackageAgeHours: () => 48,
|
||||
ECOSYSTEM_PY: "py",
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../environment/userInteraction.js", {
|
||||
namedExports: {
|
||||
ui: {
|
||||
writeVerbose: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
modifyPipInfoResponse,
|
||||
} = await import("./modifyPipInfo.js");
|
||||
|
||||
it("removes too-young files from simple HTML metadata", () => {
|
||||
const headers = {
|
||||
"content-type": "application/vnd.pypi.simple.v1+html",
|
||||
etag: "abc",
|
||||
"cache-control": "public",
|
||||
"content-length": "999",
|
||||
"transfer-encoding": "chunked",
|
||||
};
|
||||
|
||||
const body = Buffer.from(`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<a href="https://files.pythonhosted.org/packages/source/r/requests/requests-1.0.0.tar.gz">requests-1.0.0.tar.gz</a>
|
||||
<a href="https://files.pythonhosted.org/packages/source/r/requests/requests-2.0.0.tar.gz">requests-2.0.0.tar.gz</a>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const modified = modifyPipInfoResponse(
|
||||
body,
|
||||
headers,
|
||||
"https://pypi.org/simple/requests/",
|
||||
(_packageName, version) => version === "2.0.0",
|
||||
"requests"
|
||||
).toString("utf8");
|
||||
|
||||
assert.ok(modified.includes("requests-1.0.0.tar.gz"));
|
||||
assert.ok(!modified.includes("requests-2.0.0.tar.gz"));
|
||||
assert.equal(headers.etag, undefined);
|
||||
assert.equal(headers["cache-control"], undefined);
|
||||
assert.equal(headers["content-length"], undefined);
|
||||
assert.equal(headers["transfer-encoding"], "chunked");
|
||||
});
|
||||
|
||||
it("leaves mixed-case transport headers untouched for MITM layer to normalize", () => {
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
ETag: "abc",
|
||||
"Content-Length": "999",
|
||||
"Last-Modified": "yesterday",
|
||||
"Cache-Control": "public, max-age=60",
|
||||
"Transfer-Encoding": "chunked",
|
||||
};
|
||||
|
||||
const body = Buffer.from(
|
||||
JSON.stringify({
|
||||
info: { version: "2.0.0" },
|
||||
releases: {
|
||||
"1.0.0": [{ filename: "requests-1.0.0.tar.gz" }],
|
||||
"2.0.0": [{ filename: "requests-2.0.0.tar.gz" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
modifyPipInfoResponse(
|
||||
body,
|
||||
headers,
|
||||
"https://pypi.org/pypi/requests/json",
|
||||
(_packageName, version) => version === "2.0.0",
|
||||
"requests"
|
||||
);
|
||||
|
||||
assert.equal(headers.ETag, "abc");
|
||||
assert.equal(headers["Last-Modified"], "yesterday");
|
||||
assert.equal(headers["Cache-Control"], "public, max-age=60");
|
||||
assert.equal(headers["Transfer-Encoding"], "chunked");
|
||||
assert.equal(headers["Content-Length"], "999");
|
||||
assert.equal(headers["content-length"], undefined);
|
||||
});
|
||||
|
||||
it("returns body unchanged when no HTML versions are suppressed", () => {
|
||||
const headers = {
|
||||
"content-type": "application/vnd.pypi.simple.v1+html",
|
||||
etag: "abc",
|
||||
};
|
||||
|
||||
const body = Buffer.from(
|
||||
`<a href="https://files.pythonhosted.org/packages/source/r/requests/requests-1.0.0.tar.gz">requests-1.0.0.tar.gz</a>`
|
||||
);
|
||||
|
||||
const result = modifyPipInfoResponse(
|
||||
body,
|
||||
headers,
|
||||
"https://pypi.org/simple/requests/",
|
||||
() => false,
|
||||
"requests"
|
||||
);
|
||||
|
||||
assert.equal(result, body); // same Buffer reference — no copy made
|
||||
assert.equal(headers.etag, "abc"); // headers untouched
|
||||
});
|
||||
|
||||
it("matches HTML anchor hrefs using normalised package name (underscore vs hyphen)", () => {
|
||||
const headers = { "content-type": "application/vnd.pypi.simple.v1+html" };
|
||||
|
||||
const body = Buffer.from(
|
||||
`<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz">foo_bar-2.0.0.tar.gz</a>` +
|
||||
`<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-1.0.0.tar.gz">foo_bar-1.0.0.tar.gz</a>`
|
||||
);
|
||||
|
||||
const modified = modifyPipInfoResponse(
|
||||
body,
|
||||
headers,
|
||||
"https://pypi.org/simple/foo-bar/",
|
||||
(_packageName, version) => version === "2.0.0",
|
||||
"foo-bar" // hyphenated name, hrefs use underscore
|
||||
).toString("utf8");
|
||||
|
||||
assert.ok(!modified.includes("foo_bar-2.0.0.tar.gz"));
|
||||
assert.ok(modified.includes("foo_bar-1.0.0.tar.gz"));
|
||||
});
|
||||
|
||||
it("matches anchor href regex with single quotes and extra attributes", () => {
|
||||
const headers = { "content-type": "application/vnd.pypi.simple.v1+html" };
|
||||
|
||||
const body = Buffer.from(`
|
||||
<a
|
||||
data-requires-python=">=3.9"
|
||||
class="pkg"
|
||||
href='https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz'
|
||||
>
|
||||
foo_bar-2.0.0.tar.gz
|
||||
</a>
|
||||
<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-1.0.0.tar.gz">foo_bar-1.0.0.tar.gz</a>
|
||||
`);
|
||||
|
||||
const modified = modifyPipInfoResponse(
|
||||
body,
|
||||
headers,
|
||||
"https://pypi.org/simple/foo-bar/",
|
||||
(_packageName, version) => version === "2.0.0",
|
||||
"foo-bar"
|
||||
).toString("utf8");
|
||||
|
||||
assert.ok(!modified.includes("foo_bar-2.0.0.tar.gz"));
|
||||
assert.ok(modified.includes("foo_bar-1.0.0.tar.gz"));
|
||||
});
|
||||
|
||||
it("removes too-young files from simple JSON metadata", () => {
|
||||
const headers = {
|
||||
"content-type": "application/vnd.pypi.simple.v1+json",
|
||||
};
|
||||
|
||||
const body = Buffer.from(
|
||||
JSON.stringify({
|
||||
name: "requests",
|
||||
files: [
|
||||
{
|
||||
filename: "requests-1.0.0.tar.gz",
|
||||
url: "https://files.pythonhosted.org/packages/source/r/requests/requests-1.0.0.tar.gz",
|
||||
},
|
||||
{
|
||||
filename: "requests-2.0.0.tar.gz",
|
||||
url: "https://files.pythonhosted.org/packages/source/r/requests/requests-2.0.0.tar.gz",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const modified = JSON.parse(
|
||||
modifyPipInfoResponse(
|
||||
body,
|
||||
headers,
|
||||
"https://pypi.org/simple/requests/",
|
||||
(_packageName, version) => version === "2.0.0",
|
||||
"requests"
|
||||
).toString("utf8")
|
||||
);
|
||||
|
||||
assert.equal(modified.files.length, 1);
|
||||
assert.equal(modified.files[0].filename, "requests-1.0.0.tar.gz");
|
||||
});
|
||||
|
||||
it("filters simple JSON metadata entries that have only filename (no url)", () => {
|
||||
const headers = { "content-type": "application/vnd.pypi.simple.v1+json" };
|
||||
|
||||
const body = Buffer.from(
|
||||
JSON.stringify({
|
||||
name: "requests",
|
||||
files: [
|
||||
{ filename: "requests-1.0.0.tar.gz" },
|
||||
{ filename: "requests-2.0.0.tar.gz" },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const modified = JSON.parse(
|
||||
modifyPipInfoResponse(
|
||||
body,
|
||||
headers,
|
||||
"https://pypi.org/simple/requests/",
|
||||
(_packageName, version) => version === "2.0.0",
|
||||
"requests"
|
||||
).toString("utf8")
|
||||
);
|
||||
|
||||
assert.equal(modified.files.length, 1);
|
||||
assert.equal(modified.files[0].filename, "requests-1.0.0.tar.gz");
|
||||
});
|
||||
|
||||
it("recalculates JSON API info.version after removing too-young releases", () => {
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
const body = Buffer.from(
|
||||
JSON.stringify({
|
||||
info: { version: "2.0.0" },
|
||||
releases: {
|
||||
"1.0.0": [
|
||||
{
|
||||
filename: "requests-1.0.0.tar.gz",
|
||||
upload_time_iso_8601: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
"2.0.0": [
|
||||
{
|
||||
filename: "requests-2.0.0.tar.gz",
|
||||
upload_time_iso_8601: "2024-01-02T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
"3.0.0rc1": [
|
||||
{
|
||||
filename: "requests-3.0.0rc1.tar.gz",
|
||||
upload_time_iso_8601: "2024-01-03T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
urls: [
|
||||
{ filename: "requests-2.0.0.tar.gz" },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const modified = JSON.parse(
|
||||
modifyPipInfoResponse(
|
||||
body,
|
||||
headers,
|
||||
"https://pypi.org/pypi/requests/json",
|
||||
(_packageName, version) =>
|
||||
version === "2.0.0" || version === "3.0.0rc1",
|
||||
"requests"
|
||||
).toString("utf8")
|
||||
);
|
||||
|
||||
assert.deepEqual(Object.keys(modified.releases), ["1.0.0"]);
|
||||
assert.equal(modified.info.version, "1.0.0");
|
||||
assert.equal(modified.urls.length, 0);
|
||||
});
|
||||
|
||||
it("falls back to latest pre-release when all stable versions are removed", () => {
|
||||
const headers = { "content-type": "application/json" };
|
||||
|
||||
const body = Buffer.from(
|
||||
JSON.stringify({
|
||||
info: { version: "2.0.0rc2" },
|
||||
releases: {
|
||||
"1.0.0rc1": [{ filename: "requests-1.0.0rc1.tar.gz" }],
|
||||
"2.0.0rc2": [{ filename: "requests-2.0.0rc2.tar.gz" }],
|
||||
},
|
||||
urls: [],
|
||||
})
|
||||
);
|
||||
|
||||
const modified = JSON.parse(
|
||||
modifyPipInfoResponse(
|
||||
body,
|
||||
headers,
|
||||
"https://pypi.org/pypi/requests/json",
|
||||
(_packageName, version) => version === "2.0.0rc2",
|
||||
"requests"
|
||||
).toString("utf8")
|
||||
);
|
||||
|
||||
assert.deepEqual(Object.keys(modified.releases), ["1.0.0rc1"]);
|
||||
assert.equal(modified.info.version, "1.0.0rc1");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
import {
|
||||
calculateLatestVersion,
|
||||
getAvailableVersionsFromJson,
|
||||
getPackageVersionFromMetadataFile,
|
||||
} from "./pipMetadataVersionUtils.js";
|
||||
import { logSuppressedVersion } from "./pipMetadataResponseUtils.js";
|
||||
|
||||
/**
|
||||
* @param {any} json
|
||||
* @param {string} metadataUrl
|
||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||
* @param {string} packageName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function modifyPipJsonResponse(
|
||||
json,
|
||||
metadataUrl,
|
||||
isNewlyReleasedPackage,
|
||||
packageName
|
||||
) {
|
||||
const filesModified = filterJsonMetadataFiles(
|
||||
json,
|
||||
metadataUrl,
|
||||
isNewlyReleasedPackage,
|
||||
packageName
|
||||
);
|
||||
const releasesModified = removeJsonMetadataReleases(
|
||||
json,
|
||||
isNewlyReleasedPackage,
|
||||
packageName
|
||||
);
|
||||
const urlsModified = filterJsonMetadataUrls(
|
||||
json,
|
||||
metadataUrl,
|
||||
isNewlyReleasedPackage,
|
||||
packageName
|
||||
);
|
||||
const versionModified = updateJsonInfoVersion(json, metadataUrl);
|
||||
|
||||
return filesModified || releasesModified || urlsModified || versionModified;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} json
|
||||
* @param {string} metadataUrl
|
||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||
* @param {string} packageName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function filterJsonMetadataFiles(
|
||||
json,
|
||||
metadataUrl,
|
||||
isNewlyReleasedPackage,
|
||||
packageName
|
||||
) {
|
||||
if (!Array.isArray(json.files)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let modified = false;
|
||||
const loggedVersions = new Set();
|
||||
json.files = json.files.filter((/** @type {any} */ file) => {
|
||||
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
|
||||
|
||||
if (version && isNewlyReleasedPackage(packageName, version)) {
|
||||
modified = true;
|
||||
if (!loggedVersions.has(version)) {
|
||||
logSuppressedVersion(packageName, version);
|
||||
loggedVersions.add(version);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} json
|
||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||
* @param {string} packageName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) {
|
||||
if (!json.releases || typeof json.releases !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let modified = false;
|
||||
|
||||
for (const [version, files] of Object.entries(json.releases)) {
|
||||
if (
|
||||
Array.isArray(/** @type {unknown[]} */ (files)) &&
|
||||
isNewlyReleasedPackage(packageName, version)
|
||||
) {
|
||||
delete json.releases[version];
|
||||
modified = true;
|
||||
logSuppressedVersion(packageName, version);
|
||||
}
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} json
|
||||
* @param {string} metadataUrl
|
||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||
* @param {string} packageName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function filterJsonMetadataUrls(
|
||||
json,
|
||||
metadataUrl,
|
||||
isNewlyReleasedPackage,
|
||||
packageName
|
||||
) {
|
||||
if (!Array.isArray(json.urls)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let modified = false;
|
||||
const loggedVersions = new Set();
|
||||
json.urls = json.urls.filter((/** @type {any} */ file) => {
|
||||
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
|
||||
|
||||
if (version && isNewlyReleasedPackage(packageName, version)) {
|
||||
modified = true;
|
||||
if (!loggedVersions.has(version)) {
|
||||
logSuppressedVersion(packageName, version);
|
||||
loggedVersions.add(version);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} json
|
||||
* @param {string} metadataUrl
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function updateJsonInfoVersion(json, metadataUrl) {
|
||||
if (!json.info || typeof json.info !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const replacementVersion = computeReplacementVersion(json, metadataUrl);
|
||||
|
||||
if (
|
||||
typeof json.info.version !== "string" ||
|
||||
!replacementVersion ||
|
||||
json.info.version === replacementVersion
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
json.info.version = replacementVersion;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} json
|
||||
* @param {string} metadataUrl
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
function computeReplacementVersion(json, metadataUrl) {
|
||||
const candidateVersions = getAvailableVersionsFromJson(json, metadataUrl);
|
||||
return calculateLatestVersion(candidateVersions);
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
/**
|
||||
* Parses a PyPI metadata URL and returns the package name and API type.
|
||||
*
|
||||
* @example
|
||||
* parsePipMetadataUrl("https://pypi.org/simple/requests/")
|
||||
* // => { packageName: "requests", type: "simple" }
|
||||
*
|
||||
* parsePipMetadataUrl("https://pypi.org/pypi/requests/json")
|
||||
* // => { packageName: "requests", type: "json" }
|
||||
*
|
||||
* parsePipMetadataUrl("https://pypi.org/pypi/requests/2.28.1/json")
|
||||
* // => { packageName: "requests", type: "json" }
|
||||
*
|
||||
* parsePipMetadataUrl("https://files.pythonhosted.org/packages/requests-2.28.1.tar.gz")
|
||||
* // => { packageName: undefined, type: undefined }
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns {{ packageName: string | undefined, type: "simple" | "json" | undefined }}
|
||||
*/
|
||||
export function parsePipMetadataUrl(url) {
|
||||
if (typeof url !== "string") {
|
||||
return { packageName: undefined, type: undefined };
|
||||
}
|
||||
|
||||
let urlObj;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
return { packageName: undefined, type: undefined };
|
||||
}
|
||||
|
||||
const pathSegments = urlObj.pathname.split("/").filter(Boolean);
|
||||
if (pathSegments[0] === "simple" && pathSegments[1]) {
|
||||
return {
|
||||
packageName: decodeURIComponent(pathSegments[1]),
|
||||
type: "simple",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
pathSegments[0] === "pypi" &&
|
||||
pathSegments[pathSegments.length - 1] === "json" &&
|
||||
pathSegments[1]
|
||||
) {
|
||||
return {
|
||||
packageName: decodeURIComponent(pathSegments[1]),
|
||||
type: "json",
|
||||
};
|
||||
}
|
||||
|
||||
return { packageName: undefined, type: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPipPackageInfoUrl(url) {
|
||||
return !!parsePipMetadataUrl(url).packageName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Python package artifact URLs from PyPI-style registries.
|
||||
* Examples:
|
||||
* - Wheel: https://files.pythonhosted.org/packages/.../requests-2.28.1-py3-none-any.whl
|
||||
* - Wheel metadata: https://files.pythonhosted.org/packages/.../requests-2.28.1-py3-none-any.whl.metadata
|
||||
* - Sdist: https://files.pythonhosted.org/packages/.../requests-2.28.1.tar.gz
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} registry
|
||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||
*/
|
||||
export function parsePipPackageFromUrl(url, registry) {
|
||||
if (!registry || typeof url !== "string") {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
||||
let urlObj;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
||||
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
|
||||
if (!lastSegment) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
||||
const filename = decodeURIComponent(lastSegment);
|
||||
|
||||
const wheelExtRe = /\.whl(?:\.metadata)?$/;
|
||||
if (wheelExtRe.test(filename)) {
|
||||
return parseWheelFilename(filename, wheelExtRe);
|
||||
}
|
||||
|
||||
const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
|
||||
if (!sdistExtWithMetadataRe.test(filename)) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
||||
return parseSdistFilename(filename, sdistExtWithMetadataRe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse wheel filenames and Poetry preflight metadata.
|
||||
* Examples:
|
||||
* - foo_bar-2.0.0-py3-none-any.whl
|
||||
* - foo_bar-2.0.0-py3-none-any.whl.metadata
|
||||
*
|
||||
* @param {string} filename
|
||||
* @param {RegExp} wheelExtRe
|
||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||
*/
|
||||
function parseWheelFilename(filename, wheelExtRe) {
|
||||
const base = filename.replace(wheelExtRe, "");
|
||||
const firstDash = base.indexOf("-");
|
||||
if (firstDash <= 0) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
||||
const packageName = base.slice(0, firstDash);
|
||||
const rest = base.slice(firstDash + 1);
|
||||
const secondDash = rest.indexOf("-");
|
||||
const version = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
|
||||
|
||||
// "latest" is a resolver-style token, not an actual published artifact version.
|
||||
if (version === "latest" || !packageName || !version) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse source distribution filenames, with optional metadata suffix.
|
||||
* Examples:
|
||||
* - requests-2.28.1.tar.gz
|
||||
* - requests-2.28.1.zip
|
||||
* - requests-2.28.1.tar.gz.metadata
|
||||
*
|
||||
* @param {string} filename
|
||||
* @param {RegExp} sdistExtWithMetadataRe
|
||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||
*/
|
||||
function parseSdistFilename(filename, sdistExtWithMetadataRe) {
|
||||
const base = filename.replace(sdistExtWithMetadataRe, "");
|
||||
const lastDash = base.lastIndexOf("-");
|
||||
if (lastDash <= 0 || lastDash >= base.length - 1) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
||||
const packageName = base.slice(0, lastDash);
|
||||
const version = base.slice(lastDash + 1);
|
||||
|
||||
// "latest" is a resolver-style token, not an actual published artifact version.
|
||||
if (version === "latest" || !packageName || !version) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
||||
return { packageName, version };
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import {
|
||||
isPipPackageInfoUrl,
|
||||
parsePipMetadataUrl,
|
||||
parsePipPackageFromUrl,
|
||||
} from "./parsePipPackageUrl.js";
|
||||
|
||||
describe("parsePipPackageUrl", () => {
|
||||
it("parses simple metadata URLs", () => {
|
||||
assert.deepEqual(parsePipMetadataUrl("https://pypi.org/simple/requests/"), {
|
||||
packageName: "requests",
|
||||
type: "simple",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses json metadata URLs", () => {
|
||||
assert.deepEqual(parsePipMetadataUrl("https://pypi.org/pypi/requests/json"), {
|
||||
packageName: "requests",
|
||||
type: "json",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses per-version json metadata URLs", () => {
|
||||
assert.deepEqual(
|
||||
parsePipMetadataUrl("https://pypi.org/pypi/requests/2.28.1/json"),
|
||||
{ packageName: "requests", type: "json" }
|
||||
);
|
||||
});
|
||||
|
||||
it("decodes encoded metadata package names", () => {
|
||||
assert.deepEqual(
|
||||
parsePipMetadataUrl("https://pypi.org/simple/foo-bar%5Fbaz/"),
|
||||
{
|
||||
packageName: "foo-bar_baz",
|
||||
type: "simple",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for unrecognized metadata paths", () => {
|
||||
assert.deepEqual(
|
||||
parsePipMetadataUrl("https://pypi.org/unknown/requests/"),
|
||||
{
|
||||
packageName: undefined,
|
||||
type: undefined,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for invalid metadata URLs", () => {
|
||||
assert.deepEqual(parsePipMetadataUrl("not a url"), {
|
||||
packageName: undefined,
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes package info URLs", () => {
|
||||
assert.equal(
|
||||
isPipPackageInfoUrl("https://pypi.org/simple/requests/"),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat artifact URLs as package info URLs", () => {
|
||||
assert.equal(
|
||||
isPipPackageInfoUrl(
|
||||
"https://files.pythonhosted.org/packages/source/r/requests/requests-2.28.1.tar.gz"
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("parses wheel artifact URLs", () => {
|
||||
assert.deepEqual(
|
||||
parsePipPackageFromUrl(
|
||||
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl",
|
||||
"files.pythonhosted.org"
|
||||
),
|
||||
{ packageName: "foo_bar", version: "2.0.0" }
|
||||
);
|
||||
});
|
||||
|
||||
it("parses sdist artifact URLs", () => {
|
||||
assert.deepEqual(
|
||||
parsePipPackageFromUrl(
|
||||
"https://files.pythonhosted.org/packages/source/r/requests/requests-2.28.1.tar.gz",
|
||||
"files.pythonhosted.org"
|
||||
),
|
||||
{ packageName: "requests", version: "2.28.1" }
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for non-artifact URLs", () => {
|
||||
assert.deepEqual(
|
||||
parsePipPackageFromUrl("https://pypi.org/simple/requests/", "pypi.org"),
|
||||
{ packageName: undefined, version: undefined }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("pipInterceptor custom registries", async () => {
|
||||
let scannedPackages;
|
||||
let malwareResponse = false;
|
||||
let customRegistries = [];
|
||||
|
||||
mock.module("../../../config/settings.js", {
|
||||
namedExports: {
|
||||
ECOSYSTEM_PY: "py",
|
||||
getEcoSystem: () => "py",
|
||||
getLoggingLevel: () => "silent",
|
||||
getMinimumPackageAgeHours: () => 48,
|
||||
getMinimumPackageAgeExclusions: () => [],
|
||||
getPipCustomRegistries: () => customRegistries,
|
||||
LOGGING_SILENT: "silent",
|
||||
LOGGING_VERBOSE: "verbose",
|
||||
skipMinimumPackageAge: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../scanning/newPackagesListCache.js", {
|
||||
namedExports: {
|
||||
openNewPackagesDatabase: async () => ({
|
||||
isNewlyReleasedPackage: () => false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async (packageName, version) => {
|
||||
scannedPackages.push({ packageName, version });
|
||||
return malwareResponse;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
|
||||
|
||||
it("should create interceptor for custom registry", () => {
|
||||
customRegistries = ["my-custom-registry.example.com"];
|
||||
const url =
|
||||
"https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
assert.ok(interceptor);
|
||||
});
|
||||
|
||||
it("should parse package from custom registry URL", async () => {
|
||||
scannedPackages = [];
|
||||
customRegistries = ["my-custom-registry.example.com"];
|
||||
const url =
|
||||
"https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.ok(
|
||||
scannedPackages.some(
|
||||
({ packageName, version }) =>
|
||||
packageName === "foobar" && version === "1.2.3"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("should parse wheel package from custom registry URL", async () => {
|
||||
scannedPackages = [];
|
||||
customRegistries = ["private-pypi.internal.com"];
|
||||
const url =
|
||||
"https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.ok(
|
||||
scannedPackages.some(
|
||||
({ packageName, version }) =>
|
||||
packageName === "foo-bar" && version === "2.0.0"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle multiple custom registries", async () => {
|
||||
customRegistries = [
|
||||
"registry-one.example.com",
|
||||
"registry-two.example.com",
|
||||
];
|
||||
|
||||
const url1 =
|
||||
"https://registry-one.example.com/packages/package1-1.0.0.tar.gz";
|
||||
const url2 =
|
||||
"https://registry-two.example.com/packages/package2-2.0.0.tar.gz";
|
||||
|
||||
const interceptor1 = pipInterceptorForUrl(url1);
|
||||
const interceptor2 = pipInterceptorForUrl(url2);
|
||||
|
||||
assert.ok(interceptor1);
|
||||
assert.ok(interceptor2);
|
||||
});
|
||||
|
||||
it("should block malicious package from custom registry", async () => {
|
||||
scannedPackages = [];
|
||||
customRegistries = ["my-custom-registry.example.com"];
|
||||
malwareResponse = true;
|
||||
|
||||
const url =
|
||||
"https://my-custom-registry.example.com/packages/malicious_package-1.0.0.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor);
|
||||
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.ok(result.blockResponse);
|
||||
assert.equal(result.blockResponse.statusCode, 403);
|
||||
assert.equal(result.blockResponse.message, "Forbidden - blocked by safe-chain");
|
||||
|
||||
malwareResponse = false;
|
||||
});
|
||||
|
||||
it("should still work with known registries when custom registries are set", async () => {
|
||||
scannedPackages = [];
|
||||
customRegistries = ["my-custom-registry.example.com"];
|
||||
|
||||
const url =
|
||||
"https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
assert.ok(interceptor);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.ok(
|
||||
scannedPackages.some(
|
||||
({ packageName, version }) =>
|
||||
packageName === "foobar" && version === "1.2.3"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("should not create interceptor for unknown registry when custom registries are set", () => {
|
||||
customRegistries = ["my-custom-registry.example.com"];
|
||||
const url = "https://unknown-registry.example.com/packages/foobar-1.0.0.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
assert.equal(interceptor, undefined);
|
||||
});
|
||||
|
||||
it("should handle empty custom registries array", () => {
|
||||
customRegistries = [];
|
||||
const url =
|
||||
"https://my-custom-registry.example.com/packages/foobar-1.0.0.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
assert.equal(interceptor, undefined);
|
||||
});
|
||||
|
||||
it("should parse .whl.metadata from custom registry", async () => {
|
||||
scannedPackages = [];
|
||||
customRegistries = ["private-pypi.internal.com"];
|
||||
const url =
|
||||
"https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl.metadata";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.ok(
|
||||
scannedPackages.some(
|
||||
({ packageName, version }) =>
|
||||
packageName === "foo-bar" && version === "2.0.0"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("should parse .tar.gz.metadata from custom registry", async () => {
|
||||
scannedPackages = [];
|
||||
customRegistries = ["private-pypi.internal.com"];
|
||||
const url =
|
||||
"https://private-pypi.internal.com/packages/foo_bar-2.0.0.tar.gz.metadata";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.ok(
|
||||
scannedPackages.some(
|
||||
({ packageName, version }) =>
|
||||
packageName === "foo-bar" && version === "2.0.0"
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import {
|
||||
ECOSYSTEM_PY,
|
||||
getPipCustomRegistries,
|
||||
skipMinimumPackageAge,
|
||||
} from "../../../config/settings.js";
|
||||
import { isMalwarePackage } from "../../../scanning/audit/index.js";
|
||||
import { getEquivalentPackageNames } from "../../../scanning/packageNameVariants.js";
|
||||
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
|
||||
import { interceptRequests } from "../interceptorBuilder.js";
|
||||
import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js";
|
||||
import {
|
||||
modifyPipInfoRequestHeaders,
|
||||
modifyPipInfoResponse,
|
||||
parsePipMetadataUrl,
|
||||
} from "./modifyPipInfo.js";
|
||||
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
|
||||
|
||||
const knownPipRegistries = [
|
||||
"files.pythonhosted.org",
|
||||
"pypi.org",
|
||||
"pypi.python.org",
|
||||
"pythonhosted.org",
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
export function pipInterceptorForUrl(url) {
|
||||
const customRegistries = getPipCustomRegistries();
|
||||
const registries = [...knownPipRegistries, ...customRegistries];
|
||||
const registry = registries.find((reg) => url.includes(reg));
|
||||
|
||||
if (registry) {
|
||||
return buildPipInterceptor(registry);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} registry
|
||||
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
function buildPipInterceptor(registry) {
|
||||
return interceptRequests(createPipRequestHandler(registry));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} registry
|
||||
* @returns {(reqContext: import("../interceptorBuilder.js").RequestInterceptionContext) => Promise<void>}
|
||||
*/
|
||||
function createPipRequestHandler(registry) {
|
||||
return async (reqContext) => {
|
||||
const minimumAgeChecksEnabled = !skipMinimumPackageAge();
|
||||
const metadataInfo = parsePipMetadataUrl(reqContext.targetUrl);
|
||||
const metadataPackageName = metadataInfo.packageName;
|
||||
|
||||
if (
|
||||
minimumAgeChecksEnabled &&
|
||||
metadataPackageName &&
|
||||
!isExcludedFromMinimumPackageAge(metadataPackageName)
|
||||
) {
|
||||
const newPackagesDatabase = await openNewPackagesDatabase();
|
||||
reqContext.modifyRequestHeaders(modifyPipInfoRequestHeaders);
|
||||
reqContext.modifyBody((body, headers) =>
|
||||
modifyPipInfoResponse(
|
||||
body,
|
||||
headers,
|
||||
reqContext.targetUrl,
|
||||
newPackagesDatabase.isNewlyReleasedPackage,
|
||||
metadataPackageName
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { packageName, version } = parsePipPackageFromUrl(
|
||||
reqContext.targetUrl,
|
||||
registry
|
||||
);
|
||||
|
||||
if (!packageName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const equivalentPackageNames = getEquivalentPackageNames(
|
||||
packageName,
|
||||
ECOSYSTEM_PY
|
||||
);
|
||||
let isMalicious = false;
|
||||
for (const equivalentPackageName of equivalentPackageNames) {
|
||||
if (await isMalwarePackage(equivalentPackageName, version)) {
|
||||
isMalicious = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMalicious) {
|
||||
reqContext.blockMalware(packageName, version);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
version &&
|
||||
minimumAgeChecksEnabled &&
|
||||
!isExcludedFromMinimumPackageAge(packageName)
|
||||
) {
|
||||
const newPackagesDatabase = await openNewPackagesDatabase();
|
||||
const isNewlyReleased = newPackagesDatabase.isNewlyReleasedPackage(
|
||||
packageName,
|
||||
version
|
||||
);
|
||||
|
||||
if (isNewlyReleased) {
|
||||
reqContext.blockMinimumAgeRequest(
|
||||
packageName,
|
||||
version,
|
||||
`Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("pipInterceptor minimum package age", async () => {
|
||||
let skipMinimumPackageAgeSetting = false;
|
||||
let newlyReleasedPackageResponse = false;
|
||||
let minimumPackageAgeExclusionsSetting = [];
|
||||
|
||||
mock.module("../../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async () => false,
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../scanning/newPackagesListCache.js", {
|
||||
namedExports: {
|
||||
openNewPackagesDatabase: async () => ({
|
||||
isNewlyReleasedPackage: (packageName, version) => {
|
||||
return newlyReleasedPackageResponse &&
|
||||
(packageName === "foo-bar" ||
|
||||
packageName === "foo_bar" ||
|
||||
packageName === "foo.bar") &&
|
||||
version === "2.0.0";
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../config/settings.js", {
|
||||
namedExports: {
|
||||
ECOSYSTEM_PY: "py",
|
||||
getEcoSystem: () => "py",
|
||||
getLoggingLevel: () => "silent",
|
||||
getMinimumPackageAgeHours: () => 48,
|
||||
getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
|
||||
getPipCustomRegistries: () => [],
|
||||
LOGGING_SILENT: "silent",
|
||||
LOGGING_VERBOSE: "verbose",
|
||||
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
||||
},
|
||||
});
|
||||
|
||||
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
|
||||
|
||||
it("should block newly released package downloads", async () => {
|
||||
const url =
|
||||
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
|
||||
newlyReleasedPackageResponse = true;
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.ok(result.blockResponse);
|
||||
assert.equal(result.blockResponse.statusCode, 403);
|
||||
assert.equal(
|
||||
result.blockResponse.message,
|
||||
"Forbidden - blocked by safe-chain direct download minimum package age (foo_bar@2.0.0)"
|
||||
);
|
||||
|
||||
newlyReleasedPackageResponse = false;
|
||||
});
|
||||
|
||||
it("should modify simple metadata responses to suppress too-young versions", async () => {
|
||||
const url = "https://pypi.org/simple/foo-bar/";
|
||||
newlyReleasedPackageResponse = true;
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.equal(result.modifiesResponse(), true);
|
||||
|
||||
const modifiedBody = result.modifyBody(
|
||||
Buffer.from(`
|
||||
<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-1.0.0.tar.gz">foo_bar-1.0.0.tar.gz</a>
|
||||
<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz">foo_bar-2.0.0.tar.gz</a>
|
||||
`),
|
||||
{
|
||||
"content-type": "application/vnd.pypi.simple.v1+html",
|
||||
}
|
||||
).toString("utf8");
|
||||
|
||||
assert.ok(modifiedBody.includes("foo_bar-1.0.0.tar.gz"));
|
||||
assert.ok(!modifiedBody.includes("foo_bar-2.0.0.tar.gz"));
|
||||
|
||||
newlyReleasedPackageResponse = false;
|
||||
});
|
||||
|
||||
it("should not block newly released package downloads when skipMinimumPackageAge is enabled", async () => {
|
||||
const url =
|
||||
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
|
||||
newlyReleasedPackageResponse = true;
|
||||
skipMinimumPackageAgeSetting = true;
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.equal(result.blockResponse, undefined);
|
||||
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
newlyReleasedPackageResponse = false;
|
||||
});
|
||||
|
||||
it("should not block newly released package downloads when the package is excluded", async () => {
|
||||
const url =
|
||||
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
|
||||
newlyReleasedPackageResponse = true;
|
||||
minimumPackageAgeExclusionsSetting = ["foo-bar"];
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.equal(result.blockResponse, undefined);
|
||||
|
||||
minimumPackageAgeExclusionsSetting = [];
|
||||
newlyReleasedPackageResponse = false;
|
||||
});
|
||||
|
||||
it("should not modify metadata responses when the package is excluded", async () => {
|
||||
const url = "https://pypi.org/simple/foo-bar/";
|
||||
newlyReleasedPackageResponse = true;
|
||||
minimumPackageAgeExclusionsSetting = ["foo-bar"];
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.equal(result.modifiesResponse(), false);
|
||||
|
||||
minimumPackageAgeExclusionsSetting = [];
|
||||
newlyReleasedPackageResponse = false;
|
||||
});
|
||||
|
||||
it("strips If-None-Match and If-Modified-Since from metadata requests to prevent 304 cache bypass", async () => {
|
||||
const url = "https://pypi.org/simple/foo-bar/";
|
||||
newlyReleasedPackageResponse = true;
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
const headers = {
|
||||
"if-none-match": '"some-etag"',
|
||||
"if-modified-since": "Thu, 01 Jan 2026 00:00:00 GMT",
|
||||
accept: "*/*",
|
||||
};
|
||||
|
||||
result.modifyRequestHeaders(headers);
|
||||
|
||||
assert.equal(headers["if-none-match"], undefined, "If-None-Match must be stripped");
|
||||
assert.equal(headers["if-modified-since"], undefined, "If-Modified-Since must be stripped");
|
||||
assert.equal(headers.accept, "*/*", "unrelated headers must be preserved");
|
||||
|
||||
newlyReleasedPackageResponse = false;
|
||||
});
|
||||
|
||||
it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => {
|
||||
const url =
|
||||
"https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz";
|
||||
newlyReleasedPackageResponse = true;
|
||||
minimumPackageAgeExclusionsSetting = ["foo-bar"];
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.equal(result.blockResponse, undefined);
|
||||
|
||||
minimumPackageAgeExclusionsSetting = [];
|
||||
newlyReleasedPackageResponse = false;
|
||||
});
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
|
||||
import { ui } from "../../../environment/userInteraction.js";
|
||||
import { getHeaderValueAsString } from "../../http-utils.js";
|
||||
import { recordSuppressedVersion } from "../suppressedVersionsState.js";
|
||||
|
||||
/**
|
||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
export function getPipMetadataContentType(headers) {
|
||||
return getHeaderValueAsString(headers, "content-type")
|
||||
?.toLowerCase()
|
||||
.split(";")[0]
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} packageName
|
||||
* @param {string} version
|
||||
* @returns {void}
|
||||
*/
|
||||
export function logSuppressedVersion(packageName, version) {
|
||||
recordSuppressedVersion();
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
|
||||
);
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
|
||||
|
||||
/**
|
||||
* @param {any} file
|
||||
* @param {string} metadataUrl
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
export function getPackageVersionFromMetadataFile(file, metadataUrl) {
|
||||
const href = typeof file?.url === "string" ? file.url : undefined;
|
||||
const filename = typeof file?.filename === "string" ? file.filename : undefined;
|
||||
|
||||
if (href) {
|
||||
const resolvedHref = new URL(href, metadataUrl).toString();
|
||||
return parsePipPackageFromUrl(
|
||||
resolvedHref,
|
||||
new URL(resolvedHref).host
|
||||
).version;
|
||||
}
|
||||
|
||||
if (filename) {
|
||||
return parsePipPackageFromUrl(
|
||||
new URL(filename, metadataUrl).toString(),
|
||||
new URL(metadataUrl).host
|
||||
).version;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} json
|
||||
* @param {string} metadataUrl
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getAvailableVersionsFromJson(json, metadataUrl) {
|
||||
if (json.releases && typeof json.releases === "object") {
|
||||
return Object.keys(json.releases);
|
||||
}
|
||||
|
||||
if (!Array.isArray(json.files)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
json.files
|
||||
.map((/** @type {any} */ file) =>
|
||||
getPackageVersionFromMetadataFile(file, metadataUrl)
|
||||
)
|
||||
.filter(isDefinedString)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} value
|
||||
* @returns {value is string}
|
||||
*/
|
||||
function isDefinedString(value) {
|
||||
return typeof value === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} versions
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
export function calculateLatestVersion(versions) {
|
||||
const stableVersions = versions.filter((version) => !isPrerelease(version));
|
||||
if (stableVersions.length > 0) {
|
||||
return stableVersions.sort(comparePep440ishVersions).at(-1);
|
||||
}
|
||||
|
||||
return versions.sort(comparePep440ishVersions).at(-1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} left
|
||||
* @param {string} right
|
||||
* @returns {number}
|
||||
*/
|
||||
function comparePep440ishVersions(left, right) {
|
||||
const leftParts = tokenizeVersion(left);
|
||||
const rightParts = tokenizeVersion(right);
|
||||
const maxLength = Math.max(leftParts.length, rightParts.length);
|
||||
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const leftPart = leftParts[index];
|
||||
const rightPart = rightParts[index];
|
||||
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
|
||||
if (leftPart === rightPart) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const leftNumeric = typeof leftPart === "number";
|
||||
const rightNumeric = typeof rightPart === "number";
|
||||
|
||||
if (leftNumeric && rightNumeric) {
|
||||
return leftPart - rightPart;
|
||||
}
|
||||
|
||||
if (leftNumeric) return 1;
|
||||
if (rightNumeric) return -1;
|
||||
|
||||
return String(leftPart).localeCompare(String(rightPart));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} version
|
||||
* @returns {(string | number)[]}
|
||||
*/
|
||||
function tokenizeVersion(version) {
|
||||
return version
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.flatMap((part) => part.match(/[a-z]+|\d+/g) || [])
|
||||
.map((part) => (/^\d+$/.test(part) ? Number(part) : part));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} version
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPrerelease(version) {
|
||||
return /(a|b|rc|dev)\d+/i.test(version);
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { isMalwarePackage } from "../../scanning/audit/index.js";
|
||||
import { interceptRequests } from "./interceptorBuilder.js";
|
||||
|
||||
const knownPipRegistries = [
|
||||
"files.pythonhosted.org",
|
||||
"pypi.org",
|
||||
"pypi.python.org",
|
||||
"pythonhosted.org",
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
export function pipInterceptorForUrl(url) {
|
||||
const registry = knownPipRegistries.find((reg) => url.includes(reg));
|
||||
|
||||
if (registry) {
|
||||
return buildPipInterceptor(registry);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} registry
|
||||
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
function buildPipInterceptor(registry) {
|
||||
return interceptRequests(async (reqContext) => {
|
||||
const { packageName, version } = parsePipPackageFromUrl(
|
||||
reqContext.targetUrl,
|
||||
registry
|
||||
);
|
||||
|
||||
// Normalize underscores to hyphens for DB matching, as PyPI allows underscores in distribution names.
|
||||
// Per python, packages that differ only by hyphen vs underscore are considered the same.
|
||||
const hyphenName = packageName?.includes("_") ? packageName.replace(/_/g, "-") : packageName;
|
||||
|
||||
const isMalicious =
|
||||
await isMalwarePackage(packageName, version)
|
||||
|| await isMalwarePackage(hyphenName, version);
|
||||
|
||||
if (isMalicious) {
|
||||
reqContext.blockMalware(packageName, version);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {string} registry
|
||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||
*/
|
||||
function parsePipPackageFromUrl(url, registry) {
|
||||
let packageName, version;
|
||||
|
||||
// Basic validation
|
||||
if (!registry || typeof url !== "string") {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
// Quick sanity check on the URL + parse
|
||||
let urlObj;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
// Get the last path segment (filename) and decode it (strip query & fragment automatically)
|
||||
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
|
||||
if (!lastSegment) {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
const filename = decodeURIComponent(lastSegment);
|
||||
|
||||
// Parse Python package downloads from PyPI/pythonhosted.org
|
||||
// Example wheel: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1-py3-none-any.whl
|
||||
// Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz
|
||||
|
||||
// Wheel (.whl) and Poetry's preflight metadata (.whl.metadata)
|
||||
// Examples:
|
||||
// foo_bar-2.0.0-py3-none-any.whl
|
||||
// foo_bar-2.0.0-py3-none-any.whl.metadata
|
||||
const wheelExtRe = /\.whl(?:\.metadata)?$/;
|
||||
const wheelExtMatch = filename.match(wheelExtRe);
|
||||
if (wheelExtMatch) {
|
||||
const base = filename.replace(wheelExtRe, "");
|
||||
const firstDash = base.indexOf("-");
|
||||
if (firstDash > 0) {
|
||||
const dist = base.slice(0, firstDash); // may contain underscores
|
||||
const rest = base.slice(firstDash + 1); // version + the rest of tags
|
||||
const secondDash = rest.indexOf("-");
|
||||
const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
|
||||
packageName = dist;
|
||||
version = rawVersion;
|
||||
// Reject "latest" as it's a placeholder, not a real version
|
||||
// When version is "latest", this signals the URL doesn't contain actual version info
|
||||
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
|
||||
if (version === "latest" || !packageName || !version) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
return { packageName, version };
|
||||
}
|
||||
}
|
||||
|
||||
// Source dist (sdist) and potential metadata sidecars (e.g., .tar.gz.metadata)
|
||||
const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
|
||||
const sdistExtMatch = filename.match(sdistExtWithMetadataRe);
|
||||
if (sdistExtMatch) {
|
||||
const base = filename.replace(sdistExtWithMetadataRe, "");
|
||||
const lastDash = base.lastIndexOf("-");
|
||||
if (lastDash > 0 && lastDash < base.length - 1) {
|
||||
packageName = base.slice(0, lastDash);
|
||||
version = base.slice(lastDash + 1);
|
||||
// Reject "latest" as it's a placeholder, not a real version
|
||||
// When version is "latest", this signals the URL doesn't contain actual version info
|
||||
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
|
||||
if (version === "latest" || !packageName || !version) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
return { packageName, version };
|
||||
}
|
||||
}
|
||||
// Unknown file type or invalid
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
|
@ -2,43 +2,22 @@ import { describe, it, mock } from "node:test";
|
|||
import assert from "node:assert";
|
||||
|
||||
describe("pipInterceptor", async () => {
|
||||
let scannedPackages;
|
||||
let lastPackage;
|
||||
let malwareResponse = false;
|
||||
|
||||
mock.module("../../../scanning/audit/index.js", {
|
||||
mock.module("../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async (packageName, version) => {
|
||||
scannedPackages.push({ packageName, version });
|
||||
lastPackage = { packageName, version };
|
||||
return malwareResponse;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../scanning/newPackagesListCache.js", {
|
||||
namedExports: {
|
||||
openNewPackagesDatabase: async () => ({
|
||||
isNewlyReleasedPackage: () => false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../config/settings.js", {
|
||||
namedExports: {
|
||||
ECOSYSTEM_PY: "py",
|
||||
getEcoSystem: () => "py",
|
||||
getLoggingLevel: () => "silent",
|
||||
getMinimumPackageAgeHours: () => 48,
|
||||
getMinimumPackageAgeExclusions: () => [],
|
||||
getPipCustomRegistries: () => [],
|
||||
LOGGING_SILENT: "silent",
|
||||
LOGGING_VERBOSE: "verbose",
|
||||
skipMinimumPackageAge: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
|
||||
|
||||
const parserCases = [
|
||||
// Valid pip URLs
|
||||
{
|
||||
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz",
|
||||
expected: { packageName: "foobar", version: "1.2.3" },
|
||||
|
|
@ -56,6 +35,7 @@ describe("pipInterceptor", async () => {
|
|||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
{
|
||||
// Poetry preflight metadata alongside wheel (.whl.metadata)
|
||||
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl.metadata",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
|
|
@ -72,6 +52,7 @@ describe("pipInterceptor", async () => {
|
|||
expected: { packageName: "foo-bar", version: "2.0.0b1" },
|
||||
},
|
||||
{
|
||||
// sdist with metadata sidecar (.tar.gz.metadata)
|
||||
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz.metadata",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
|
|
@ -95,6 +76,7 @@ describe("pipInterceptor", async () => {
|
|||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
// Invalid pip URLs
|
||||
{
|
||||
url: "https://pypi.org/simple/",
|
||||
expected: { packageName: undefined, version: undefined },
|
||||
|
|
@ -115,49 +97,49 @@ describe("pipInterceptor", async () => {
|
|||
|
||||
parserCases.forEach(({ url, expected }, index) => {
|
||||
it(`should parse URL ${index + 1}: ${url}`, async () => {
|
||||
scannedPackages = [];
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor, "Interceptor should be created for known pip registry");
|
||||
assert.ok(
|
||||
interceptor,
|
||||
"Interceptor should be created for known npm registry"
|
||||
);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
if (expected.packageName === undefined) {
|
||||
assert.deepEqual(scannedPackages, []);
|
||||
return;
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
scannedPackages.some(
|
||||
({ packageName, version }) =>
|
||||
packageName === expected.packageName &&
|
||||
version === expected.version
|
||||
)
|
||||
);
|
||||
assert.deepEqual(lastPackage, expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create interceptor for unknown registry", () => {
|
||||
const url = "https://example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.equal(interceptor, undefined);
|
||||
|
||||
assert.equal(
|
||||
interceptor,
|
||||
undefined,
|
||||
"Interceptor should be undefined for unknown registry"
|
||||
);
|
||||
});
|
||||
|
||||
it("should block malicious package", async () => {
|
||||
scannedPackages = [];
|
||||
const url =
|
||||
"https://files.pythonhosted.org/packages/xx/yy/malicious_package-1.0.0.tar.gz";
|
||||
malwareResponse = true;
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.ok(result.blockResponse);
|
||||
assert.equal(result.blockResponse.statusCode, 403);
|
||||
assert.ok(result.blockResponse, "Should contain a blockResponse");
|
||||
assert.equal(
|
||||
result.blockResponse.statusCode,
|
||||
403,
|
||||
"Block response should have status code 403"
|
||||
);
|
||||
assert.equal(
|
||||
result.blockResponse.message,
|
||||
"Forbidden - blocked by safe-chain"
|
||||
"Forbidden - blocked by safe-chain",
|
||||
"Block response should have correct status message"
|
||||
);
|
||||
|
||||
malwareResponse = false;
|
||||
});
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
const state = {
|
||||
hasSuppressedVersions: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks whether any rewritten metadata response suppressed versions during the
|
||||
* current process lifetime. This is intentional shared state used only for the
|
||||
* end-of-run summary message exposed through the proxy API.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function recordSuppressedVersion() {
|
||||
state.hasSuppressedVersions = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function getHasSuppressedVersions() {
|
||||
return state.hasSuppressedVersions;
|
||||
}
|
||||
|
|
@ -2,8 +2,7 @@ import https from "https";
|
|||
import { generateCertForHost } from "./certUtils.js";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { gunzipSync } from "zlib";
|
||||
import { omitHeaders } from "./http-utils.js";
|
||||
import { gunzipSync, gzipSync } from "zlib";
|
||||
|
||||
/**
|
||||
* @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor
|
||||
|
|
@ -16,7 +15,7 @@ import { omitHeaders } from "./http-utils.js";
|
|||
*/
|
||||
export function mitmConnect(req, clientSocket, interceptor) {
|
||||
ui.writeVerbose(`Safe-chain: Set up MITM tunnel for ${req.url}`);
|
||||
const { hostname, port } = new URL(`http://${req.url}`);
|
||||
const { hostname } = new URL(`http://${req.url}`);
|
||||
|
||||
clientSocket.on("error", (err) => {
|
||||
ui.writeVerbose(
|
||||
|
|
@ -27,7 +26,7 @@ export function mitmConnect(req, clientSocket, interceptor) {
|
|||
// Not subscribing to 'close' event will cause node to throw and crash.
|
||||
});
|
||||
|
||||
const server = createHttpsServer(hostname, port, interceptor);
|
||||
const server = createHttpsServer(hostname, interceptor);
|
||||
|
||||
server.on("error", (err) => {
|
||||
ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`);
|
||||
|
|
@ -47,11 +46,10 @@ export function mitmConnect(req, clientSocket, interceptor) {
|
|||
|
||||
/**
|
||||
* @param {string} hostname
|
||||
* @param {string} port
|
||||
* @param {Interceptor} interceptor
|
||||
* @returns {import("https").Server}
|
||||
*/
|
||||
function createHttpsServer(hostname, port, interceptor) {
|
||||
function createHttpsServer(hostname, interceptor) {
|
||||
const cert = generateCertForHost(hostname);
|
||||
|
||||
/**
|
||||
|
|
@ -82,7 +80,7 @@ function createHttpsServer(hostname, port, interceptor) {
|
|||
}
|
||||
|
||||
// Collect request body
|
||||
forwardRequest(req, hostname, port, res, requestInterceptor);
|
||||
forwardRequest(req, hostname, res, requestInterceptor);
|
||||
}
|
||||
|
||||
const server = https.createServer(
|
||||
|
|
@ -111,12 +109,11 @@ function getRequestPathAndQuery(url) {
|
|||
/**
|
||||
* @param {import("http").IncomingMessage} req
|
||||
* @param {string} hostname
|
||||
* @param {string} port
|
||||
* @param {import("http").ServerResponse} res
|
||||
* @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler
|
||||
*/
|
||||
function forwardRequest(req, hostname, port, res, requestHandler) {
|
||||
const proxyReq = createProxyRequest(hostname, port, req, res, requestHandler);
|
||||
function forwardRequest(req, hostname, res, requestHandler) {
|
||||
const proxyReq = createProxyRequest(hostname, req, res, requestHandler);
|
||||
|
||||
proxyReq.on("error", (err) => {
|
||||
ui.writeVerbose(
|
||||
|
|
@ -147,14 +144,13 @@ function forwardRequest(req, hostname, port, res, requestHandler) {
|
|||
|
||||
/**
|
||||
* @param {string} hostname
|
||||
* @param {string} port
|
||||
* @param {import("http").IncomingMessage} req
|
||||
* @param {import("http").ServerResponse} res
|
||||
* @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler
|
||||
*
|
||||
* @returns {import("http").ClientRequest}
|
||||
*/
|
||||
function createProxyRequest(hostname, port, req, res, requestHandler) {
|
||||
function createProxyRequest(hostname, req, res, requestHandler) {
|
||||
/** @type {NodeJS.Dict<string | string[]> | undefined} */
|
||||
let headers = { ...req.headers };
|
||||
// Remove the host header from the incoming request before forwarding.
|
||||
|
|
@ -167,7 +163,7 @@ function createProxyRequest(hostname, port, req, res, requestHandler) {
|
|||
/** @type {import("http").RequestOptions} */
|
||||
const options = {
|
||||
hostname: hostname,
|
||||
port: port || 443,
|
||||
port: 443,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...headers },
|
||||
|
|
@ -216,16 +212,11 @@ function createProxyRequest(hostname, port, req, res, requestHandler) {
|
|||
|
||||
buffer = requestHandler.modifyBody(buffer, headers);
|
||||
|
||||
// For rewritten responses, send the final body uncompressed.
|
||||
// This avoids mismatches between upstream compression metadata and the
|
||||
// rewritten payload on the wire.
|
||||
const rewrittenHeaders = omitHeaders(
|
||||
headers,
|
||||
["content-length", "transfer-encoding", "content-encoding"],
|
||||
{ caseInsensitive: true }
|
||||
) || {};
|
||||
rewrittenHeaders["content-length"] = String(buffer.byteLength);
|
||||
res.writeHead(statusCode, rewrittenHeaders);
|
||||
if (proxyRes.headers["content-encoding"] === "gzip") {
|
||||
buffer = gzipSync(buffer);
|
||||
}
|
||||
|
||||
res.writeHead(statusCode, headers);
|
||||
res.end(buffer);
|
||||
});
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,138 +0,0 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import zlib from "node:zlib";
|
||||
|
||||
describe("mitmRequestHandler", async () => {
|
||||
let capturedHandler;
|
||||
let capturedOptions;
|
||||
|
||||
mock.module("https", {
|
||||
defaultExport: {
|
||||
createServer: (_options, handler) => {
|
||||
capturedHandler = handler;
|
||||
return {
|
||||
on: () => {},
|
||||
emit: () => {},
|
||||
};
|
||||
},
|
||||
request: (options, callback) => {
|
||||
capturedOptions = options;
|
||||
|
||||
const listeners = {};
|
||||
const proxyRes = {
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
"content-encoding": "gzip",
|
||||
"content-length": "999",
|
||||
"transfer-encoding": "chunked",
|
||||
},
|
||||
on: (event, handler) => {
|
||||
listeners[event] = handler;
|
||||
},
|
||||
};
|
||||
|
||||
callback(proxyRes);
|
||||
|
||||
return {
|
||||
on: () => {},
|
||||
write: () => {},
|
||||
end: () => {
|
||||
const payload = Buffer.from("rewritten body");
|
||||
listeners["data"]?.(zlib.gzipSync(payload));
|
||||
listeners["end"]?.();
|
||||
},
|
||||
destroy: () => {},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
generateCertForHost: () => ({
|
||||
privateKey: "key",
|
||||
certificate: "cert",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("https-proxy-agent", {
|
||||
namedExports: {
|
||||
HttpsProxyAgent: class {},
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../environment/userInteraction.js", {
|
||||
namedExports: {
|
||||
ui: {
|
||||
writeVerbose: () => {},
|
||||
writeError: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { mitmConnect } = await import("./mitmRequestHandler.js");
|
||||
|
||||
it("sets content-length from the final compressed payload after body rewrite", async () => {
|
||||
const interceptor = {
|
||||
handleRequest: async () => ({
|
||||
blockResponse: undefined,
|
||||
modifyRequestHeaders: (headers) => headers,
|
||||
modifiesResponse: () => true,
|
||||
modifyBody: () => Buffer.from("rewritten body"),
|
||||
}),
|
||||
};
|
||||
|
||||
const req = {
|
||||
url: "pypi.org:443",
|
||||
};
|
||||
|
||||
const clientSocket = {
|
||||
on: () => {},
|
||||
write: () => {},
|
||||
headersSent: false,
|
||||
writable: true,
|
||||
end: () => {},
|
||||
};
|
||||
|
||||
mitmConnect(req, clientSocket, interceptor);
|
||||
|
||||
const resState = {
|
||||
statusCode: undefined,
|
||||
headers: undefined,
|
||||
body: undefined,
|
||||
};
|
||||
|
||||
const res = {
|
||||
headersSent: false,
|
||||
writeHead: (statusCode, headers) => {
|
||||
resState.statusCode = statusCode;
|
||||
resState.headers = headers;
|
||||
},
|
||||
end: (body) => {
|
||||
resState.body = body;
|
||||
},
|
||||
};
|
||||
|
||||
const request = {
|
||||
url: "/simple/example/",
|
||||
headers: {},
|
||||
method: "GET",
|
||||
on: (event, handler) => {
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
await capturedHandler(request, res);
|
||||
|
||||
assert.equal(capturedOptions.hostname, "pypi.org");
|
||||
assert.equal(resState.statusCode, 200);
|
||||
assert.equal(resState.headers["transfer-encoding"], undefined);
|
||||
assert.equal(
|
||||
resState.headers["content-length"],
|
||||
String(resState.body.byteLength)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -182,13 +182,13 @@ describe("registryProxy.connectTunnel", () => {
|
|||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts)
|
||||
// Should return 502 Bad Gateway
|
||||
assert.ok(
|
||||
responseData.includes("HTTP/1.1 504 Gateway Timeout"),
|
||||
"Should return 504 for timeout"
|
||||
responseData.includes("HTTP/1.1 502 Bad Gateway"),
|
||||
"Should return 502 for timeout"
|
||||
);
|
||||
|
||||
// Should timeout around 100ms for IMDS endpoints (allow some margin)
|
||||
// Should timeout around 3 seconds for IMDS endpoints (allow some margin)
|
||||
assert.ok(
|
||||
duration >= 80 && duration < 200,
|
||||
`IMDS timeout should be ~80-200ms, got ${duration}ms`
|
||||
|
|
@ -280,10 +280,10 @@ describe("registryProxy.connectTunnel", () => {
|
|||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts)
|
||||
// Should return 502 Bad Gateway (timeout)
|
||||
assert.ok(
|
||||
responseData.includes("HTTP/1.1 504 Gateway Timeout"),
|
||||
"Should return 504 for timeout"
|
||||
responseData.includes("HTTP/1.1 502 Bad Gateway"),
|
||||
"Should return 502 for timeout"
|
||||
);
|
||||
|
||||
// Should NOT be instant - it should retry the connection (taking ~500ms due to mock timeout)
|
||||
|
|
|
|||
|
|
@ -2,24 +2,19 @@ import * as http from "http";
|
|||
import { tunnelRequest } from "./tunnelRequestHandler.js";
|
||||
import { mitmConnect } from "./mitmRequestHandler.js";
|
||||
import { handleHttpProxyRequest } from "./plainHttpProxy.js";
|
||||
import { getCombinedCaBundlePath, cleanupCertBundle } from "./certBundle.js";
|
||||
import { getCombinedCaBundlePath } from "./certBundle.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import chalk from "chalk";
|
||||
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
|
||||
import { getHasSuppressedVersions } from "./interceptors/suppressedVersionsState.js";
|
||||
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
|
||||
|
||||
const SERVER_STOP_TIMEOUT_MS = 1000;
|
||||
/**
|
||||
* @type {{
|
||||
* port: number | null,
|
||||
* blockedRequests: {packageName: string, version: string, url: string}[],
|
||||
* blockedMinimumAgeRequests: {packageName: string, version: string, url: string}[]
|
||||
* }}
|
||||
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}}
|
||||
*/
|
||||
const state = {
|
||||
port: null,
|
||||
blockedRequests: [],
|
||||
blockedMinimumAgeRequests: [],
|
||||
};
|
||||
|
||||
export function createSafeChainProxy() {
|
||||
|
|
@ -28,8 +23,7 @@ export function createSafeChainProxy() {
|
|||
return {
|
||||
startServer: () => startServer(server),
|
||||
stopServer: () => stopServer(server),
|
||||
hasBlockedMaliciousPackages,
|
||||
hasBlockedMinimumAgeRequests,
|
||||
verifyNoMaliciousPackages,
|
||||
hasSuppressedVersions: getHasSuppressedVersions,
|
||||
};
|
||||
}
|
||||
|
|
@ -42,7 +36,7 @@ function getSafeChainProxyEnvironmentVariables() {
|
|||
return {};
|
||||
}
|
||||
|
||||
const proxyUrl = `http://127.0.0.1:${state.port}`;
|
||||
const proxyUrl = `http://localhost:${state.port}`;
|
||||
const caCertPath = getCombinedCaBundlePath();
|
||||
|
||||
return {
|
||||
|
|
@ -95,11 +89,8 @@ function createProxyServer() {
|
|||
*/
|
||||
function startServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Bind to loopback only. Without an explicit host, Node listens on every
|
||||
// interface, turning the proxy into an unauthenticated forward proxy that
|
||||
// anyone reachable on the network can use to hit the victim's localhost,
|
||||
// intranet, or cloud metadata endpoints. Port 0 lets the OS pick a port.
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
// Passing port 0 makes the OS assign an available port
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
if (address && typeof address === "object") {
|
||||
state.port = address.port;
|
||||
|
|
@ -124,16 +115,12 @@ function stopServer(server) {
|
|||
return new Promise((resolve) => {
|
||||
try {
|
||||
server.close(() => {
|
||||
cleanupCertBundle();
|
||||
resolve();
|
||||
});
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
setTimeout(() => {
|
||||
cleanupCertBundle();
|
||||
resolve();
|
||||
}, SERVER_STOP_TIMEOUT_MS);
|
||||
setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -160,18 +147,6 @@ function handleConnect(req, clientSocket, head) {
|
|||
onMalwareBlocked(event.packageName, event.version, event.targetUrl);
|
||||
}
|
||||
);
|
||||
interceptor.on(
|
||||
"minimumAgeRequestBlocked",
|
||||
(
|
||||
/** @type {import("./interceptors/interceptorBuilder.js").MinimumAgeRequestBlockedEvent} */ event
|
||||
) => {
|
||||
onMinimumAgeRequestBlocked(
|
||||
event.packageName,
|
||||
event.version,
|
||||
event.targetUrl
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
mitmConnect(req, clientSocket, interceptor);
|
||||
} else {
|
||||
|
|
@ -191,19 +166,10 @@ function onMalwareBlocked(packageName, version, url) {
|
|||
state.blockedRequests.push({ packageName, version, url });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} packageName
|
||||
* @param {string} version
|
||||
* @param {string} url
|
||||
*/
|
||||
function onMinimumAgeRequestBlocked(packageName, version, url) {
|
||||
state.blockedMinimumAgeRequests.push({ packageName, version, url });
|
||||
}
|
||||
|
||||
function hasBlockedMaliciousPackages() {
|
||||
function verifyNoMaliciousPackages() {
|
||||
if (state.blockedRequests.length === 0) {
|
||||
return false;
|
||||
// No malicious packages were blocked, so nothing to block
|
||||
return true;
|
||||
}
|
||||
|
||||
ui.emptyLine();
|
||||
|
|
@ -222,37 +188,5 @@ function hasBlockedMaliciousPackages() {
|
|||
ui.writeExitWithoutInstallingMaliciousPackages();
|
||||
ui.emptyLine();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasBlockedMinimumAgeRequests() {
|
||||
if (state.blockedMinimumAgeRequests.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ui.emptyLine();
|
||||
|
||||
ui.writeInformation(
|
||||
`Safe-chain: ${chalk.bold(
|
||||
`blocked ${state.blockedMinimumAgeRequests.length} direct package download request(s) due to minimum package age`
|
||||
)}:`
|
||||
);
|
||||
|
||||
for (const req of state.blockedMinimumAgeRequests) {
|
||||
ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`);
|
||||
}
|
||||
|
||||
ui.writeInformation(
|
||||
` To disable this check, use: ${chalk.cyan(
|
||||
"--safe-chain-skip-minimum-package-age"
|
||||
)}`
|
||||
);
|
||||
|
||||
ui.emptyLine();
|
||||
ui.writeError(
|
||||
"Safe-chain: Exiting without installing packages blocked by the direct download minimum package age check."
|
||||
);
|
||||
ui.emptyLine();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
import { before, after, describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import {
|
||||
createSafeChainProxy,
|
||||
mergeSafeChainProxyEnvironmentVariables,
|
||||
} from "./registryProxy.js";
|
||||
|
||||
describe("registryProxy loopback binding", () => {
|
||||
let proxy, proxyPort;
|
||||
|
||||
before(async () => {
|
||||
proxy = createSafeChainProxy();
|
||||
await proxy.startServer();
|
||||
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
|
||||
proxyPort = parseInt(new URL(envVars.HTTPS_PROXY).port, 10);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await proxy.stopServer();
|
||||
});
|
||||
|
||||
it("advertises a loopback HTTPS_PROXY URL", () => {
|
||||
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
|
||||
const hostname = new URL(envVars.HTTPS_PROXY).hostname;
|
||||
assert.ok(
|
||||
hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost",
|
||||
`expected loopback hostname, got ${hostname}`
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses connections on non-loopback interfaces", async () => {
|
||||
const externalAddrs = Object.values(os.networkInterfaces())
|
||||
.flat()
|
||||
.filter((iface) => iface && iface.family === "IPv4" && !iface.internal)
|
||||
.map((iface) => iface.address);
|
||||
|
||||
if (externalAddrs.length === 0) {
|
||||
// No non-loopback interface available (e.g. locked-down CI) - skip.
|
||||
return;
|
||||
}
|
||||
|
||||
for (const addr of externalAddrs) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const sock = net.createConnection({ host: addr, port: proxyPort });
|
||||
const timer = setTimeout(() => {
|
||||
sock.destroy();
|
||||
resolve(); // Filtered / dropped is also fine - we just don't want success.
|
||||
}, 500);
|
||||
sock.once("connect", () => {
|
||||
clearTimeout(timer);
|
||||
sock.destroy();
|
||||
reject(
|
||||
new Error(
|
||||
`proxy accepted a connection on non-loopback ${addr}:${proxyPort}`
|
||||
)
|
||||
);
|
||||
});
|
||||
sock.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -2,17 +2,12 @@ import { before, after, describe, it } from "node:test";
|
|||
import assert from "node:assert";
|
||||
import net from "net";
|
||||
import tls from "tls";
|
||||
import { gunzipSync } from "zlib";
|
||||
import {
|
||||
createSafeChainProxy,
|
||||
mergeSafeChainProxyEnvironmentVariables,
|
||||
} from "./registryProxy.js";
|
||||
import { getCaCertPath } from "./certUtils.js";
|
||||
import {
|
||||
setEcoSystem,
|
||||
ECOSYSTEM_JS,
|
||||
ECOSYSTEM_PY,
|
||||
} from "../config/settings.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
import fs from "fs";
|
||||
|
||||
describe("registryProxy.mitm", () => {
|
||||
|
|
@ -38,7 +33,7 @@ describe("registryProxy.mitm", () => {
|
|||
proxyHost,
|
||||
proxyPort,
|
||||
"registry.npmjs.org",
|
||||
"/lodash",
|
||||
"/lodash"
|
||||
);
|
||||
|
||||
assert.strictEqual(response.statusCode, 200);
|
||||
|
|
@ -50,7 +45,7 @@ describe("registryProxy.mitm", () => {
|
|||
proxyHost,
|
||||
proxyPort,
|
||||
"registry.npmjs.org",
|
||||
"/lodash/-/lodash-4.17.21.tgz",
|
||||
"/lodash/-/lodash-4.17.21.tgz"
|
||||
);
|
||||
|
||||
// Should get a response (200 or redirect, but not 403 blocked)
|
||||
|
|
@ -62,7 +57,7 @@ describe("registryProxy.mitm", () => {
|
|||
proxyHost,
|
||||
proxyPort,
|
||||
"registry.npmjs.org",
|
||||
"/this-package-definitely-does-not-exist-12345",
|
||||
"/this-package-definitely-does-not-exist-12345"
|
||||
);
|
||||
|
||||
assert.strictEqual(response.statusCode, 404);
|
||||
|
|
@ -73,7 +68,7 @@ describe("registryProxy.mitm", () => {
|
|||
proxyHost,
|
||||
proxyPort,
|
||||
"registry.npmjs.org",
|
||||
"/lodash?write=true",
|
||||
"/lodash?write=true"
|
||||
);
|
||||
|
||||
assert.strictEqual(response.statusCode, 200);
|
||||
|
|
@ -84,7 +79,7 @@ describe("registryProxy.mitm", () => {
|
|||
proxyHost,
|
||||
proxyPort,
|
||||
"registry.yarnpkg.com",
|
||||
"/lodash",
|
||||
"/lodash"
|
||||
);
|
||||
|
||||
assert.strictEqual(response.statusCode, 200);
|
||||
|
|
@ -95,7 +90,7 @@ describe("registryProxy.mitm", () => {
|
|||
proxyHost,
|
||||
proxyPort,
|
||||
"registry.npmjs.org",
|
||||
"/lodash",
|
||||
"/lodash"
|
||||
);
|
||||
|
||||
// Check certificate common name matches the target hostname
|
||||
|
|
@ -114,14 +109,14 @@ describe("registryProxy.mitm", () => {
|
|||
proxyHost,
|
||||
proxyPort,
|
||||
"registry.npmjs.org",
|
||||
"/lodash",
|
||||
"/lodash"
|
||||
);
|
||||
|
||||
const { cert: cert2 } = await makeRegistryRequestAndGetCert(
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
"registry.yarnpkg.com",
|
||||
"/lodash",
|
||||
"/lodash"
|
||||
);
|
||||
|
||||
// Different hostnames should have different certificates
|
||||
|
|
@ -135,14 +130,14 @@ describe("registryProxy.mitm", () => {
|
|||
proxyHost,
|
||||
proxyPort,
|
||||
"registry.npmjs.org",
|
||||
"/lodash",
|
||||
"/lodash"
|
||||
);
|
||||
|
||||
const { cert: cert2 } = await makeRegistryRequestAndGetCert(
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
"registry.npmjs.org",
|
||||
"/package/lodash",
|
||||
"/package/lodash"
|
||||
);
|
||||
|
||||
// Same hostname should get the same certificate (fingerprint)
|
||||
|
|
@ -164,7 +159,7 @@ describe("registryProxy.mitm", () => {
|
|||
proxyHost,
|
||||
proxyPort,
|
||||
"pypi.org",
|
||||
"/packages/source/f/foo_bar/foo_bar-2.0.0.tar.gz",
|
||||
"/packages/source/f/foo_bar/foo_bar-2.0.0.tar.gz"
|
||||
);
|
||||
assert.notStrictEqual(response.statusCode, 403);
|
||||
assert.ok(typeof response.body === "string");
|
||||
|
|
@ -177,7 +172,7 @@ describe("registryProxy.mitm", () => {
|
|||
proxyHost,
|
||||
proxyPort,
|
||||
"files.pythonhosted.org",
|
||||
"/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl",
|
||||
"/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl"
|
||||
);
|
||||
assert.notStrictEqual(response.statusCode, 403);
|
||||
assert.ok(typeof response.body === "string");
|
||||
|
|
@ -190,7 +185,7 @@ describe("registryProxy.mitm", () => {
|
|||
proxyHost,
|
||||
proxyPort,
|
||||
"pypi.org",
|
||||
"/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz",
|
||||
"/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz"
|
||||
);
|
||||
assert.notStrictEqual(response.statusCode, 403);
|
||||
assert.ok(typeof response.body === "string");
|
||||
|
|
@ -203,7 +198,7 @@ describe("registryProxy.mitm", () => {
|
|||
proxyHost,
|
||||
proxyPort,
|
||||
"pypi.org",
|
||||
"/packages/source/f/foo_bar/foo_bar-latest.tar.gz",
|
||||
"/packages/source/f/foo_bar/foo_bar-latest.tar.gz"
|
||||
);
|
||||
assert.notStrictEqual(response.statusCode, 403);
|
||||
assert.ok(typeof response.body === "string");
|
||||
|
|
@ -239,73 +234,34 @@ async function makeRegistryRequest(proxyHost, proxyPort, targetHost, path) {
|
|||
});
|
||||
|
||||
// Step 4: Send HTTP request over TLS
|
||||
const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\nAccept-encoding: gzip\r\n\r\n`;
|
||||
const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\n\r\n`;
|
||||
tlsSocket.write(httpRequest);
|
||||
|
||||
// Step 5: Read response as binary chunks
|
||||
// Step 5: Read response
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let data = "";
|
||||
|
||||
tlsSocket.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
tlsSocket.on("end", () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
// Find the header/body separator (\r\n\r\n) in binary
|
||||
const separator = Buffer.from("\r\n\r\n");
|
||||
let separatorIndex = buffer.indexOf(separator);
|
||||
if (separatorIndex === -1) {
|
||||
return reject(
|
||||
new Error("Invalid HTTP response: no header/body separator"),
|
||||
);
|
||||
}
|
||||
|
||||
// Extract headers as text
|
||||
const headersText = buffer.subarray(0, separatorIndex).toString("utf8");
|
||||
const headerLines = headersText.split("\r\n");
|
||||
const statusLine = headerLines[0];
|
||||
const lines = data.split("\r\n");
|
||||
const statusLine = lines[0];
|
||||
const statusCode = parseInt(statusLine.split(" ")[1]);
|
||||
|
||||
// Parse headers into object
|
||||
const headers = {};
|
||||
for (let i = 1; i < headerLines.length; i++) {
|
||||
const colonIndex = headerLines[i].indexOf(":");
|
||||
if (colonIndex > 0) {
|
||||
const key = headerLines[i].substring(0, colonIndex).toLowerCase();
|
||||
const value = headerLines[i].substring(colonIndex + 1).trim();
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
// Find body after empty line
|
||||
const emptyLineIndex = lines.findIndex(line => line === "");
|
||||
const body = lines.slice(emptyLineIndex + 1).join("\r\n");
|
||||
|
||||
// Extract body as binary
|
||||
let bodyBuffer = buffer.subarray(separatorIndex + separator.length);
|
||||
|
||||
// Decode chunked transfer encoding if present
|
||||
if (headers["transfer-encoding"] === "chunked") {
|
||||
bodyBuffer = decodeChunked(bodyBuffer);
|
||||
}
|
||||
|
||||
// Decompress if gzip encoded
|
||||
if (headers["content-encoding"] === "gzip" && bodyBuffer.length > 0) {
|
||||
bodyBuffer = gunzipSync(bodyBuffer);
|
||||
}
|
||||
|
||||
const body = bodyBuffer.toString("utf8");
|
||||
resolve({ statusCode, body, headers });
|
||||
resolve({ statusCode, body });
|
||||
});
|
||||
|
||||
tlsSocket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function makeRegistryRequestAndGetCert(
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
targetHost,
|
||||
path,
|
||||
) {
|
||||
async function makeRegistryRequestAndGetCert(proxyHost, proxyPort, targetHost, path) {
|
||||
// Step 1: Connect to proxy
|
||||
const socket = await new Promise((resolve, reject) => {
|
||||
const sock = net.connect({ host: proxyHost, port: proxyPort }, () => {
|
||||
|
|
@ -355,7 +311,7 @@ async function makeRegistryRequestAndGetCert(
|
|||
const statusCode = parseInt(statusLine.split(" ")[1]);
|
||||
|
||||
// Find body after empty line
|
||||
const emptyLineIndex = lines.findIndex((line) => line === "");
|
||||
const emptyLineIndex = lines.findIndex(line => line === "");
|
||||
const body = lines.slice(emptyLineIndex + 1).join("\r\n");
|
||||
|
||||
resolve({ statusCode, body });
|
||||
|
|
@ -366,37 +322,3 @@ async function makeRegistryRequestAndGetCert(
|
|||
|
||||
return { cert: peerCert, response };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode HTTP chunked transfer encoding
|
||||
* Format: <chunk-size-hex>\r\n<chunk-data>\r\n ... 0\r\n\r\n
|
||||
* @param {Buffer} buffer
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
function decodeChunked(buffer) {
|
||||
const chunks = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < buffer.length) {
|
||||
// Find the end of the chunk size line
|
||||
const lineEnd = buffer.indexOf(Buffer.from("\r\n"), offset);
|
||||
if (lineEnd === -1) break;
|
||||
|
||||
// Parse chunk size (hex)
|
||||
const sizeHex = buffer.subarray(offset, lineEnd).toString("utf8");
|
||||
const chunkSize = parseInt(sizeHex, 16);
|
||||
|
||||
// End of chunks
|
||||
if (chunkSize === 0) break;
|
||||
|
||||
// Extract chunk data
|
||||
const dataStart = lineEnd + 2;
|
||||
const dataEnd = dataStart + chunkSize;
|
||||
chunks.push(buffer.subarray(dataStart, dataEnd));
|
||||
|
||||
// Move past chunk data and trailing \r\n
|
||||
offset = dataEnd + 2;
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ export function tunnelRequest(req, clientSocket, head) {
|
|||
function tunnelRequestToDestination(req, clientSocket, head) {
|
||||
const { port, hostname } = new URL(`http://${req.url}`);
|
||||
const isImds = isImdsEndpoint(hostname);
|
||||
const targetPort = Number.parseInt(port) || 443;
|
||||
|
||||
if (timedoutImdsEndpoints.includes(hostname)) {
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
|
|
@ -59,77 +58,64 @@ function tunnelRequestToDestination(req, clientSocket, head) {
|
|||
return;
|
||||
}
|
||||
|
||||
const connectTimeout = getConnectTimeout(hostname);
|
||||
|
||||
// Use JS setTimeout for true connection timeout (not idle timeout).
|
||||
// socket.setTimeout() measures inactivity, not time since connection attempt.
|
||||
const connectTimer = setTimeout(() => {
|
||||
if (isImds) {
|
||||
timedoutImdsEndpoints.push(hostname);
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`
|
||||
);
|
||||
} else {
|
||||
ui.writeError(
|
||||
`Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`
|
||||
);
|
||||
}
|
||||
serverSocket.destroy();
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end("HTTP/1.1 504 Gateway Timeout\r\n\r\n");
|
||||
}
|
||||
}, connectTimeout);
|
||||
|
||||
const serverSocket = net.connect(targetPort, hostname, () => {
|
||||
// Clear timer to prevent false timeout errors after successful connection
|
||||
clearTimeout(connectTimer);
|
||||
|
||||
const serverSocket = net.connect(
|
||||
Number.parseInt(port) || 443,
|
||||
hostname,
|
||||
() => {
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
serverSocket.write(head);
|
||||
serverSocket.pipe(clientSocket);
|
||||
clientSocket.pipe(serverSocket);
|
||||
}
|
||||
);
|
||||
|
||||
// Set explicit connection timeout to avoid waiting for OS default (~2 minutes).
|
||||
// IMDS endpoints get shorter timeout (3s) since they're commonly unreachable outside cloud environments.
|
||||
const connectTimeout = getConnectTimeout(hostname);
|
||||
serverSocket.setTimeout(connectTimeout);
|
||||
|
||||
serverSocket.on("timeout", () => {
|
||||
// Suppress error logging for IMDS endpoints - timeouts are expected when not in cloud
|
||||
if (isImds) {
|
||||
timedoutImdsEndpoints.push(hostname);
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: connect to ${hostname}:${
|
||||
port || 443
|
||||
} timed out after ${connectTimeout}ms`
|
||||
);
|
||||
} else {
|
||||
ui.writeError(
|
||||
`Safe-chain: connect to ${hostname}:${
|
||||
port || 443
|
||||
} timed out after ${connectTimeout}ms`
|
||||
);
|
||||
}
|
||||
serverSocket.destroy(); // Clean up socket to prevent event loop hanging
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
});
|
||||
|
||||
clientSocket.on("error", () => {
|
||||
// This can happen if the client TCP socket sends RST instead of FIN.
|
||||
// Not subscribing to 'error' event will cause node to throw and crash.
|
||||
clearTimeout(connectTimer);
|
||||
if (serverSocket.writable) {
|
||||
serverSocket.end();
|
||||
}
|
||||
});
|
||||
|
||||
clientSocket.on("close", () => {
|
||||
// Client closed connection - clean up server socket
|
||||
clearTimeout(connectTimer);
|
||||
if (serverSocket.writable) {
|
||||
serverSocket.end();
|
||||
}
|
||||
});
|
||||
|
||||
serverSocket.on("error", (err) => {
|
||||
clearTimeout(connectTimer);
|
||||
if (isImds) {
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`
|
||||
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
|
||||
);
|
||||
} else {
|
||||
ui.writeError(
|
||||
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`
|
||||
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
|
||||
);
|
||||
}
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
}
|
||||
});
|
||||
|
||||
serverSocket.on("close", () => {
|
||||
// Server closed connection - clean up client socket
|
||||
clearTimeout(connectTimer);
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -15,12 +15,8 @@ import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js";
|
|||
* @property {function(string, string): boolean} isMalware
|
||||
*/
|
||||
|
||||
// Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved
|
||||
// value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields
|
||||
// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all
|
||||
// concurrent callers see it immediately and share a single fetch.
|
||||
/** @type {Promise<MalwareDatabase> | null} */
|
||||
let cachedMalwareDatabasePromise = null;
|
||||
/** @type {MalwareDatabase | null} */
|
||||
let cachedMalwareDatabase = null;
|
||||
|
||||
/**
|
||||
* Normalize package name for comparison.
|
||||
|
|
@ -38,9 +34,13 @@ function normalizePackageName(name) {
|
|||
return name;
|
||||
}
|
||||
|
||||
export function openMalwareDatabase() {
|
||||
if (!cachedMalwareDatabasePromise) {
|
||||
cachedMalwareDatabasePromise = getMalwareDatabase().then((malwareDatabase) => {
|
||||
export async function openMalwareDatabase() {
|
||||
if (cachedMalwareDatabase) {
|
||||
return cachedMalwareDatabase;
|
||||
}
|
||||
|
||||
const malwareDatabase = await getMalwareDatabase();
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} version
|
||||
|
|
@ -63,19 +63,16 @@ export function openMalwareDatabase() {
|
|||
return packageData.reason;
|
||||
}
|
||||
|
||||
return {
|
||||
// This implicitly caches the malware database
|
||||
// that's closed over by the getPackageStatus function
|
||||
cachedMalwareDatabase = {
|
||||
getPackageStatus,
|
||||
isMalware: (/** @type {string} */ name, /** @type {string} */ version) => {
|
||||
isMalware: (name, version) => {
|
||||
const status = getPackageStatus(name, version);
|
||||
return isMalwareStatus(status);
|
||||
},
|
||||
};
|
||||
}).catch((error) => {
|
||||
cachedMalwareDatabasePromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return cachedMalwareDatabasePromise;
|
||||
return cachedMalwareDatabase;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,267 +0,0 @@
|
|||
import { describe, it, mock, beforeEach } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
// --- shared mutable state for mocks ---
|
||||
let fetchedList = [];
|
||||
let fetchedVersion = "etag-1";
|
||||
let fetchVersionResult = "etag-1";
|
||||
let minimumPackageAgeHours = 24;
|
||||
let ecosystem = "js";
|
||||
let writeWarningCalls = [];
|
||||
let fetchListError = null;
|
||||
let fetchVersionError = null;
|
||||
let importCounter = 0;
|
||||
let testHomeDir = "";
|
||||
|
||||
mock.module("../api/aikido.js", {
|
||||
namedExports: {
|
||||
fetchNewPackagesList: async () => {
|
||||
if (fetchListError) {
|
||||
throw fetchListError;
|
||||
}
|
||||
|
||||
return {
|
||||
newPackagesList: fetchedList,
|
||||
version: fetchedVersion,
|
||||
};
|
||||
},
|
||||
fetchNewPackagesListVersion: async () => {
|
||||
if (fetchVersionError) {
|
||||
throw fetchVersionError;
|
||||
}
|
||||
|
||||
return fetchVersionResult;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../environment/userInteraction.js", {
|
||||
namedExports: {
|
||||
ui: {
|
||||
writeWarning: (msg) => writeWarningCalls.push(msg),
|
||||
writeVerbose: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../config/settings.js", {
|
||||
namedExports: {
|
||||
getMinimumPackageAgeHours: () => minimumPackageAgeHours,
|
||||
getEcoSystem: () => ecosystem,
|
||||
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
|
||||
ECOSYSTEM_JS: "js",
|
||||
ECOSYSTEM_PY: "py",
|
||||
},
|
||||
});
|
||||
|
||||
// Import the warnings module so we can reset its state between tests.
|
||||
const { resetWarningState } = await import("./newPackagesDatabaseWarnings.js");
|
||||
|
||||
describe("newPackagesDatabase", async () => {
|
||||
beforeEach(() => {
|
||||
fetchedList = [];
|
||||
fetchedVersion = "etag-1";
|
||||
fetchVersionResult = "etag-1";
|
||||
minimumPackageAgeHours = 24;
|
||||
ecosystem = "js";
|
||||
writeWarningCalls = [];
|
||||
fetchListError = null;
|
||||
fetchVersionError = null;
|
||||
resetWarningState();
|
||||
testHomeDir = path.join(
|
||||
os.tmpdir(),
|
||||
`safe-chain-new-packages-db-${process.pid}-${importCounter}`
|
||||
);
|
||||
fs.rmSync(testHomeDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(testHomeDir, { recursive: true });
|
||||
process.env.HOME = testHomeDir;
|
||||
});
|
||||
|
||||
async function openNewPackagesDatabase() {
|
||||
const module = await import(
|
||||
`./newPackagesListCache.js?test_case=${importCounter++}`
|
||||
);
|
||||
return module.openNewPackagesDatabase();
|
||||
}
|
||||
|
||||
async function loadNewPackagesDatabaseModule() {
|
||||
return import(`./newPackagesListCache.js?test_case=${importCounter++}`);
|
||||
}
|
||||
|
||||
function hoursAgo(hours) {
|
||||
return Math.floor((Date.now() - hours * 3600 * 1000) / 1000);
|
||||
}
|
||||
|
||||
function writeCachedList(list, version) {
|
||||
const safeChainDir = path.join(testHomeDir, ".safe-chain");
|
||||
fs.mkdirSync(safeChainDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(safeChainDir, `newPackagesList_${ecosystem}.json`),
|
||||
JSON.stringify(list)
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`),
|
||||
version
|
||||
);
|
||||
}
|
||||
|
||||
describe("isNewlyReleasedPackage", () => {
|
||||
it("returns true for a package released within the age threshold", async () => {
|
||||
fetchedList = [
|
||||
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) },
|
||||
];
|
||||
|
||||
const db = await openNewPackagesDatabase();
|
||||
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
|
||||
});
|
||||
|
||||
it("returns false for a package released outside the age threshold", async () => {
|
||||
fetchedList = [
|
||||
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(48) },
|
||||
];
|
||||
|
||||
const db = await openNewPackagesDatabase();
|
||||
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
|
||||
});
|
||||
|
||||
it("returns false for a package not in the list", async () => {
|
||||
fetchedList = [];
|
||||
|
||||
const db = await openNewPackagesDatabase();
|
||||
assert.strictEqual(db.isNewlyReleasedPackage("not-there", "1.0.0"), false);
|
||||
});
|
||||
|
||||
it("returns false for a known package but different version", async () => {
|
||||
fetchedList = [
|
||||
{ package_name: "foo", version: "2.0.0", released_on: hoursAgo(1) },
|
||||
];
|
||||
|
||||
const db = await openNewPackagesDatabase();
|
||||
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
|
||||
});
|
||||
|
||||
it("matches the current feed ecosystem when source metadata is present", async () => {
|
||||
fetchedList = [
|
||||
{
|
||||
source: "pypi",
|
||||
package_name: "foo",
|
||||
version: "1.0.0",
|
||||
released_on: hoursAgo(1),
|
||||
},
|
||||
{
|
||||
source: "npm",
|
||||
package_name: "bar",
|
||||
version: "1.0.0",
|
||||
released_on: hoursAgo(1),
|
||||
},
|
||||
];
|
||||
|
||||
const db = await openNewPackagesDatabase();
|
||||
|
||||
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
|
||||
assert.strictEqual(db.isNewlyReleasedPackage("bar", "1.0.0"), true);
|
||||
});
|
||||
|
||||
it("respects a custom minimumPackageAgeHours threshold", async () => {
|
||||
minimumPackageAgeHours = 168; // 7 days
|
||||
fetchedList = [
|
||||
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(100) },
|
||||
];
|
||||
|
||||
const db = await openNewPackagesDatabase();
|
||||
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
|
||||
});
|
||||
|
||||
it("supports package checks for the python ecosystem", async () => {
|
||||
ecosystem = "py";
|
||||
fetchedList = [
|
||||
{
|
||||
source: "pypi",
|
||||
package_name: "foo",
|
||||
version: "1.0.0",
|
||||
released_on: hoursAgo(1),
|
||||
},
|
||||
];
|
||||
const db = await openNewPackagesDatabase();
|
||||
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("caching behaviour", () => {
|
||||
it("uses local cache when etag matches", async () => {
|
||||
writeCachedList([
|
||||
{ package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1) },
|
||||
], "etag-1");
|
||||
fetchVersionResult = "etag-1";
|
||||
// fetchedList is empty — if we used the remote list, the lookup would return false
|
||||
fetchedList = [];
|
||||
|
||||
const db = await openNewPackagesDatabase();
|
||||
assert.strictEqual(db.isNewlyReleasedPackage("cached-pkg", "1.0.0"), true);
|
||||
});
|
||||
|
||||
it("fetches fresh list when etag does not match", async () => {
|
||||
writeCachedList([
|
||||
{ package_name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1) },
|
||||
], "etag-old");
|
||||
fetchVersionResult = "etag-new";
|
||||
fetchedList = [
|
||||
{ package_name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1) },
|
||||
];
|
||||
|
||||
const db = await openNewPackagesDatabase();
|
||||
assert.strictEqual(db.isNewlyReleasedPackage("stale-pkg", "1.0.0"), false);
|
||||
assert.strictEqual(db.isNewlyReleasedPackage("fresh-pkg", "2.0.0"), true);
|
||||
});
|
||||
|
||||
it("falls back to local cache when fetch fails", async () => {
|
||||
writeCachedList([
|
||||
{
|
||||
package_name: "cached-pkg",
|
||||
version: "1.0.0",
|
||||
released_on: hoursAgo(1),
|
||||
},
|
||||
], "etag-old");
|
||||
fetchVersionResult = "etag-new";
|
||||
fetchListError = new Error("Network error");
|
||||
|
||||
const db = await openNewPackagesDatabase();
|
||||
|
||||
assert.strictEqual(db.isNewlyReleasedPackage("cached-pkg", "1.0.0"), true);
|
||||
assert.strictEqual(writeWarningCalls.length, 1);
|
||||
assert.ok(writeWarningCalls[0].includes("Using cached version"));
|
||||
});
|
||||
|
||||
it("emits a warning when list has no version (cannot be cached)", async () => {
|
||||
fetchedList = [
|
||||
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) },
|
||||
];
|
||||
fetchedVersion = undefined;
|
||||
|
||||
const db = await openNewPackagesDatabase();
|
||||
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
|
||||
assert.strictEqual(writeWarningCalls.length, 1);
|
||||
assert.ok(writeWarningCalls[0].includes("could not be cached"));
|
||||
});
|
||||
|
||||
it("fails open and only warns once when the new packages list cannot be loaded", async () => {
|
||||
fetchListError = new Error("feed unavailable");
|
||||
|
||||
const module = await loadNewPackagesDatabaseModule();
|
||||
const db1 = await module.openNewPackagesDatabase();
|
||||
const db2 = await module.openNewPackagesDatabase();
|
||||
|
||||
assert.strictEqual(db1.isNewlyReleasedPackage("foo", "1.0.0"), false);
|
||||
assert.strictEqual(db2.isNewlyReleasedPackage("foo", "1.0.0"), false);
|
||||
assert.strictEqual(writeWarningCalls.length, 1);
|
||||
assert.ok(
|
||||
writeWarningCalls[0].includes(
|
||||
"Continuing with metadata-based minimum age checks only"
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue