diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 08f714a..987db03 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -4,135 +4,9 @@ on: push: tags: - "*" - release: - types: [published] - -permissions: - id-token: write - contents: write jobs: - set-version: - name: Set version number - if: github.event_name == 'push' - runs-on: open-source-releaser - outputs: - version: ${{ steps.get_version.outputs.tag }} - steps: - - name: Set version number - id: get_version - run: | - version="${{ github.ref_name }}" - echo "tag=$version" >> $GITHUB_OUTPUT - - create-binaries: - if: github.event_name == 'push' - needs: set-version - uses: ./.github/workflows/create-artifact.yml - with: - version: ${{ needs.set-version.outputs.version }} - - publish-binaries: - name: Publish to GitHub release - if: github.event_name == 'push' - needs: [set-version, create-binaries] - runs-on: open-source-releaser - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Download all binary artifacts - uses: actions/download-artifact@v4 - with: - path: binaries/ - pattern: safe-chain-* - merge-multiple: false - - - name: Rename binaries to include platform and architecture - run: | - mkdir release-artifacts - mv binaries/safe-chain-macos-x64/safe-chain release-artifacts/safe-chain-macos-x64 - mv binaries/safe-chain-macos-arm64/safe-chain release-artifacts/safe-chain-macos-arm64 - mv binaries/safe-chain-linux-x64/safe-chain release-artifacts/safe-chain-linux-x64 - mv binaries/safe-chain-linux-arm64/safe-chain release-artifacts/safe-chain-linux-arm64 - mv binaries/safe-chain-linuxstatic-x64/safe-chain release-artifacts/safe-chain-linuxstatic-x64 - mv binaries/safe-chain-linuxstatic-arm64/safe-chain release-artifacts/safe-chain-linuxstatic-arm64 - mv binaries/safe-chain-win-x64/safe-chain.exe release-artifacts/safe-chain-win-x64.exe - mv binaries/safe-chain-win-arm64/safe-chain.exe release-artifacts/safe-chain-win-arm64.exe - - - name: Move install scripts and hard-code version and checksums - env: - VERSION: ${{ needs.set-version.outputs.version }} - run: | - SHA_MACOS_X64=$(sha256sum release-artifacts/safe-chain-macos-x64 | awk '{print $1}') - SHA_MACOS_ARM64=$(sha256sum release-artifacts/safe-chain-macos-arm64 | awk '{print $1}') - SHA_LINUX_X64=$(sha256sum release-artifacts/safe-chain-linux-x64 | awk '{print $1}') - SHA_LINUX_ARM64=$(sha256sum release-artifacts/safe-chain-linux-arm64 | awk '{print $1}') - SHA_LINUXSTATIC_X64=$(sha256sum release-artifacts/safe-chain-linuxstatic-x64 | awk '{print $1}') - SHA_LINUXSTATIC_ARM64=$(sha256sum release-artifacts/safe-chain-linuxstatic-arm64 | awk '{print $1}') - SHA_WIN_X64=$(sha256sum release-artifacts/safe-chain-win-x64.exe | awk '{print $1}') - SHA_WIN_ARM64=$(sha256sum release-artifacts/safe-chain-win-arm64.exe | awk '{print $1}') - - sed \ - -e "s/\$(fetch_latest_version)/${VERSION}/" \ - -e "s|^SHA256_MACOS_X64=\"\"|SHA256_MACOS_X64=\"${SHA_MACOS_X64}\"|" \ - -e "s|^SHA256_MACOS_ARM64=\"\"|SHA256_MACOS_ARM64=\"${SHA_MACOS_ARM64}\"|" \ - -e "s|^SHA256_LINUX_X64=\"\"|SHA256_LINUX_X64=\"${SHA_LINUX_X64}\"|" \ - -e "s|^SHA256_LINUX_ARM64=\"\"|SHA256_LINUX_ARM64=\"${SHA_LINUX_ARM64}\"|" \ - -e "s|^SHA256_LINUXSTATIC_X64=\"\"|SHA256_LINUXSTATIC_X64=\"${SHA_LINUXSTATIC_X64}\"|" \ - -e "s|^SHA256_LINUXSTATIC_ARM64=\"\"|SHA256_LINUXSTATIC_ARM64=\"${SHA_LINUXSTATIC_ARM64}\"|" \ - -e "s|^SHA256_WIN_X64=\"\"|SHA256_WIN_X64=\"${SHA_WIN_X64}\"|" \ - -e "s|^SHA256_WIN_ARM64=\"\"|SHA256_WIN_ARM64=\"${SHA_WIN_ARM64}\"|" \ - install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh - - sed \ - -e "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" \ - -e "s|^\$SHA256_MACOS_X64 = \"\"|\$SHA256_MACOS_X64 = \"${SHA_MACOS_X64}\"|" \ - -e "s|^\$SHA256_MACOS_ARM64 = \"\"|\$SHA256_MACOS_ARM64 = \"${SHA_MACOS_ARM64}\"|" \ - -e "s|^\$SHA256_LINUX_X64 = \"\"|\$SHA256_LINUX_X64 = \"${SHA_LINUX_X64}\"|" \ - -e "s|^\$SHA256_LINUX_ARM64 = \"\"|\$SHA256_LINUX_ARM64 = \"${SHA_LINUX_ARM64}\"|" \ - -e "s|^\$SHA256_LINUXSTATIC_X64 = \"\"|\$SHA256_LINUXSTATIC_X64 = \"${SHA_LINUXSTATIC_X64}\"|" \ - -e "s|^\$SHA256_LINUXSTATIC_ARM64 = \"\"|\$SHA256_LINUXSTATIC_ARM64 = \"${SHA_LINUXSTATIC_ARM64}\"|" \ - -e "s|^\$SHA256_WIN_X64 = \"\"|\$SHA256_WIN_X64 = \"${SHA_WIN_X64}\"|" \ - -e "s|^\$SHA256_WIN_ARM64 = \"\"|\$SHA256_WIN_ARM64 = \"${SHA_WIN_ARM64}\"|" \ - install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1 - - cp install-scripts/uninstall-safe-chain.sh release-artifacts/uninstall-safe-chain.sh - cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1 - cp install-scripts/install-endpoint-mac.sh release-artifacts/install-endpoint-mac.sh - cp install-scripts/install-endpoint-windows.ps1 release-artifacts/install-endpoint-windows.ps1 - cp install-scripts/uninstall-endpoint-mac.sh release-artifacts/uninstall-endpoint-mac.sh - cp install-scripts/uninstall-endpoint-windows.ps1 release-artifacts/uninstall-endpoint-windows.ps1 - - - name: Create draft release and upload assets - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.set-version.outputs.version }} - run: | - if ! gh release view "$VERSION" &>/dev/null; then - gh release create "$VERSION" --draft --title "$VERSION" --generate-notes - fi - gh release upload "$VERSION" --clobber \ - release-artifacts/safe-chain-macos-x64 \ - release-artifacts/safe-chain-macos-arm64 \ - release-artifacts/safe-chain-linux-x64 \ - release-artifacts/safe-chain-linux-arm64 \ - release-artifacts/safe-chain-linuxstatic-x64 \ - release-artifacts/safe-chain-linuxstatic-arm64 \ - release-artifacts/safe-chain-win-x64.exe \ - release-artifacts/safe-chain-win-arm64.exe \ - release-artifacts/install-safe-chain.sh \ - release-artifacts/install-safe-chain.ps1 \ - release-artifacts/uninstall-safe-chain.sh \ - release-artifacts/uninstall-safe-chain.ps1 \ - release-artifacts/install-endpoint-mac.sh \ - release-artifacts/install-endpoint-windows.ps1 \ - release-artifacts/uninstall-endpoint-mac.sh \ - release-artifacts/uninstall-endpoint-windows.ps1 - - publish-npm: - name: Publish to npm - if: github.event_name == 'release' + build: runs-on: ubuntu-latest steps: @@ -144,12 +18,22 @@ jobs: with: node-version: "lts/*" registry-url: "https://registry.npmjs.org/" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - name: Setup safe-chain - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + run: | + npm i -g @aikidosec/safe-chain + safe-chain setup-ci + + - name: Set version number + id: get_version + run: | + version="${{ github.ref_name }}" + echo "tag=$version" >> $GITHUB_OUTPUT - name: Set the version in safe-chain package - run: npm --no-git-tag-version version ${{ github.event.release.tag_name }} --workspace=packages/safe-chain + run: npm --no-git-tag-version version ${{ steps.get_version.outputs.tag }} --workspace=packages/safe-chain - name: Install dependencies run: npm ci @@ -162,15 +46,10 @@ jobs: 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 + echo "Publishing version ${{ steps.get_version.outputs.tag }} to NPM" + npm publish --workspace=packages/safe-chain --access public + env: + NPM_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml deleted file mode 100644 index 8c61826..0000000 --- a/.github/workflows/bump-endpoint.yml +++ /dev/null @@ -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}\"}" diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml deleted file mode 100644 index da2a1bd..0000000 --- a/.github/workflows/create-artifact.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Create binaries - -on: - pull_request: - workflow_call: - inputs: - version: - description: "Version to set in package.json" - required: false - type: string - -jobs: - create-binaries: - name: Create binary for ${{ matrix.os }}-${{ matrix.arch }} - - runs-on: ${{ matrix.runner }} - - strategy: - fail-fast: false - matrix: - include: - - os: macos - arch: x64 - runner: macos-15-intel - target: node20-macos-x64 - extension: "" - - os: macos - arch: arm64 - runner: macos-latest - target: node20-macos-arm64 - extension: "" - - os: linux - arch: x64 - runner: ubuntu-latest - target: node20-linux-x64 - extension: "" - - os: linux - arch: arm64 - runner: ubuntu-24.04-arm - target: node20-linux-arm64 - extension: "" - - os: linuxstatic - arch: x64 - runner: ubuntu-latest - target: node20-linuxstatic-x64 - extension: "" - - os: linuxstatic - arch: arm64 - runner: ubuntu-24.04-arm - target: node20-linuxstatic-arm64 - extension: "" - - os: win - arch: x64 - runner: windows-latest - target: node20-win-x64 - extension: ".exe" - - os: win - arch: arm64 - runner: windows-11-arm - target: node20-win-arm64 - extension: ".exe" - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: "20.x" - - - name: Setup safe-chain - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - shell: bash - - - name: Install dependencies - run: npm ci --ignore-scripts - - - name: Set the version in safe-chain package - if: inputs.version != '' - env: - VERSION: ${{ inputs.version }} - shell: bash - run: npm --no-git-tag-version version $VERSION --workspace=packages/safe-chain --ignore-scripts - - - name: Create binary - run: | - node build.js ${{ matrix.target }} - - - name: Upload binary artifact - uses: actions/upload-artifact@v4 - with: - name: safe-chain-${{ matrix.os }}-${{ matrix.arch }} - path: dist/* diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index 744f52c..c31138c 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -6,12 +6,7 @@ jobs: unit-test: name: Run unit tests and linting - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ubuntu-latest steps: - name: Checkout code @@ -23,28 +18,24 @@ 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 + run: npm ci - name: Run unit tests run: npm test - - name: Run linting + - name: Run ESLint run: npm run lint - - name: Type check - run: npm run typecheck --workspace=packages/safe-chain - - name: Create package tarball - if: matrix.os == 'ubuntu-latest' run: npm pack --workspace=packages/safe-chain - name: Upload package tarball uses: actions/upload-artifact@v4 - if: matrix.os == 'ubuntu-latest' with: name: safe-chain-package path: aikidosec-safe-chain-*.tgz @@ -77,7 +68,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 +78,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 +100,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 + safe-chain setup-ci - name: Install dependencies (root) run: npm ci diff --git a/.gitignore b/.gitignore index 920883f..acae695 100644 --- a/.gitignore +++ b/.gitignore @@ -143,11 +143,4 @@ vite.config.ts.timestamp-* # AI Claude.md .claude -.reference - -# Build files -build/ -dist/ - -# Jetbrains IDEs -.idea/** +.reference \ No newline at end of file diff --git a/.oxlintrc.json b/.oxlintrc.json deleted file mode 100644 index b9c483c..0000000 --- a/.oxlintrc.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": [ - "node", - "promise", - "eslint", - "unicorn", - "oxc", - "import" - ], - "env": { - "browser": false, - "node": true - }, - "rules": { - "eslint/no-console": "error", - "eslint/no-empty": "error", - "eslint/no-undef": "error" - }, - "overrides": [ - { - "files": [ - "*.spec.js" - ], - "rules": { - "eslint/no-console": "off" - } - } - ] -} diff --git a/README.md b/README.md index cb8f34b..d36f1a0 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,53 @@ -![Aikido Safe Chain](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/banner.svg) - # Aikido Safe Chain -[![NPM Version](https://img.shields.io/npm/v/%40aikidosec%2Fsafe-chain?style=flat-square)](https://www.npmjs.com/package/@aikidosec/safe-chain) -[![NPM Downloads](https://img.shields.io/npm/dw/%40aikidosec%2Fsafe-chain?style=flat-square)](https://www.npmjs.com/package/@aikidosec/safe-chain) +The Aikido Safe Chain **prevents developers from installing malware** on their workstations through npm, npx, yarn, pnpm and pnpx. It's **free** to use and does not require any token. -- ✅ **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 -- ✅ **Tokenless, free, no build data shared** +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/), and [pnpx](https://pnpm.io/cli/dlx) 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 or pnpx from downloading or running the malware. -## Need protection beyond npm & PyPI? +![demo](./docs/safe-package-manager-demo.png) -[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. +Aikido Safe Chain works on Node.js version 18 and above and supports the following package managers: -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). +- ✅ full coverage: **npm >= 10.4.0**: +- ⚠️ limited to scanning the install command arguments (broader scanning coming soon): + - **npm < 10.4.0** + - **npx** + - **yarn** + - **pnpm** + - **pnpx** +- 🚧 **bun**: coming soon ---- - -Aikido Safe Chain supports the following package managers: - -- 📦 **npm** -- 📦 **npx** -- 📦 **yarn** -- 📦 **pnpm** -- 📦 **pnpx** -- 📦 **rush** -- 📦 **rushx** -- 📦 **bun** -- 📦 **bunx** -- 📦 **pip** -- 📦 **pip3** -- 📦 **uv** -- 📦 **poetry** -- 📦 **uvx** -- 📦 **pipx** -- 📦 **pdm** +Note on the limited support for npm < 10.4.0, npx, yarn, pnpm and pnpx: adding **full support for these package managers is a high priority**. In the meantime, we offer limited support already, which means that the Aikido Safe Chain will scan the package names passed as arguments to the install commands. However, it will not scan the full dependency tree of these packages. # Usage -![Aikido Safe Chain demo](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/safe-package-manager-demo.gif) - ## Installation -Installing the Aikido Safe Chain is easy with our one-line installer. - -### Unix/Linux/macOS - -```shell -curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -``` - -### Windows (PowerShell) - -```powershell -iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1" -UseBasicParsing) -``` - -### Pinning to a specific version - -To install a specific version instead of the latest, replace `latest` with the version number in the URL (available from version 1.3.2 onwards): - -**Unix/Linux/macOS:** - -```shell -curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.sh | sh -``` - -**Windows (PowerShell):** - -```powershell -iex (iwr "https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.ps1" -UseBasicParsing) -``` - -You can find all available versions on the [releases page](https://github.com/AikidoSec/safe-chain/releases). - -### Verify the installation - -1. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, poetry, uv, uvx, pipx and pdm are loaded correctly. If you do not restart your terminal, the aliases will not be available. - -2. **Verify the installation** by running the verification command: +Installing the Aikido Safe Chain is easy. You just need 3 simple steps: +1. **Install the Aikido Safe Chain package globally** using npm: ```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 + npm install -g @aikidosec/safe-chain ``` - - - 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: - - For JavaScript/Node.js: - +2. **Setup the shell integration** by running: + ```shell + safe-chain setup + ``` +3. **❗Restart your terminal** to start using the Aikido Safe Chain. + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm and pnpx are loaded correctly. If you do not restart your terminal, the aliases will not be available. +4. **Verify the installation** by running: ```shell npm install safe-chain-test ``` + - The output should show that Aikido Safe Chain is blocking the installation of this package as it is flagged as malware. - For Python: - - ```shell - pip3 install safe-chain-pi-test - ``` - - - 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. - -You can check the installed version by running: - -```shell -safe-chain --version -``` +When running `npm`, `npx`, `yarn`, `pnpm` or `pnpx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. If any malware is detected, it will prompt you to exit the command. ## How it works -### Malware Blocking +The Aikido Safe Chain works by intercepting the npm, npx, yarn, pnpm and pnpx commands and verifying the packages against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. -The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, uv, uvx, poetry, pipx or pdm commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. - -### Minimum package age - -Safe Chain applies minimum package age checks to supported ecosystems. - -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. - -### 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 and pnpx commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which perform malware checks before executing the original commands. We currently support: - ✅ **Bash** - ✅ **Zsh** @@ -148,238 +55,57 @@ The Aikido Safe Chain integrates with your shell to provide a seamless experienc - ✅ **PowerShell** - ✅ **PowerShell Core** -More information about the shell integration can be found in the [shell integration documentation](https://github.com/AikidoSec/safe-chain/blob/main/docs/shell-integration.md). +More information about the shell integration can be found in the [shell integration documentation](docs/shell-integration.md). ## Uninstallation -To uninstall the Aikido Safe Chain, use our one-line uninstaller: +To uninstall the Aikido Safe Chain, you can run the following command: -### Unix/Linux/macOS - -```shell -curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/uninstall-safe-chain.sh | sh -``` - -### Windows (PowerShell) - -```powershell -iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/uninstall-safe-chain.ps1" -UseBasicParsing) -``` - -**❗Restart your terminal** after uninstalling to ensure all aliases are removed. +1. **Remove all aliases from your shell** by running: + ```shell + safe-chain teardown + ``` +2. **Uninstall the Aikido Safe Chain package** using npm: + ```shell + npm uninstall -g @aikidosec/safe-chain + ``` +3. **❗Restart your terminal** to remove the aliases. # Configuration -## Logging +## Malware Action -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 how Aikido Safe Chain responds when malware is detected using the `--safe-chain-malware-action` flag: -### Configuration Options +- `--safe-chain-malware-action=block` (**default**) - Automatically blocks installation and exits with an error when malware is detected +- `--safe-chain-malware-action=prompt` - Prompts the user to decide whether to continue despite the malware detection -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. - - ```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. - - ```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. - -### Configuration Options - -You can set the minimum package age through multiple sources (in order of priority): - -1. **CLI Argument** (highest priority): - - ```shell - npm install express --safe-chain-minimum-package-age-hours=48 - ``` - -2. **Environment Variable**: - - ```shell - export SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS=48 - npm install express - ``` - -3. **Config File** (`~/.safe-chain/config.json`): - - ```json - { - "minimumPackageAgeHours": 48 - } - ``` - -### Excluding Packages - -Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Use `@scope/*` to trust all packages from an organization: +Example usage: ```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'" +npm install suspicious-package --safe-chain-malware-action=prompt ``` # 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. -## Installation for CI/CD +For optimal protection in CI/CD environments, we recommend using **npm >= 10.4.0** as it provides full dependency tree scanning. Other package managers currently offer limited scanning of install command arguments only. -Use the `--ci` flag to automatically configure Aikido Safe Chain for CI/CD environments. This sets up executable shims in the PATH instead of shell aliases. +## Setup -### Unix/Linux/macOS (GitHub Actions, Azure Pipelines, etc.) +To use Aikido Safe Chain in CI/CD environments, run the following command after installing the package: ```shell -curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci +safe-chain setup-ci ``` -### Windows (Azure Pipelines, etc.) - -```powershell -iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" -``` +This automatically configures your CI environment to use Aikido Safe Chain for all package manager commands. ## Supported Platforms - ✅ **GitHub Actions** - ✅ **Azure Pipelines** -- ✅ **CircleCI** -- ✅ **Jenkins** -- ✅ **Bitbucket Pipelines** -- ✅ **GitLab Pipelines** ## GitHub Actions Example @@ -390,11 +116,14 @@ iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download node-version: "22" 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 +- name: Setup safe-chain + run: | + npm i -g @aikidosec/safe-chain + safe-chain setup-ci - name: Install dependencies - run: npm ci + run: | + npm ci ``` ## Azure DevOps Example @@ -405,162 +134,14 @@ 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 - displayName: "Install safe-chain" +- script: | + npm i -g @aikidosec/safe-chain + safe-chain setup-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 +- script: | + npm ci + displayName: "npm install and build" ``` 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) diff --git a/build.js b/build.js deleted file mode 100644 index 81619f4..0000000 --- a/build.js +++ /dev/null @@ -1,139 +0,0 @@ -import { build } from "esbuild"; -import { mkdir, cp, rm, readFile, writeFile, stat } from "node:fs/promises"; -import { spawn } from "node:child_process"; -import { resolve } from "node:path"; - -const target = process.argv[2]; -if (!target) { - // eslint-disable-next-line no-console - console.error("Usage: node build.js "); - // eslint-disable-next-line no-console - console.error("Example: node build.js node22-macos-arm64"); - process.exit(1); -} - -(async function main() { - const startBuildTime = performance.now(); - - await clearOutputFolder(); - console.log("- Cleared output folder ✅") - - // Esbuild creates a single safe-chain.cjs with all dependencies included - await bundleSafeChain(); - console.log("- Bundled safe-chain into safe-chain.cjs (es-build) ✅") - - // Copy assets that need to be included in the binary - // - All shell scripts that are used to setup safe-chain - // - Certifi because it contains static root certs for Python - // - Package.json for its metadata (package name, version, ...) - await copyShellScripts(); - await copyCertifi(); - await copyAndModifyPackageJson(); - console.log("- Copied auxiliary resources (shell, package.json,...) ✅") - - // Creates a single binary with safe-chain.cjs and the copied assets - await buildSafeChainBinary(target); - console.log(`- Built safe-chain binary for ${target} (pkg) ✅`) - - - const totalBuildTime = (performance.now() - startBuildTime)/1000; - const totalSizeInMb = (await stat("./dist/safe-chain" + (process.platform === "win32" ? ".exe" : ""))).size / (1024*1024); - console.log(`🏁 Finished build in ${totalBuildTime.toFixed(2)}s, total build size: ${totalSizeInMb.toFixed(2)}MB`); -})(); - -async function clearOutputFolder() { - await rm("./build", { recursive: true, force: true }); - await mkdir("./build"); -} - -async function bundleSafeChain() { - await build({ - entryPoints: ["./packages/safe-chain/bin/safe-chain.js"], - bundle: true, - platform: "node", - target: "node24", - outfile: "./build/bin/safe-chain.cjs", - external: ["certifi"], - }); - - let bundledContent = await readFile("./build/bin/safe-chain.cjs", "utf-8"); - - await writeFile("./build/bin/safe-chain.cjs", bundledContent); -} - -async function copyShellScripts() { - await mkdir("./build/bin/startup-scripts", { recursive: true }); - await cp( - "./packages/safe-chain/src/shell-integration/startup-scripts/", - "./build/bin/startup-scripts", - { recursive: true } - ); - await mkdir("./build/bin/path-wrappers", { recursive: true }); - await cp( - "./packages/safe-chain/src/shell-integration/path-wrappers/", - "./build/bin/path-wrappers", - { recursive: true } - ); -} - -async function copyCertifi() { - await mkdir("./build/node_modules/certifi", { recursive: true }); - await cp("./node_modules/certifi/", "./build/node_modules/certifi", { - recursive: true, - }); -} -async function copyAndModifyPackageJson() { - const packageJsonContent = await readFile( - "./packages/safe-chain/package.json", - "utf-8" - ); - const packageJson = JSON.parse(packageJsonContent); - - delete packageJson.main; - delete packageJson.scripts; - delete packageJson.exports; - delete packageJson.dependencies; - delete packageJson.devDependencies; - - packageJson.bin = { - "safe-chain": "bin/safe-chain.cjs", - }; - packageJson.type = "commonjs"; - packageJson.pkg = { - outputPath: "dist", - assets: [ - "node_modules/certifi/**/*", - "bin/startup-scripts/**/*", - "bin/path-wrappers/**/*", - ], - }; - - await writeFile("./build/package.json", JSON.stringify(packageJson, null, 2)); - - return packageJson; -} - -function buildSafeChainBinary(target) { - return new Promise((promiseResolve, reject) => { - // Use .cmd on Windows, resolve to absolute path for cross-platform compatibility - const pkgBin = process.platform === "win32" - ? resolve("node_modules/.bin/pkg.cmd") - : resolve("node_modules/.bin/pkg"); - - let pkgArgs = []; - - pkgArgs = pkgArgs.concat(["./build/package.json", "-t", target]); - const pkg = spawn(pkgBin, pkgArgs, { - stdio: "inherit", - shell: true, - }); - - pkg.on("close", (code) => { - if (code !== 0) { - reject(new Error(`pkg process exited with code ${code}`)); - } else { - promiseResolve(); - } - }); - }); -} diff --git a/docs/Release.md b/docs/Release.md deleted file mode 100644 index ed116d2..0000000 --- a/docs/Release.md +++ /dev/null @@ -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. diff --git a/docs/banner.svg b/docs/banner.svg deleted file mode 100644 index ce9a00c..0000000 --- a/docs/banner.svg +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/safe-package-manager-demo.gif b/docs/safe-package-manager-demo.gif index 10cf972..9d22d8c 100644 Binary files a/docs/safe-package-manager-demo.gif and b/docs/safe-package-manager-demo.gif differ diff --git a/docs/shell-integration.md b/docs/shell-integration.md index d6cc0e0..6b2c79e 100644 --- a/docs/shell-integration.md +++ b/docs/shell-integration.md @@ -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`) with Aikido's security scanning functionality. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. ## Supported Shells @@ -28,8 +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` -- Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name +- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, and `pnpx` ❗ 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 +77,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` and `aikido-pnpx` commands exist - Check that these commands are in your system's PATH ### Manual Verification @@ -121,29 +120,4 @@ 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. - -To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions: - -```bash -# Example for Bash/Zsh -python() { - if [[ "$1" == "-m" && "$2" == pip* ]]; then - local mod="$2"; shift 2 - if [[ "$mod" == "pip3" ]]; then aikido-pip3 "$@"; else aikido-pip "$@"; fi - else - command python "$@" - fi -} - -python3() { - if [[ "$1" == "-m" && "$2" == pip* ]]; then - local mod="$2"; shift 2 - if [[ "$mod" == "pip3" ]]; then aikido-pip3 "$@"; else aikido-pip "$@"; fi - else - command python3 "$@" - fi -} -``` - -Limitations: these only apply when invoking `python`/`python3` by name. Absolute paths (e.g., `/usr/bin/python -m pip`) bypass shell functions. +Repeat this pattern for `npx`, `yarn`, `pnpm`, and `pnpx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md deleted file mode 100644 index 4672849..0000000 --- a/docs/troubleshooting.md +++ /dev/null @@ -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\\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//.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 -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 -``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..3db1b7f --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,26 @@ +import js from "@eslint/js"; +import { defineConfig, globalIgnores } from "@eslint/config-helpers"; +import globals from "globals"; +import importPlugin from "eslint-plugin-import"; + +export default defineConfig([ + { + files: ["**/*.{js,mjs,cjs,ts}"], + plugins: { js }, + extends: ["js/recommended"], + }, + { + files: ["**/*.{js,mjs,cjs,ts}"], + languageOptions: { globals: globals.node }, + }, + importPlugin.flatConfigs.recommended, + { + files: ["**/*.{js,mjs,cjs}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, + rules: {}, + }, + globalIgnores(['test/e2e', 'node_modules']), +]); diff --git a/install-scripts/install-endpoint-mac.sh b/install-scripts/install-endpoint-mac.sh deleted file mode 100755 index 4cb9e9a..0000000 --- a/install-scripts/install-endpoint-mac.sh +++ /dev/null @@ -1,133 +0,0 @@ -#!/bin/sh - -# Downloads and installs Aikido Endpoint Protection on macOS -# -# Usage: curl -fsSL | sudo sh -s -- --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 | sudo sh -s -- --token " - fi - - # Check if token is provided via command argument - if [ -z "$TOKEN" ]; then - error "Token is required. Pass it with --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 "$@" diff --git a/install-scripts/install-endpoint-windows.ps1 b/install-scripts/install-endpoint-windows.ps1 deleted file mode 100644 index 05da8b4..0000000 --- a/install-scripts/install-endpoint-windows.ps1 +++ /dev/null @@ -1,100 +0,0 @@ -# Downloads and installs Aikido Endpoint Protection on Windows -# -# Usage: iex "& { $(iwr '' -UseBasicParsing) } -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 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: $_" -} diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 deleted file mode 100644 index 53ce15f..0000000 --- a/install-scripts/install-safe-chain.ps1 +++ /dev/null @@ -1,381 +0,0 @@ -# Downloads and installs safe-chain for Windows -# -# Usage with "iex (iwr {url} -UseBasicParsing)" --> See README.md - -param( - [switch]$ci, - [switch]$includepython, - [string]$InstallDir -) - -# Validates and normalizes the requested install directory. -# Rejects non-absolute, root, PATH-like, and traversal-containing paths. -function Test-InstallDir { - param([string]$Dir) - - if ([string]::IsNullOrWhiteSpace($Dir)) { - return @{ Ok = $true; Normalized = $null } - } - - if (-not [System.IO.Path]::IsPathRooted($Dir)) { - return @{ Ok = $false; Reason = "-InstallDir must be an absolute path, got: $Dir" } - } - - if ($Dir.Contains([System.IO.Path]::PathSeparator)) { - return @{ Ok = $false; Reason = "-InstallDir must not contain the PATH separator ($([System.IO.Path]::PathSeparator))" } - } - - $inputSegments = $Dir.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries) - if ($inputSegments -contains "..") { - return @{ Ok = $false; Reason = "-InstallDir must not contain path traversal segments" } - } - - $normalized = [System.IO.Path]::GetFullPath($Dir) - $root = [System.IO.Path]::GetPathRoot($normalized) - if ($normalized.TrimEnd('\', '/') -eq $root.TrimEnd('\', '/')) { - return @{ Ok = $false; Reason = "-InstallDir cannot be a root or drive-root directory" } - } - - return @{ Ok = $true; Normalized = $normalized } -} - -$Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set -$SafeChainBase = if ($InstallDir) { $InstallDir } else { Join-Path $HOME ".safe-chain" } - -$installDirValidation = Test-InstallDir -Dir $SafeChainBase -if (-not $installDirValidation.Ok) { - Write-Host "[ERROR] $($installDirValidation.Reason)" -ForegroundColor Red - exit 1 -} - -$SafeChainBase = $installDirValidation.Normalized -$InstallDir = Join-Path $SafeChainBase "bin" -$RepoUrl = "https://github.com/AikidoSec/safe-chain" - -# SHA256 checksums for release binaries. -# Empty in source; populated by the release pipeline. -# When empty (running from main), checksum verification is skipped. -# Non-Windows hashes are unused today (PS script is Windows-only) but baked in -# for future cross-platform support. -$SHA256_MACOS_X64 = "" -$SHA256_MACOS_ARM64 = "" -$SHA256_LINUX_X64 = "" -$SHA256_LINUX_ARM64 = "" -$SHA256_LINUXSTATIC_X64 = "" -$SHA256_LINUXSTATIC_ARM64 = "" -$SHA256_WIN_X64 = "" -$SHA256_WIN_ARM64 = "" - -# Ensure TLS 1.2 is enabled for downloads -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - -# Helper functions -function Write-Info { - param([string]$Message) - Write-Host "[INFO] $Message" -ForegroundColor Green -} - -function Write-Warn { - param([string]$Message) - Write-Host "[WARN] $Message" -ForegroundColor Yellow -} - -function Write-Error-Custom { - param([string]$Message) - Write-Host "[ERROR] $Message" -ForegroundColor Red - exit 1 -} - -# Get currently installed version of safe-chain -function Get-InstalledVersion { - # Check if safe-chain command exists - if (-not (Get-Command safe-chain -ErrorAction SilentlyContinue)) { - return $null - } - - try { - # Execute safe-chain -v and capture output - $output = & safe-chain -v 2>&1 - - # Extract version from "Current safe-chain version: X.Y.Z" output - if ($output -match "Current safe-chain version:\s*(.+)") { - return $matches[1].Trim() - } - - return $null - } - catch { - return $null - } -} - -# Check if the requested version is already installed -function Test-VersionInstalled { - param([string]$RequestedVersion) - - $installedVersion = Get-InstalledVersion - - if ([string]::IsNullOrWhiteSpace($installedVersion)) { - return $false - } - - # Strip leading 'v' from versions if present for comparison - $requestedClean = $RequestedVersion -replace '^v', '' - $installedClean = $installedVersion -replace '^v', '' - - return $requestedClean -eq $installedClean -} - -# Fetch latest release version tag from GitHub -function Get-LatestVersion { - try { - $response = Invoke-RestMethod -Uri "https://api.github.com/repos/AikidoSec/safe-chain/releases/latest" -UseBasicParsing - $latestVersion = $response.tag_name - - if ([string]::IsNullOrWhiteSpace($latestVersion)) { - Write-Error-Custom "Failed to fetch latest version from GitHub API. Please set SAFE_CHAIN_VERSION environment variable." - } - - return $latestVersion - } - catch { - Write-Error-Custom "Failed to fetch latest version from GitHub API: $($_.Exception.Message). Please set SAFE_CHAIN_VERSION environment variable." - } -} - -# Detect architecture -function Get-Architecture { - $arch = $env:PROCESSOR_ARCHITECTURE - switch ($arch) { - "AMD64" { return "x64" } - "ARM64" { return "arm64" } - default { Write-Error-Custom "Unsupported architecture: $arch" } - } -} - -# Emits the deprecation warning for SAFE_CHAIN_VERSION and prints the version-pinned install command. -# Returns immediately when no version was provided through the environment. -function Write-VersionDeprecationWarning { - if ([string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { - return - } - - Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated." - Write-Warn "" - Write-Warn "Please use direct download URLs for version pinning instead:" - Write-Warn "" - if ($ci) { - Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`"" - } else { - Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)" - } - Write-Warn "" -} - -# Builds the Windows release binary filename for the detected architecture. -# Centralizes binary name generation for the download step. -function Get-BinaryName { - param([string]$Architecture) - - return "safe-chain-win-$Architecture.exe" -} - -# Returns the expected SHA256 for the given OS+arch, or empty if not baked in. -function Get-ExpectedSha256 { - param([string]$Os, [string]$Architecture) - switch ("$Os-$Architecture") { - "macos-x64" { return $SHA256_MACOS_X64 } - "macos-arm64" { return $SHA256_MACOS_ARM64 } - "linux-x64" { return $SHA256_LINUX_X64 } - "linux-arm64" { return $SHA256_LINUX_ARM64 } - "linuxstatic-x64" { return $SHA256_LINUXSTATIC_X64 } - "linuxstatic-arm64" { return $SHA256_LINUXSTATIC_ARM64 } - "win-x64" { return $SHA256_WIN_X64 } - "win-arm64" { return $SHA256_WIN_ARM64 } - default { return "" } - } -} - -function Test-Checksum { - param([string]$File, [string]$Expected) - - if ([string]::IsNullOrWhiteSpace($Expected)) { return } - - $actual = (Get-FileHash -Path $File -Algorithm SHA256).Hash.ToLowerInvariant() - $expectedLower = $Expected.ToLowerInvariant() - - if ($actual -ne $expectedLower) { - Remove-Item -Path $File -Force -ErrorAction SilentlyContinue - Write-Error-Custom "Checksum verification failed. Expected: $expectedLower, Got: $actual" - } - - Write-Info "Checksum verified." -} - -# Runs safe-chain setup or setup-ci after the binary is installed. -# Temporarily appends the install directory to PATH and downgrades setup failures to warnings. -function Invoke-SafeChainSetup { - param( - [string]$BinaryPath, - [string]$InstallDirectory - ) - - $setupCmd = if ($ci) { "setup-ci" } else { "setup" } - - Write-Info "Running safe-chain $setupCmd..." - try { - $env:Path = "$env:Path;$InstallDirectory" - & $BinaryPath $setupCmd - - if ($LASTEXITCODE -ne 0) { - Write-Warn "safe-chain was installed but setup encountered issues." - Write-Warn "You can run 'safe-chain $setupCmd' manually later." - } - } - catch { - Write-Warn "safe-chain was installed but setup encountered issues: $_" - Write-Warn "You can run 'safe-chain $setupCmd' manually later." - } -} - -# Check and uninstall npm global package if present -function Remove-NpmInstallation { - # Check if npm is available - if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { - return - } - - # Check if safe-chain is installed as an npm global package - npm list -g @aikidosec/safe-chain 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Info "Detected npm global installation of @aikidosec/safe-chain" - Write-Info "Uninstalling npm version before installing binary version..." - - npm uninstall -g @aikidosec/safe-chain 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Info "Successfully uninstalled npm version" - } - else { - Write-Warn "Failed to uninstall npm version automatically" - Write-Warn "Please run: npm uninstall -g @aikidosec/safe-chain" - } - } -} - -# Check and uninstall Volta-managed package if present -function Remove-VoltaInstallation { - # Check if Volta is available - if (-not (Get-Command volta -ErrorAction SilentlyContinue)) { - return - } - - # Volta manages global packages in its own directory - # Check if safe-chain is installed via Volta - volta list safe-chain 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Info "Detected Volta installation of @aikidosec/safe-chain" - Write-Info "Uninstalling Volta version before installing binary version..." - - volta uninstall @aikidosec/safe-chain 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Info "Successfully uninstalled Volta version" - } - else { - Write-Warn "Failed to uninstall Volta version automatically" - Write-Warn "Please run: volta uninstall @aikidosec/safe-chain" - } - } -} - -# Main installation -function Install-SafeChain { - Write-VersionDeprecationWarning - - # Fetch latest version if VERSION is not set - if ([string]::IsNullOrWhiteSpace($Version)) { - Write-Info "Fetching latest release version..." - $Version = Get-LatestVersion - } - - # Check if the requested version is already installed - if (Test-VersionInstalled -RequestedVersion $Version) { - Write-Info "safe-chain $Version is already installed" - return - } - - # Build installation message - $installMsg = "Installing safe-chain $Version" - if ($ci) { - $installMsg += " in ci" - } - if ($includepython) { - Write-Warn "-includepython is deprecated and ignored. Python ecosystem is now included by default." - } - - Write-Info $installMsg - - # Check for existing safe-chain installation through npm or volta - Remove-NpmInstallation - Remove-VoltaInstallation - - # Detect platform - $arch = Get-Architecture - $binaryName = Get-BinaryName -Architecture $arch - - Write-Info "Detected architecture: $arch" - - # Create installation directory - if (-not (Test-Path $InstallDir)) { - Write-Info "Creating installation directory: $InstallDir" - try { - New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null - } - catch { - Write-Error-Custom "Failed to create directory $InstallDir : $_" - } - } - - # Download binary - $downloadUrl = "$RepoUrl/releases/download/$Version/$binaryName" - $tempFile = Join-Path $InstallDir $binaryName - - Write-Info "Downloading from: $downloadUrl" - - try { - # Download with progress suppressed for cleaner output - $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest -Uri $downloadUrl -OutFile $tempFile -UseBasicParsing - $ProgressPreference = 'Continue' - } - catch { - Write-Error-Custom "Failed to download from $downloadUrl : $_" - } - - $expectedSha = Get-ExpectedSha256 -Os "win" -Architecture $arch - Test-Checksum -File $tempFile -Expected $expectedSha - - # Rename to final location - $finalFile = Join-Path $InstallDir "safe-chain.exe" - try { - # Remove existing file if present (Move-Item -Force doesn't overwrite) - if (Test-Path $finalFile) { - Remove-Item -Path $finalFile -Force - } - Move-Item -Path $tempFile -Destination $finalFile -Force - } - catch { - Write-Error-Custom "Failed to move binary to $finalFile : $_" - } - - Write-Info "Binary installed to: $finalFile" - - Invoke-SafeChainSetup -BinaryPath $finalFile -InstallDirectory $InstallDir -} - -# Run installation -try { - Install-SafeChain -} -catch { - Write-Error-Custom "Installation failed: $_" -} diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh deleted file mode 100755 index 5f73c53..0000000 --- a/install-scripts/install-safe-chain.sh +++ /dev/null @@ -1,509 +0,0 @@ -#!/bin/sh - -# Downloads and installs safe-chain, depending on the operating system and architecture -# -# Usage with "curl -fsSL {url} | sh" --> See README.md - -set -e # Exit on error - -# Validates a user-provided install dir and exits on unsafe values. -# Rejects relative paths, root paths, PATH separators, and traversal segments. -validate_install_dir() { - dir="$1" - - if [ -z "$dir" ]; then - return 0 - fi - - case "$dir" in - /*) ;; - *) - printf '[ERROR] --install-dir must be an absolute path, got: %s\n' "$dir" >&2 - exit 1 - ;; - esac - - case "$dir" in - *:*) - printf '[ERROR] --install-dir must not contain the PATH separator (:)\n' >&2 - exit 1 - ;; - esac - - if [ "$dir" = "/" ]; then - printf '[ERROR] --install-dir cannot be a root or drive-root directory\n' >&2 - exit 1 - fi - - old_ifs=$IFS - IFS='/' - set -- $dir - IFS=$old_ifs - - for segment in "$@"; do - if [ "$segment" = ".." ]; then - printf '[ERROR] --install-dir must not contain path traversal segments\n' >&2 - exit 1 - fi - done -} - -# Configuration -VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set -SAFE_CHAIN_BASE="${HOME}/.safe-chain" - -INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" -REPO_URL="https://github.com/AikidoSec/safe-chain" - -# SHA256 checksums for release binaries. -# Empty in source; populated by the release pipeline via sed. -# When empty (running from main), checksum verification is skipped. -SHA256_MACOS_X64="" -SHA256_MACOS_ARM64="" -SHA256_LINUX_X64="" -SHA256_LINUX_ARM64="" -SHA256_LINUXSTATIC_X64="" -SHA256_LINUXSTATIC_ARM64="" -SHA256_WIN_X64="" -SHA256_WIN_ARM64="" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Helper functions -info() { - printf "${GREEN}[INFO]${NC} %s\n" "$1" -} - -warn() { - printf "${YELLOW}[WARN]${NC} %s\n" "$1" -} - -error() { - printf "${RED}[ERROR]${NC} %s\n" "$1" >&2 - exit 1 -} - -# Detect OS -# For legacy versions (when SAFE_CHAIN_VERSION is set), use 'linux' instead of 'linuxstatic' -detect_os() { - case "$(uname -s)" in - Linux*) - if [ -n "$SAFE_CHAIN_VERSION" ]; then - echo "linux" - else - echo "linuxstatic" - fi - ;; - Darwin*) echo "macos" ;; - MINGW*|MSYS*|CYGWIN*) echo "win" ;; - *) error "Unsupported operating system: $(uname -s)" ;; - esac -} - -# Detect architecture -detect_arch() { - case "$(uname -m)" in - x86_64|amd64) echo "x64" ;; - aarch64|arm64) echo "arm64" ;; - *) error "Unsupported architecture: $(uname -m)" ;; - esac -} - -# Check if command exists -command_exists() { - command -v "$1" >/dev/null 2>&1 -} - -# Get currently installed version of safe-chain -get_installed_version() { - if ! command_exists safe-chain; then - echo "" - return - fi - - # Extract version from "Current safe-chain version: X.Y.Z" output - installed_version=$(safe-chain -v 2>/dev/null | grep "Current safe-chain version:" | sed -E 's/.*: (.*)/\1/') - echo "$installed_version" -} - -# Check if the requested version is already installed -is_version_installed() { - requested_version="$1" - installed_version=$(get_installed_version) - - if [ -z "$installed_version" ]; then - return 1 # Not installed - fi - - # Strip leading 'v' from versions if present for comparison - requested_clean=$(echo "$requested_version" | sed 's/^v//') - installed_clean=$(echo "$installed_version" | sed 's/^v//') - - if [ "$requested_clean" = "$installed_clean" ]; then - return 0 # Same version installed - else - return 1 # Different version installed - fi -} - -# Fetch latest release version tag from GitHub -fetch_latest_version() { - # Try using GitHub API to get the latest release tag - if command_exists curl; then - latest_version=$(curl -fsSL "https://api.github.com/repos/AikidoSec/safe-chain/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') - elif command_exists wget; then - latest_version=$(wget -qO- "https://api.github.com/repos/AikidoSec/safe-chain/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') - else - error "Neither curl nor wget found. Please install one of them or set SAFE_CHAIN_VERSION environment variable." - fi - - if [ -z "$latest_version" ]; then - error "Failed to fetch latest version from GitHub API. Please set SAFE_CHAIN_VERSION environment variable." - fi - - echo "$latest_version" -} - -# Returns the expected SHA256 for the detected platform, or empty if the -# release pipeline has not baked one in (i.e. running the source from main). -get_expected_sha256() { - os="$1"; arch="$2" - case "${os}-${arch}" in - macos-x64) echo "$SHA256_MACOS_X64" ;; - macos-arm64) echo "$SHA256_MACOS_ARM64" ;; - linux-x64) echo "$SHA256_LINUX_X64" ;; - linux-arm64) echo "$SHA256_LINUX_ARM64" ;; - linuxstatic-x64) echo "$SHA256_LINUXSTATIC_X64" ;; - linuxstatic-arm64) echo "$SHA256_LINUXSTATIC_ARM64" ;; - win-x64) echo "$SHA256_WIN_X64" ;; - win-arm64) echo "$SHA256_WIN_ARM64" ;; - *) echo "" ;; - esac -} - -compute_sha256() { - file="$1" - if command_exists sha256sum; then - sha256sum "$file" | awk '{print $1}' - elif command_exists shasum; then - shasum -a 256 "$file" | awk '{print $1}' - else - echo "" - fi -} - -# Verifies the downloaded binary against the expected hash baked in by the release pipeline. -# No-op when no expected hash is set (running the script from main). -verify_checksum() { - file="$1"; expected="$2" - - if [ -z "$expected" ]; then - return - fi - - actual=$(compute_sha256 "$file") - if [ -z "$actual" ]; then - rm -f "$file" - error "Cannot verify checksum: neither sha256sum nor shasum is available. Install one and re-run." - fi - - if [ "$actual" != "$expected" ]; then - rm -f "$file" - error "Checksum verification failed. Expected: $expected, Got: $actual" - fi - - info "Checksum verified." -} - -# Download file -download() { - url="$1" - dest="$2" - - if command_exists curl; then - curl -fsSL "$url" -o "$dest" || error "Failed to download from $url" - elif command_exists wget; then - wget -q "$url" -O "$dest" || error "Failed to download from $url" - else - error "Neither curl nor wget found. Please install one of them." - fi -} - -# Prints the deprecation warning for SAFE_CHAIN_VERSION and the replacement install command. -# Returns immediately when no version was pinned through the environment. -warn_deprecated_version_env() { - if [ -z "$SAFE_CHAIN_VERSION" ]; then - return - fi - - warn "SAFE_CHAIN_VERSION environment variable is deprecated." - warn "" - warn "Please use direct download URLs for version pinning instead:" - warn "" - if [ "$USE_CI_SETUP" = "true" ]; then - warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci" - else - warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh" - fi - warn "" -} - -# Ensures VERSION is populated before installation continues. -# Fetches the latest release only when no explicit version was provided. -ensure_version() { - if [ -n "$VERSION" ]; then - return - fi - - info "Fetching latest release version..." - VERSION=$(fetch_latest_version) -} - -# Constructs platform-specific binary filename to match GitHub release asset naming convention. -get_binary_name() { - os="$1" - arch="$2" - - if [ "$os" = "win" ]; then - printf 'safe-chain-%s-%s.exe\n' "$os" "$arch" - else - printf 'safe-chain-%s-%s\n' "$os" "$arch" - fi -} - -# Returns the final installation path for the downloaded safe-chain binary. -# Uses INSTALL_DIR and the platform-specific executable name. -get_final_binary_path() { - os="$1" - - if [ "$os" = "win" ]; then - printf '%s/safe-chain.exe\n' "$INSTALL_DIR" - else - printf '%s/safe-chain\n' "$INSTALL_DIR" - fi -} - -run_setup_command() { - final_file="$1" - - setup_cmd="setup" - if [ "$USE_CI_SETUP" = "true" ]; then - setup_cmd="setup-ci" - fi - - info "Running safe-chain $setup_cmd..." - if ! "$final_file" "$setup_cmd"; then - warn "safe-chain was installed but setup encountered issues." - warn "You can run 'safe-chain $setup_cmd' manually later." - fi -} - -# Check and uninstall npm global package if present -remove_npm_installation() { - if ! command_exists npm; then - return - fi - - # Check if safe-chain is installed as an npm global package - if npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then - info "Detected npm global installation of @aikidosec/safe-chain" - info "Uninstalling npm version before installing binary version..." - - if npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then - info "Successfully uninstalled npm version" - else - warn "Failed to uninstall npm version automatically" - warn "Please run: npm uninstall -g @aikidosec/safe-chain" - fi - fi -} - -# Check and uninstall Volta-managed package if present -remove_volta_installation() { - if ! command_exists volta; then - return - fi - - # Volta manages global packages in its own directory - # Check if safe-chain is installed via Volta - if volta list safe-chain >/dev/null 2>&1; then - info "Detected Volta installation of @aikidosec/safe-chain" - info "Uninstalling Volta version before installing binary version..." - - if volta uninstall @aikidosec/safe-chain >/dev/null 2>&1; then - info "Successfully uninstalled Volta version" - else - warn "Failed to uninstall Volta version automatically" - warn "Please run: volta uninstall @aikidosec/safe-chain" - fi - fi -} - -# Check and uninstall nvm-managed package if present across all Node versions -remove_nvm_installation() { - # This script is run in sh shell for greatest compatibility. - # Because nvm is usually setup in bash/zsh/fish startup scripts, we need to source it. - # Otherwise it won't be available in sh. - if [ -s "$HOME/.nvm/nvm.sh" ]; then - # Source nvm to make it available in this script - . "$HOME/.nvm/nvm.sh" >/dev/null 2>&1 - elif [ -s "$NVM_DIR/nvm.sh" ]; then - . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 - fi - - # Check if nvm is now available - if ! command_exists nvm; then - return - fi - - nvm_versions=$(nvm list 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || echo "") - - if [ -z "$nvm_versions" ]; then - return - fi - - # Track if we found any installations - found_installation=false - uninstall_failed=false - current_version=$(nvm current 2>/dev/null || echo "") - - # Check each version for safe-chain installation - for version in $nvm_versions; do - # Check if this version has safe-chain installed - # Use nvm exec to run npm list in the context of that Node version - if nvm exec "$version" npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then - if [ "$found_installation" = false ]; then - info "Detected nvm installation(s) of @aikidosec/safe-chain" - info "Uninstalling from all Node versions..." - found_installation=true - fi - - info " Removing from Node $version..." - if nvm exec "$version" npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then - info " Successfully uninstalled from Node $version" - else - warn " Failed to uninstall from Node $version" - uninstall_failed=true - fi - fi - done - - # Restore original Node version if it was set - if [ -n "$current_version" ] && [ "$current_version" != "none" ] && [ "$current_version" != "system" ]; then - nvm use "$current_version" >/dev/null 2>&1 || true - fi - - # If any uninstall failed, error out instead of continuing - if [ "$uninstall_failed" = true ]; then - error "Failed to uninstall @aikidosec/safe-chain from all nvm Node versions. Please uninstall manually and try again." - fi -} - -# Parse command-line arguments -parse_arguments() { - while [ $# -gt 0 ]; do - case "$1" in - --ci) - USE_CI_SETUP=true - ;; - --install-dir) - shift - if [ $# -eq 0 ]; then - error "Missing value for --install-dir" - fi - if [ -z "$1" ]; then - error "--install-dir must not be empty" - fi - SAFE_CHAIN_BASE="$1" - ;; - --install-dir=*) - SAFE_CHAIN_BASE="${1#--install-dir=}" - if [ -z "$SAFE_CHAIN_BASE" ]; then - error "--install-dir must not be empty" - fi - ;; - --include-python) - warn "--include-python is deprecated and ignored. Python ecosystem is now included by default." - ;; - *) - error "Unknown argument: $1" - ;; - esac - shift - done - - validate_install_dir "${SAFE_CHAIN_BASE}" - INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" -} - -# Main installation -main() { - # Initialize argument flags - USE_CI_SETUP=false - - # Parse command-line arguments - parse_arguments "$@" - - warn_deprecated_version_env - - ensure_version - - # Check if the requested version is already installed - if is_version_installed "$VERSION"; then - info "safe-chain ${VERSION} is already installed" - exit 0 - fi - - # Build installation message - INSTALL_MSG="Installing safe-chain ${VERSION}" - if [ "$USE_CI_SETUP" = "true" ]; then - INSTALL_MSG="${INSTALL_MSG} in ci" - fi - - info "$INSTALL_MSG" - - # Check for existing safe-chain installation through nvm, volta, or npm - remove_npm_installation - remove_volta_installation - remove_nvm_installation - - # Detect platform - OS=$(detect_os) - ARCH=$(detect_arch) - BINARY_NAME=$(get_binary_name "$OS" "$ARCH") - - info "Detected platform: ${OS}-${ARCH}" - - # Create installation directory - if [ ! -d "$INSTALL_DIR" ]; then - info "Creating installation directory: $INSTALL_DIR" - mkdir -p "$INSTALL_DIR" || error "Failed to create directory $INSTALL_DIR" - fi - - # Download binary - DOWNLOAD_URL="${REPO_URL}/releases/download/${VERSION}/${BINARY_NAME}" - TEMP_FILE="${INSTALL_DIR}/${BINARY_NAME}" - - info "Downloading from: $DOWNLOAD_URL" - download "$DOWNLOAD_URL" "$TEMP_FILE" - - EXPECTED_SHA256=$(get_expected_sha256 "$OS" "$ARCH") - verify_checksum "$TEMP_FILE" "$EXPECTED_SHA256" - - # Rename and make executable - FINAL_FILE=$(get_final_binary_path "$OS") - mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE" - if [ "$OS" != "win" ]; then - chmod +x "$FINAL_FILE" || error "Failed to make binary executable" - fi - - info "Binary installed to: $FINAL_FILE" - - run_setup_command "$FINAL_FILE" -} - -main "$@" diff --git a/install-scripts/uninstall-endpoint-mac.sh b/install-scripts/uninstall-endpoint-mac.sh deleted file mode 100755 index bd3b0e7..0000000 --- a/install-scripts/uninstall-endpoint-mac.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/sh - -# Uninstalls Aikido Endpoint Protection on macOS -# -# Usage: curl -fsSL | 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 | 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 "$@" diff --git a/install-scripts/uninstall-endpoint-windows.ps1 b/install-scripts/uninstall-endpoint-windows.ps1 deleted file mode 100644 index 90741c7..0000000 --- a/install-scripts/uninstall-endpoint-windows.ps1 +++ /dev/null @@ -1,59 +0,0 @@ -# Uninstalls Aikido Endpoint Protection endpoint on Windows -# -# Usage: iex (iwr '' -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: $_" -} diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 deleted file mode 100644 index 6e24d5d..0000000 --- a/install-scripts/uninstall-safe-chain.ps1 +++ /dev/null @@ -1,249 +0,0 @@ -# Uninstalls safe-chain from Windows -# -# Usage with "iex (iwr {url} -UseBasicParsing)" --> See README.md - -# Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) -$HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } - -# Helper functions -function Write-Info { - param([string]$Message) - Write-Host "[INFO] $Message" -ForegroundColor Green -} - -function Write-Warn { - param([string]$Message) - Write-Host "[WARN] $Message" -ForegroundColor Yellow -} - -function Write-Error-Custom { - param([string]$Message) - Write-Host "[ERROR] $Message" -ForegroundColor Red - exit 1 -} - -# Derives the safe-chain base install directory from a resolved binary path. -# Rejects wrapper scripts and paths that do not match the packaged bin layout. -function Get-InstallDirFromBinaryPath { - param([string]$BinaryPath) - - if ([string]::IsNullOrWhiteSpace($BinaryPath)) { - return $null - } - - try { - $resolvedPath = (Resolve-Path -LiteralPath $BinaryPath -ErrorAction Stop).Path - } - catch { - $resolvedPath = [System.IO.Path]::GetFullPath($BinaryPath) - } - - $fileName = [System.IO.Path]::GetFileName($resolvedPath) - if (($fileName -ne "safe-chain") -and ($fileName -ne "safe-chain.exe")) { - return $null - } - - if ($resolvedPath -match '\.(js|cjs|mjs|cmd|ps1)$') { - return $null - } - - $binDir = Split-Path -Parent $resolvedPath - if ((Split-Path -Leaf $binDir) -ne "bin") { - return $null - } - - return (Split-Path -Parent $binDir) -} - -# Returns the first safe-chain command found on PATH, if any. -# Used as the starting point for install-dir discovery. -function Get-SafeChainCommand { - return Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1 -} - -# Returns the safe-chain command path only when it points to a valid packaged binary install. -# Prevents teardown from invoking arbitrary wrappers or scripts from PATH. -function Get-ValidatedSafeChainCommandPath { - $command = Get-SafeChainCommand - if (-not $command -or [string]::IsNullOrWhiteSpace($command.Path)) { - return $null - } - - $installDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path - if (-not $installDir) { - return $null - } - - return $command.Path -} - -# Invokes the validated safe-chain binary with get-install-dir and returns the reported base directory. -# Safely returns $null when the command is unavailable or the lookup fails. -function Get-ReportedInstallDir { - $safeChainPath = Get-ValidatedSafeChainCommandPath - if (-not $safeChainPath) { - return $null - } - - try { - $reportedInstallDir = & $safeChainPath get-install-dir 2>$null | Select-Object -First 1 - if ($reportedInstallDir) { - $reportedInstallDir = $reportedInstallDir.Trim() - } - if ($reportedInstallDir) { - return $reportedInstallDir - } - } - catch { - return $null - } - - return $null -} - -# Determines the safe-chain base install directory for uninstall. -# Prefers the binary-reported location, then derives it from PATH, then falls back to the default home-dir layout. -function Get-SafeChainInstallDir { - $reportedInstallDir = Get-ReportedInstallDir - if ($reportedInstallDir) { - return $reportedInstallDir - } - - $command = Get-SafeChainCommand - if ($command -and $command.Path) { - $discoveredInstallDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path - if ($discoveredInstallDir) { - return $discoveredInstallDir - } - } - - return (Join-Path $HomeDir ".safe-chain") -} - -# Finds the installed safe-chain binary inside the resolved install directory. -# Falls back to a validated safe-chain command when the expected file is missing. -function Find-SafeChainBinary { - param([string]$DotSafeChain) - - $safeChainExe = Join-Path $DotSafeChain "bin/safe-chain.exe" - $safeChainBin = Join-Path $DotSafeChain "bin/safe-chain" - - if (Test-Path $safeChainExe) { - return $safeChainExe - } - - if (Test-Path $safeChainBin) { - return $safeChainBin - } - - return Get-ValidatedSafeChainCommandPath -} - -# Runs safe-chain teardown before removing the installation directory. -# Converts teardown failures into warnings so uninstall can still complete. -function Invoke-SafeChainTeardown { - param([string]$SafeChainPath) - - if (-not $SafeChainPath) { - Write-Warn "safe-chain command not found. Proceeding with uninstallation." - return - } - - Write-Info "Running safe-chain teardown..." - try { - & $SafeChainPath teardown - if ($LASTEXITCODE -ne 0) { - Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..." - } - } - catch { - Write-Warn "safe-chain teardown encountered issues: $_" - Write-Warn "Continuing with uninstallation..." - } -} - -# Check and uninstall npm global package if present -function Remove-NpmInstallation { - # Check if npm is available - if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { - return - } - - # Check if safe-chain is installed as an npm global package - npm list -g @aikidosec/safe-chain 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Info "Detected npm global installation of @aikidosec/safe-chain" - Write-Info "Uninstalling npm version before installing binary version..." - - npm uninstall -g @aikidosec/safe-chain 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Info "Successfully uninstalled npm version" - } - else { - Write-Warn "Failed to uninstall npm version automatically" - Write-Warn "Please run: npm uninstall -g @aikidosec/safe-chain" - } - } -} - -# Check and uninstall Volta-managed package if present -function Remove-VoltaInstallation { - # Check if Volta is available - if (-not (Get-Command volta -ErrorAction SilentlyContinue)) { - return - } - - # Volta manages global packages in its own directory - # Check if safe-chain is installed via Volta - volta list safe-chain 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Info "Detected Volta installation of @aikidosec/safe-chain" - Write-Info "Uninstalling Volta version before installing binary version..." - - volta uninstall @aikidosec/safe-chain 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Info "Successfully uninstalled Volta version" - } - else { - Write-Warn "Failed to uninstall Volta version automatically" - Write-Warn "Please run: volta uninstall @aikidosec/safe-chain" - } - } -} - -# Main uninstallation -function Uninstall-SafeChain { - Write-Info "Uninstalling safe-chain..." - $DotSafeChain = Get-SafeChainInstallDir - $safeChainPath = Find-SafeChainBinary -DotSafeChain $DotSafeChain - Invoke-SafeChainTeardown -SafeChainPath $safeChainPath - - # Remove npm and Volta installations - Remove-NpmInstallation - Remove-VoltaInstallation - - # Remove .safe-chain directory - if (Test-Path $DotSafeChain) { - Write-Info "Removing installation directory: $DotSafeChain" - try { - Remove-Item -Path $DotSafeChain -Recurse -Force - Write-Info "Successfully removed installation directory" - } - catch { - Write-Error-Custom "Failed to remove $DotSafeChain : $_" - } - } - else { - Write-Info "Installation directory $DotSafeChain does not exist. Nothing to remove." - } - - Write-Info "safe-chain has been uninstalled successfully!" -} - -# Run uninstallation -try { - Uninstall-SafeChain -} -catch { - Write-Error-Custom "Uninstallation failed: $_" -} diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh deleted file mode 100755 index d215405..0000000 --- a/install-scripts/uninstall-safe-chain.sh +++ /dev/null @@ -1,312 +0,0 @@ -#!/bin/sh - -# Downloads and installs safe-chain, depending on the operating system and architecture -# -# Usage with "curl -fsSL {url} | sh" --> See README.md - -set -e # Exit on error - -# Configuration - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Helper functions -info() { - printf "${GREEN}[INFO]${NC} %s\n" "$1" -} - -warn() { - printf "${YELLOW}[WARN]${NC} %s\n" "$1" -} - -error() { - printf "${RED}[ERROR]${NC} %s\n" "$1" >&2 - exit 1 -} - -# Check if command exists -command_exists() { - command -v "$1" >/dev/null 2>&1 -} - -# Resolves a path to its canonical filesystem location when possible. -# Follows symlinks so binary validation can inspect the real installed path. -resolve_path() { - target="$1" - - while [ -L "$target" ]; do - link_target=$(readlink "$target" 2>/dev/null || echo "") - if [ -z "$link_target" ]; then - break - fi - - case "$link_target" in - /*) target="$link_target" ;; - *) - target="$(dirname "$target")/$link_target" - ;; - esac - done - - target_dir=$(dirname "$target") - target_name=$(basename "$target") - - if cd "$target_dir" 2>/dev/null; then - printf '%s/%s\n' "$(pwd -P)" "$target_name" - else - printf '%s\n' "$target" - fi -} - -# Derives the safe-chain base install directory from a packaged binary path. -# Rejects wrapper scripts and paths that do not match the expected bin layout. -derive_install_dir_from_binary() { - binary_path="$1" - - if [ -z "$binary_path" ]; then - return 1 - fi - - resolved_path=$(resolve_path "$binary_path") - binary_name=$(basename "$resolved_path") - case "$binary_name" in - safe-chain|safe-chain.exe) ;; - *) return 1 ;; - esac - - case "$resolved_path" in - *.js|*.cjs|*.mjs|*.cmd|*.ps1) return 1 ;; - esac - - binary_dir=$(dirname "$resolved_path") - if [ "$(basename "$binary_dir")" != "bin" ]; then - return 1 - fi - - dirname "$binary_dir" -} - -# Determines the installed safe-chain base directory for uninstall. -# Prefers the binary-reported location, then infers it from PATH, then falls back to ~/.safe-chain. -get_install_dir() { - reported_install_dir=$(get_reported_install_dir || true) - if [ -n "$reported_install_dir" ]; then - printf '%s\n' "$reported_install_dir" - return 0 - fi - - command_path=$(get_safe_chain_command_path || true) - install_dir=$(derive_install_dir_from_binary "$command_path" || true) - if [ -n "$install_dir" ]; then - printf '%s\n' "$install_dir" - return 0 - fi - - printf '%s\n' "${HOME}/.safe-chain" -} - -# Returns the current safe-chain command path from PATH. -# Fails when safe-chain is not currently resolvable. -get_safe_chain_command_path() { - if ! command_exists safe-chain; then - return 1 - fi - - command -v safe-chain -} - -# Returns the safe-chain command path only when it resolves to a valid packaged binary install. -# Prevents the uninstaller from invoking arbitrary PATH entries. -get_validated_safe_chain_command_path() { - command_path=$(get_safe_chain_command_path || true) - if [ -z "$command_path" ]; then - return 1 - fi - - install_dir=$(derive_install_dir_from_binary "$command_path" || true) - if [ -z "$install_dir" ]; then - return 1 - fi - - printf '%s\n' "$command_path" -} - -# Asks the validated safe-chain binary for its install directory via get-install-dir. -# Returns nothing if the command is unavailable or the lookup fails. -get_reported_install_dir() { - safe_chain_path=$(get_validated_safe_chain_command_path || true) - if [ -z "$safe_chain_path" ]; then - return 1 - fi - - install_dir=$("$safe_chain_path" get-install-dir 2>/dev/null || true) - if [ -n "$install_dir" ]; then - printf '%s\n' "$install_dir" - return 0 - fi - - return 1 -} - -# Locates the installed safe-chain binary to use for teardown. -# Checks the discovered install dir first, then falls back to a validated PATH entry. -find_installed_safe_chain_binary() { - dot_safe_chain="$1" - - safe_chain_location="$dot_safe_chain/bin/safe-chain" - if [ -x "$safe_chain_location" ]; then - printf '%s\n' "$safe_chain_location" - return 0 - fi - - command_path=$(get_validated_safe_chain_command_path || true) - if [ -n "$command_path" ]; then - printf '%s\n' "$command_path" - return 0 - fi - - return 1 -} - -# Runs safe-chain teardown before removing files. -# Continues with uninstall even if teardown is unavailable or fails. -run_safe_chain_teardown() { - safe_chain_command="$1" - - if [ -z "$safe_chain_command" ]; then - warn "safe-chain command not found. Proceeding with uninstallation." - return - fi - - info "Running safe-chain teardown..." - "$safe_chain_command" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..." -} - -# Check and uninstall npm global package if present -remove_npm_installation() { - if ! command_exists npm; then - return - fi - - # Check if safe-chain is installed as an npm global package - if npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then - info "Detected npm global installation of @aikidosec/safe-chain" - info "Uninstalling npm version before installing binary version..." - - if npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then - info "Successfully uninstalled npm version" - else - warn "Failed to uninstall npm version automatically" - warn "Please run: npm uninstall -g @aikidosec/safe-chain" - fi - fi -} - -# Check and uninstall Volta-managed package if present -remove_volta_installation() { - if ! command_exists volta; then - return - fi - - # Volta manages global packages in its own directory - # Check if safe-chain is installed via Volta - if volta list safe-chain >/dev/null 2>&1; then - info "Detected Volta installation of @aikidosec/safe-chain" - info "Uninstalling Volta version before installing binary version..." - - if volta uninstall @aikidosec/safe-chain >/dev/null 2>&1; then - info "Successfully uninstalled Volta version" - else - warn "Failed to uninstall Volta version automatically" - warn "Please run: volta uninstall @aikidosec/safe-chain" - fi - fi -} - -# Check and uninstall nvm-managed package if present across all Node versions -remove_nvm_installation() { - # This script is run in sh shell for greatest compatibility. - # Because nvm is usually setup in bash/zsh/fish startup scripts, we need to source it. - # Otherwise it won't be available in sh. - if [ -s "$HOME/.nvm/nvm.sh" ]; then - # Source nvm to make it available in this script - . "$HOME/.nvm/nvm.sh" >/dev/null 2>&1 - elif [ -s "$NVM_DIR/nvm.sh" ]; then - . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 - fi - - # Check if nvm is now available - if ! command_exists nvm; then - return - fi - - # Get list of installed Node versions - nvm_versions=$(nvm list 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || echo "") - - if [ -z "$nvm_versions" ]; then - return - fi - - # Track if we found any installations - found_installation=false - uninstall_failed=false - current_version=$(nvm current 2>/dev/null || echo "") - - # Check each version for safe-chain installation - for version in $nvm_versions; do - # Check if this version has safe-chain installed - # Use nvm exec to run npm list in the context of that Node version - if nvm exec "$version" npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then - if [ "$found_installation" = false ]; then - info "Detected nvm installation(s) of @aikidosec/safe-chain" - info "Uninstalling from all Node versions..." - found_installation=true - fi - - info " Removing from Node $version..." - if nvm exec "$version" npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then - info " Successfully uninstalled from Node $version" - else - warn " Failed to uninstall from Node $version" - uninstall_failed=true - fi - fi - done - - # Restore original Node version if it was set - if [ -n "$current_version" ] && [ "$current_version" != "none" ] && [ "$current_version" != "system" ]; then - nvm use "$current_version" >/dev/null 2>&1 || true - fi - - # Show warning if any uninstall failed (but don't error out during uninstall) - if [ "$uninstall_failed" = true ]; then - warn "Failed to uninstall @aikidosec/safe-chain from some nvm Node versions" - warn "You may need to manually run: nvm exec npm uninstall -g @aikidosec/safe-chain" - fi -} - -# Main uninstallation -main() { - DOT_SAFE_CHAIN=$(get_install_dir) - SAFE_CHAIN_COMMAND=$(find_installed_safe_chain_binary "$DOT_SAFE_CHAIN" || true) - run_safe_chain_teardown "$SAFE_CHAIN_COMMAND" - - # Check for existing safe-chain installation through nvm, volta, or npm - remove_npm_installation - remove_volta_installation - remove_nvm_installation - - # Remove install dir recursively if it exists - if [ -d "$DOT_SAFE_CHAIN" ]; then - info "Removing installation directory $DOT_SAFE_CHAIN" - rm -rf "$DOT_SAFE_CHAIN" || error "Failed to remove $DOT_SAFE_CHAIN" - else - info "Installation directory $DOT_SAFE_CHAIN does not exist. Nothing to remove." - fi -} - -main "$@" diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json deleted file mode 100644 index 310e28f..0000000 --- a/npm-shrinkwrap.json +++ /dev/null @@ -1,3183 +0,0 @@ -{ - "name": "aikido-safe-chain-workspace", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "aikido-safe-chain-workspace", - "license": "AGPL-3.0-or-later", - "workspaces": [ - "packages/*", - "test/e2e" - ], - "devDependencies": { - "@yao-pkg/pkg": "6.10.1", - "esbuild": "^0.27.0", - "oxlint": "^1.22.0" - } - }, - "node_modules/@aikidosec/safe-chain": { - "resolved": "packages/safe-chain", - "link": true - }, - "node_modules/@aikidosec/safe-chain-e2e-tests": { - "resolved": "test/e2e", - "link": true - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", - "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", - "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", - "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", - "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", - "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", - "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", - "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", - "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", - "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", - "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", - "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", - "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", - "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", - "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", - "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", - "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", - "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", - "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", - "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", - "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", - "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", - "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", - "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", - "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", - "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", - "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@npmcli/agent": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", - "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^11.2.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/fs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", - "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/redact": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", - "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@oxlint/darwin-arm64": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@oxlint/darwin-arm64/-/darwin-arm64-1.22.0.tgz", - "integrity": "sha512-vfgwTA1CowVaU3QXFBjfGjbPsHbdjAiJnWX5FBaq8uXS8tksGgl0ue14MK6fVnXncWK9j69LRnkteGTixxDAfA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oxlint/darwin-x64": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@oxlint/darwin-x64/-/darwin-x64-1.22.0.tgz", - "integrity": "sha512-70x7Y+e0Ddb2Cf2IZsYGnXZrnB/MZgOTi/VkyXZucbnQcpi2VoaYS4Ve662DaNkzvTxdKOGmyJVMmD/digdJLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oxlint/linux-arm64-gnu": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-gnu/-/linux-arm64-gnu-1.22.0.tgz", - "integrity": "sha512-Rv94lOyEV8WEuzhjJSpCW3DbL/tlOVizPxth1v5XAFuQdM5rgpOMs3TsAf/YFUn52/qenwVglyvQZL8oAUYlpg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxlint/linux-arm64-musl": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-musl/-/linux-arm64-musl-1.22.0.tgz", - "integrity": "sha512-Aau6V6Osoyb3SFmRejP3rRhs1qhep4aJTdotFf1RVMVSLJkF7Ir0p+eGZSaIJyylFZuCCxHpud3hWasphmZnzw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxlint/linux-x64-gnu": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-gnu/-/linux-x64-gnu-1.22.0.tgz", - "integrity": "sha512-6eOtv+2gHrKw/hxUkV6hJdvYhzr0Dqzb4oc7sNlWxp64jU6I19tgMwSlmtn02r34YNSn+/NpZ/ECvQrycKUUFQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxlint/linux-x64-musl": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-musl/-/linux-x64-musl-1.22.0.tgz", - "integrity": "sha512-c4O7qD7TCEfPE/FFKYvakF2sQoIP0LFZB8F5AQK4K9VYlyT1oENNRCdIiMu6irvLelOzJzkUM0XrvUCL9Kkxrw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxlint/win32-arm64": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@oxlint/win32-arm64/-/win32-arm64-1.22.0.tgz", - "integrity": "sha512-6DJwF5A9VoIbSWNexLYubbuteAL23l3YN00wUL7Wt4ZfEZu2f/lWtGB9yC9BfKLXzudq8MvGkrS0szmV0bc1VQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oxlint/win32-x64": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@oxlint/win32-x64/-/win32-x64-1.22.0.tgz", - "integrity": "sha512-nf8EZnIUgIrHlP9k26iOFMZZPoJG16KqZBXu5CG5YTAtVcu4CWlee9Q/cOS/rgQNGjLF+WPw8sVA5P3iGlYGQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/make-fetch-happen": { - "version": "10.0.4", - "resolved": "https://registry.npmjs.org/@types/make-fetch-happen/-/make-fetch-happen-10.0.4.tgz", - "integrity": "sha512-jKzweQaEMMAi55ehvR1z0JF6aSVQm/h1BXBhPLOJriaeQBctjw5YbpIGs7zAx9dN0Sa2OO5bcXwCkrlgenoPEA==", - "dev": true, - "dependencies": { - "@types/node-fetch": "*", - "@types/retry": "*", - "@types/ssri": "*" - } - }, - "node_modules/@types/node": { - "version": "24.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", - "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", - "dev": true, - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "dev": true, - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/@types/node-forge": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", - "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/npm-package-arg": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@types/npm-package-arg/-/npm-package-arg-6.1.4.tgz", - "integrity": "sha512-vDgdbMy2QXHnAruzlv68pUtXCjmqUk3WrBAsRboRovsOmxbfn/WiYCjmecyKjGztnMps5dWp4Uq2prp+Ilo17Q==", - "dev": true - }, - "node_modules/@types/npm-registry-fetch": { - "version": "8.0.9", - "resolved": "https://registry.npmjs.org/@types/npm-registry-fetch/-/npm-registry-fetch-8.0.9.tgz", - "integrity": "sha512-7NxvodR5Yrop3pb6+n8jhJNyzwOX0+6F+iagNEoi9u1CGxruYAwZD8pvGc9prIkL0+FdX5Xp0p80J9QPrGUp/g==", - "dev": true, - "dependencies": { - "@types/node": "*", - "@types/node-fetch": "*", - "@types/npm-package-arg": "*", - "@types/npmlog": "*", - "@types/ssri": "*" - } - }, - "node_modules/@types/npmlog": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@types/npmlog/-/npmlog-7.0.0.tgz", - "integrity": "sha512-hJWbrKFvxKyWwSUXjZMYTINsSOY6IclhvGOZ97M8ac2tmR9hMwmTnYaMdpGhvju9ctWLTPhCS+eLfQNluiEjQQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/retry": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", - "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", - "dev": true - }, - "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", - "dev": true - }, - "node_modules/@types/ssri": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/@types/ssri/-/ssri-7.1.5.tgz", - "integrity": "sha512-odD/56S3B51liILSk5aXJlnYt99S6Rt9EFDDqGtJM26rKHApHcwyU/UoYHrzKkdkHMAIquGWCuHtQTbes+FRQw==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@yao-pkg/pkg": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-6.10.1.tgz", - "integrity": "sha512-M/eqDg0Iir2nmyZ06Q9ospIPv1Yk7K1du5iLiaYrfMogQcI6bqf82A026MVYngyLH8jZsquZvjNAbvgbW4Uwkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.23.0", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", - "@yao-pkg/pkg-fetch": "3.5.30", - "into-stream": "^6.0.0", - "minimist": "^1.2.6", - "multistream": "^4.1.0", - "picocolors": "^1.1.0", - "picomatch": "^4.0.2", - "prebuild-install": "^7.1.1", - "resolve": "^1.22.10", - "stream-meter": "^1.0.4", - "tar": "^7.4.3", - "tinyglobby": "^0.2.11", - "unzipper": "^0.12.3" - }, - "bin": { - "pkg": "lib-es5/bin.js" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@yao-pkg/pkg-fetch": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.30.tgz", - "integrity": "sha512-OrXQlsR3vE/IvwXSk8R5ETYbcxAFtUPmLkeepbG+ArN82TvlIwcUJ65tEWxLG3Tl89VRbmOupuhkXfmuaO05+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "https-proxy-agent": "^5.0.0", - "node-fetch": "^2.6.6", - "picocolors": "^1.1.0", - "progress": "^2.0.3", - "semver": "^7.3.5", - "tar-fs": "^3.1.1", - "yargs": "^16.2.0" - }, - "bin": { - "pkg-fetch": "lib-es5/bin.js" - } - }, - "node_modules/@yao-pkg/pkg-fetch/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/@yao-pkg/pkg-fetch/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true - }, - "node_modules/b4a": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", - "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", - "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", - "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/cacache": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", - "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^5.0.0", - "fs-minipass": "^3.0.0", - "glob": "^13.0.0", - "lru-cache": "^11.1.0", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^13.0.0", - "unique-filename": "^5.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/certifi": { - "version": "14.5.15", - "resolved": "https://registry.npmjs.org/certifi/-/certifi-14.5.15.tgz", - "integrity": "sha512-NeLXuKCqSzwQNjpJ+WaSp5m8ntdTKJ8HnBu+eA7DxHfgzU7F1sjwrJFang+4U38+vmWbiFUpPZMV3uwwnHAisQ==", - "license": "MPL-2.0", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexer2": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", - "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "readable-stream": "^2.0.2" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "license": "MIT" - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", - "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.0", - "@esbuild/android-arm": "0.27.0", - "@esbuild/android-arm64": "0.27.0", - "@esbuild/android-x64": "0.27.0", - "@esbuild/darwin-arm64": "0.27.0", - "@esbuild/darwin-x64": "0.27.0", - "@esbuild/freebsd-arm64": "0.27.0", - "@esbuild/freebsd-x64": "0.27.0", - "@esbuild/linux-arm": "0.27.0", - "@esbuild/linux-arm64": "0.27.0", - "@esbuild/linux-ia32": "0.27.0", - "@esbuild/linux-loong64": "0.27.0", - "@esbuild/linux-mips64el": "0.27.0", - "@esbuild/linux-ppc64": "0.27.0", - "@esbuild/linux-riscv64": "0.27.0", - "@esbuild/linux-s390x": "0.27.0", - "@esbuild/linux-x64": "0.27.0", - "@esbuild/netbsd-arm64": "0.27.0", - "@esbuild/netbsd-x64": "0.27.0", - "@esbuild/openbsd-arm64": "0.27.0", - "@esbuild/openbsd-x64": "0.27.0", - "@esbuild/openharmony-arm64": "0.27.0", - "@esbuild/sunos-x64": "0.27.0", - "@esbuild/win32-arm64": "0.27.0", - "@esbuild/win32-ia32": "0.27.0", - "@esbuild/win32-x64": "0.27.0" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT" - }, - "node_modules/fs-extra": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", - "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "license": "BSD-2-Clause" - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/into-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-6.0.0.tgz", - "integrity": "sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "from2": "^2.3.0", - "p-is-promise": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/make-fetch-happen": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz", - "integrity": "sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==", - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^4.0.0", - "cacache": "^20.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", - "ssri": "^13.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.0.tgz", - "integrity": "sha512-fiCdUALipqgPWrOVTz9fw0XhcazULXOSU6ie40DDbX1F49p1dBrSRBuswndTx1x3vEb/g0FT7vC4c4C2u/mh3A==", - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/multistream": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/multistream/-/multistream-4.1.0.tgz", - "integrity": "sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "once": "^1.4.0", - "readable-stream": "^3.6.0" - } - }, - "node_modules/multistream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/nan": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", - "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", - "license": "MIT" - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abi": { - "version": "3.85.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", - "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-forge": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz", - "integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-pty": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", - "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "nan": "^2.17.0" - } - }, - "node_modules/npm-package-arg": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", - "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", - "license": "ISC", - "dependencies": { - "hosted-git-info": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-registry-fetch": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", - "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", - "license": "ISC", - "dependencies": { - "@npmcli/redact": "^4.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^15.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minizlib": "^3.0.1", - "npm-package-arg": "^13.0.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/oxlint": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.22.0.tgz", - "integrity": "sha512-/HYT1Cfanveim9QUM6KlPKJe9y+WPnh3SxIB7z1InWnag9S0nzxLaWEUiW1P4UGzh/No3KvtNmBv2IOiwAl2/w==", - "dev": true, - "bin": { - "oxc_language_server": "bin/oxc_language_server", - "oxlint": "bin/oxlint" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxlint/darwin-arm64": "1.22.0", - "@oxlint/darwin-x64": "1.22.0", - "@oxlint/linux-arm64-gnu": "1.22.0", - "@oxlint/linux-arm64-musl": "1.22.0", - "@oxlint/linux-x64-gnu": "1.22.0", - "@oxlint/linux-x64-musl": "1.22.0", - "@oxlint/win32-arm64": "1.22.0", - "@oxlint/win32-x64": "1.22.0" - }, - "peerDependencies": { - "oxlint-tsgolint": ">=0.2.0" - }, - "peerDependenciesMeta": { - "oxlint-tsgolint": { - "optional": true - } - } - }, - "node_modules/p-is-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", - "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prebuild-install/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "license": "ISC" - }, - "node_modules/prebuild-install/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/prebuild-install/node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/prebuild-install/node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "optional": true - }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/ssri": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", - "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/stream-meter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz", - "integrity": "sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "^2.1.4" - } - }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tar": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true - }, - "node_modules/unique-filename": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", - "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", - "license": "ISC", - "dependencies": { - "unique-slug": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/unique-slug": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", - "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unzipper": { - "version": "0.12.3", - "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", - "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bluebird": "~3.7.2", - "duplexer2": "~0.1.4", - "fs-extra": "^11.2.0", - "graceful-fs": "^4.2.2", - "node-int64": "^0.4.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/validate-npm-package-name": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.0.tgz", - "integrity": "sha512-bwVk/OK+Qu108aJcMAEiU4yavHUI7aN20TgZNBj9MR2iU1zPUl1Z1Otr7771ExfYTPTvfN8ZJ1pbr5Iklgt4xg==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, - "node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "packages/safe-chain": { - "name": "@aikidosec/safe-chain", - "version": "1.0.0", - "license": "AGPL-3.0-or-later", - "dependencies": { - "certifi": "14.5.15", - "chalk": "5.4.1", - "https-proxy-agent": "7.0.6", - "ini": "6.0.0", - "make-fetch-happen": "15.0.3", - "node-forge": "1.3.2", - "npm-registry-fetch": "19.1.1", - "semver": "7.7.2" - }, - "bin": { - "aikido-bun": "bin/aikido-bun.js", - "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" - }, - "devDependencies": { - "@types/ini": "^4.1.1", - "@types/make-fetch-happen": "^10.0.4", - "@types/node": "^18.19.130", - "@types/node-forge": "^1.3.14", - "@types/npm-registry-fetch": "^8.0.9", - "@types/semver": "^7.7.1", - "esbuild": "^0.27.0", - "typescript": "^5.9.3" - } - }, - "packages/safe-chain/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "dev": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "packages/safe-chain/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true - }, - "test/e2e": { - "name": "@aikidosec/safe-chain-e2e-tests", - "version": "1.0.0", - "license": "AGPL-3.0-or-later", - "dependencies": { - "node-pty": "^1.0.0" - } - } - } -} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6b74d53 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4925 @@ +{ + "name": "aikido-safe-chain-workspace", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aikido-safe-chain-workspace", + "license": "AGPL-3.0-or-later", + "workspaces": [ + "packages/*", + "test/e2e" + ], + "devDependencies": { + "@eslint/js": "^9.35.0", + "eslint": "^9.35.0", + "eslint-plugin-import": "^2.32.0", + "globals": "^16.1.0", + "typescript-eslint": "^8.32.0" + } + }, + "node_modules/@aikidosec/safe-chain": { + "resolved": "packages/safe-chain", + "link": true + }, + "node_modules/@aikidosec/safe-chain-bun": { + "resolved": "packages/safe-chain-bun", + "link": true + }, + "node_modules/@aikidosec/safe-chain-e2e-tests": { + "resolved": "test/e2e", + "link": true + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", + "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.2.2.tgz", + "integrity": "sha512-7VmYAmk4csGv08QzrDKScdzn11jHPFGyqJW39FyPgPuAp3zIaUmuCo1yxw9aGs+NEJuTGQ9Gwqpt93vtJubucg==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@oven/bun-darwin-aarch64": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.2.21.tgz", + "integrity": "sha512-SihfZ3czKeWz6Z3m5rUDrMlarwOXjnkUg+7tIiSB9VZCFSvWEItMfdAF170eCXxZmEh7A1dw20a3lW37lkmlrA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@oven/bun-darwin-x64": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.2.21.tgz", + "integrity": "sha512-iXr4y2ap6EmME7/EDoLMxSRKAh9yswKfrHDb9sF+ExHbk1C+XsNGxMY73ckQe2w0SIH6NXz2cRMTORbZ8LNjig==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@oven/bun-darwin-x64-baseline": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.2.21.tgz", + "integrity": "sha512-3KeslC5z3vpXxluYBqh6EDwojxTSyWJQeYPJFf7y/Z5QJuAN7g33l8jrx072X8P/G8CBzU1lJky14vhhnqWd7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@oven/bun-linux-aarch64": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.2.21.tgz", + "integrity": "sha512-jpUFKGUpim4h4KOqI1VYYgvifZVrWNQZFrmVPfSqGb0ZzF/p5L2qc9Hy2aUL3Lo+zHMPylwbe0iLKElPYk0xoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@oven/bun-linux-aarch64-musl": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.2.21.tgz", + "integrity": "sha512-7UoUHKACYDin3iR6kdqUrF1AOCCjTHPTv1xmzlX4rzwNQvFYSAR83AMrY7hkatKGzLYkI8EjXDAvFJpwF+ZxoA==", + "cpu": [ + "aarch64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@oven/bun-linux-x64": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.2.21.tgz", + "integrity": "sha512-6RuXFaVU2ve0TVw1vfFo7ix/jh9IX7mMAEhwE2odX8EdX/ea55upiivYQ/EKeXt+Ij3STc2bCeV4vvRoEJAHdg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@oven/bun-linux-x64-baseline": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.2.21.tgz", + "integrity": "sha512-oZ5FUMfeghwbQcL9oxajsKjwVI+1GnVvxcJ3z+pifuXaLMZr25NCr5h0q2j+ZxEFL3RtL/Pyj8/HLfzGEIVAVg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@oven/bun-linux-x64-musl": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.2.21.tgz", + "integrity": "sha512-ioZjU+2yyLJXaDA8FKoy+tj/fuZKovG9EMp+n9+EG7g3MULbe5nU8gdsS/dET28WzuPlDlSkqF8EUocvg4HajQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@oven/bun-linux-x64-musl-baseline": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.2.21.tgz", + "integrity": "sha512-0NzMg4XdXgujDM2jZogiV6MgACXW0a0NfB+o6fxwmUzdmMBUk1ZMRzypUi4XKjGUe89mYcPJcVFQRRnNwzTK/Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@oven/bun-windows-x64": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.2.21.tgz", + "integrity": "sha512-DZVCXrZGN/B4JnVnieZin1Kxse1wOkf+Fm2hDGpZHzs27ECbw5xPMFIc0r/oCpxTc/InxuvYO9UGoOmvhFaHsQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@oven/bun-windows-x64-baseline": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.2.21.tgz", + "integrity": "sha512-sTnkLdThgsa6X8ib6eb3+zgy+CGJOibK6Th4wV2wmZFi5af6TM+digEi9i+q/X3nabGwPXm0V4vBiVpvcFilsA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.0.tgz", + "integrity": "sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.32.0", + "@typescript-eslint/type-utils": "8.32.0", + "@typescript-eslint/utils": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.0.tgz", + "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.32.0", + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/typescript-estree": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.0.tgz", + "integrity": "sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.0.tgz", + "integrity": "sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.32.0", + "@typescript-eslint/utils": "8.32.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.0.tgz", + "integrity": "sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.0.tgz", + "integrity": "sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.0.tgz", + "integrity": "sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.32.0", + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/typescript-estree": "8.32.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.0.tgz", + "integrity": "sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bun": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/bun/-/bun-1.2.21.tgz", + "integrity": "sha512-y0lJ02dS90U3PJm+7KAKY8Se95AQvP5Xm77LouUwrpNOHpv59kBG4SK1+9iE1cAhpUaFipq+0EJ56S6MmE3row==", + "cpu": [ + "arm64", + "x64", + "aarch64" + ], + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "peer": true, + "bin": { + "bun": "bin/bun.exe", + "bunx": "bin/bunx.exe" + }, + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "1.2.21", + "@oven/bun-darwin-x64": "1.2.21", + "@oven/bun-darwin-x64-baseline": "1.2.21", + "@oven/bun-linux-aarch64": "1.2.21", + "@oven/bun-linux-aarch64-musl": "1.2.21", + "@oven/bun-linux-x64": "1.2.21", + "@oven/bun-linux-x64-baseline": "1.2.21", + "@oven/bun-linux-x64-musl": "1.2.21", + "@oven/bun-linux-x64-musl-baseline": "1.2.21", + "@oven/bun-windows-x64": "1.2.21", + "@oven/bun-windows-x64-baseline": "1.2.21" + } + }, + "node_modules/cacache": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", + "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.35.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.1.0.tgz", + "integrity": "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", + "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/make-fetch-happen": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", + "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nan": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", + "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-pty": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", + "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "nan": "^2.17.0" + } + }, + "node_modules/npm-package-arg": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", + "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", + "license": "ISC", + "dependencies": { + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", + "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^3.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^14.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "optional": true + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/ssri": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.32.0.tgz", + "integrity": "sha512-UMq2kxdXCzinFFPsXc9o2ozIpYCCOiEC46MG3yEh5Vipq6BO27otTtEBZA1fQ66DulEUgE97ucQ/3YY66CPg0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.32.0", + "@typescript-eslint/parser": "8.32.0", + "@typescript-eslint/utils": "8.32.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unique-filename": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unique-slug": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.0.tgz", + "integrity": "sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/safe-chain": { + "name": "@aikidosec/safe-chain", + "version": "1.0.0", + "license": "AGPL-3.0-or-later", + "dependencies": { + "abbrev": "3.0.1", + "chalk": "5.4.1", + "https-proxy-agent": "7.0.6", + "make-fetch-happen": "14.0.3", + "node-forge": "1.3.1", + "npm-registry-fetch": "18.0.2", + "ora": "8.2.0", + "semver": "7.7.2" + }, + "bin": { + "aikido-npm": "bin/aikido-npm.js", + "aikido-npx": "bin/aikido-npx.js", + "aikido-pnpm": "bin/aikido-pnpm.js", + "aikido-pnpx": "bin/aikido-pnpx.js", + "aikido-yarn": "bin/aikido-yarn.js", + "safe-chain": "bin/safe-chain.js" + } + }, + "packages/safe-chain-bun": { + "name": "@aikidosec/safe-chain-bun", + "version": "1.0.0", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@aikidosec/safe-chain": "file:../safe-chain" + }, + "peerDependencies": { + "bun": ">=1.2.21" + } + }, + "test/e2e": { + "name": "@aikidosec/safe-chain-e2e-tests", + "version": "1.0.0", + "license": "AGPL-3.0-or-later", + "dependencies": { + "node-pty": "^1.0.0" + } + } + } +} diff --git a/package.json b/package.json index 2793f9c..ad71644 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,9 @@ "test/e2e" ], "scripts": { - "test": "npm run test --workspace=packages/safe-chain", + "test": "npm run test --workspace=packages/safe-chain --workspace=packages/safe-chain-bun", "test:e2e": "npm run test --workspace=test/e2e", - "lint": "npm run lint --workspace=packages/safe-chain", - "typecheck": "npm run typecheck --workspace=packages/safe-chain" + "lint": "npm run lint --workspace=packages/safe-chain" }, "repository": { "type": "git", @@ -19,8 +18,13 @@ "author": "Aikido Security", "license": "AGPL-3.0-or-later", "devDependencies": { - "oxlint": "^1.22.0", - "esbuild": "^0.27.0", - "@yao-pkg/pkg": "6.10.1" + "@eslint/js": "^9.35.0", + "eslint": "^9.35.0", + "eslint-plugin-import": "^2.32.0", + "globals": "^16.1.0", + "typescript-eslint": "^8.32.0" + }, + "overrides": { + "brace-expansion@<=2.0.2": "2.0.2" } } diff --git a/packages/safe-chain-bun/package.json b/packages/safe-chain-bun/package.json new file mode 100644 index 0000000..b5a9e3e --- /dev/null +++ b/packages/safe-chain-bun/package.json @@ -0,0 +1,30 @@ +{ + "name": "@aikidosec/safe-chain-bun", + "version": "1.0.0", + "type": "module", + "main": "src/index.js", + "scripts": { + "test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'" + }, + "exports": { + ".": { + "bun": "./src/index.js", + "default": "./src/index.js" + } + }, + "keywords": ["bun", "security", "scanner", "malware", "aikido"], + "author": "Aikido Security", + "license": "AGPL-3.0-or-later", + "description": "Aikido Security Scanner for Bun package manager - detects malware and security threats during package installation", + "repository": { + "type": "git", + "url": "git+https://github.com/AikidoSec/safe-chain.git", + "directory": "packages/safe-chain-bun" + }, + "dependencies": { + "@aikidosec/safe-chain": "file:../safe-chain" + }, + "peerDependencies": { + "bun": ">=1.2.21" + } +} \ No newline at end of file diff --git a/packages/safe-chain-bun/src/index.js b/packages/safe-chain-bun/src/index.js new file mode 100644 index 0000000..fbd0f65 --- /dev/null +++ b/packages/safe-chain-bun/src/index.js @@ -0,0 +1,37 @@ +import { auditChanges } from "@aikidosec/safe-chain/scanning"; + +// Bun Security Scanner for Safe-Chain +// This is the entry point for Bun's native security scanner integration + +export const scanner = { + version: "1", // Our scanner is using version 1 of the bun security scanner API. + + async scan({ packages }) { + const advisories = []; + + try { + const changes = packages.map((pkg) => ({ + name: pkg.name, + version: pkg.version, + type: "add", + })); + + const audit = await auditChanges(changes); + + if (!audit.isAllowed) { + for (const change of audit.disallowedChanges) { + advisories.push({ + level: "fatal", // Fatal will block the installation process, this is what we want for packages that contain malware. + package: change.name, + url: null, + description: `Package ${change.name}@${change.version} contains known security threats (${change.reason}). Installation blocked by Safe-Chain.`, + }); + } + } + } catch (error) { + console.warn(`Safe-Chain security scan failed: ${error.message}`); + } + + return advisories; + }, +}; diff --git a/packages/safe-chain-bun/src/index.spec.js b/packages/safe-chain-bun/src/index.spec.js new file mode 100644 index 0000000..3293b56 --- /dev/null +++ b/packages/safe-chain-bun/src/index.spec.js @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { describe, it, mock } from "node:test"; + +describe("Bun Scanner", async () => { + const mockAuditChanges = mock.fn(); + + // Mock the scanning module + mock.module("@aikidosec/safe-chain/scanning", { + namedExports: { + auditChanges: mockAuditChanges, + }, + }); + + const { scanner } = await import("./index.js"); + + it("should export scanner object with version", () => { + assert.strictEqual(scanner.version, "1"); + assert.strictEqual(typeof scanner.scan, "function"); + }); + + it("should return empty advisories for clean packages", async () => { + mockAuditChanges.mock.mockImplementation(() => ({ + allowedChanges: [{ name: "express", version: "4.18.2", type: "add" }], + disallowedChanges: [], + isAllowed: true, + })); + + const packages = [{ name: "express", version: "4.18.2" }]; + const result = await scanner.scan({ packages }); + + assert.deepEqual(result, []); + assert.strictEqual(mockAuditChanges.mock.callCount(), 1); + assert.deepEqual(mockAuditChanges.mock.calls[0].arguments[0], [ + { name: "express", version: "4.18.2", type: "add" }, + ]); + }); + + it("should return fatal advisory for malware packages", async () => { + mockAuditChanges.mock.mockImplementation(() => ({ + allowedChanges: [], + disallowedChanges: [ + { + name: "malicious-pkg", + version: "1.0.0", + type: "add", + reason: "MALWARE", + }, + ], + isAllowed: false, + })); + + const packages = [{ name: "malicious-pkg", version: "1.0.0" }]; + const result = await scanner.scan({ packages }); + + assert.strictEqual(result.length, 1); + assert.deepEqual(result[0], { + level: "fatal", + package: "malicious-pkg", + url: null, + description: "Package malicious-pkg@1.0.0 contains known security threats (MALWARE). Installation blocked by Safe-Chain.", + }); + }); + + it("should handle multiple packages with mixed results", async () => { + mockAuditChanges.mock.mockImplementation(() => ({ + allowedChanges: [{ name: "express", version: "4.18.2", type: "add" }], + disallowedChanges: [ + { + name: "malicious-pkg", + version: "1.0.0", + type: "add", + reason: "MALWARE", + }, + { + name: "another-bad-pkg", + version: "2.1.0", + type: "add", + reason: "MALWARE", + }, + ], + isAllowed: false, + })); + + const packages = [ + { name: "express", version: "4.18.2" }, + { name: "malicious-pkg", version: "1.0.0" }, + { name: "another-bad-pkg", version: "2.1.0" }, + ]; + const result = await scanner.scan({ packages }); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].package, "malicious-pkg"); + assert.strictEqual(result[0].level, "fatal"); + assert.strictEqual(result[1].package, "another-bad-pkg"); + assert.strictEqual(result[1].level, "fatal"); + }); + + it("should handle empty package list", async () => { + mockAuditChanges.mock.mockImplementation(() => ({ + allowedChanges: [], + disallowedChanges: [], + isAllowed: true, + })); + + const result = await scanner.scan({ packages: [] }); + + assert.deepEqual(result, []); + assert.deepEqual( + mockAuditChanges.mock.calls[mockAuditChanges.mock.callCount() - 1] + .arguments[0], + [] + ); + }); + + it("should convert Bun package format to safe-chain format correctly", async () => { + mockAuditChanges.mock.mockImplementation(() => ({ + allowedChanges: [], + disallowedChanges: [], + isAllowed: true, + })); + + const bunPackages = [ + { name: "lodash", version: "4.17.21" }, + { name: "@types/node", version: "20.0.0" }, + ]; + + await scanner.scan({ packages: bunPackages }); + + const expectedChanges = [ + { name: "lodash", version: "4.17.21", type: "add" }, + { name: "@types/node", version: "20.0.0", type: "add" }, + ]; + + assert.deepEqual( + mockAuditChanges.mock.calls[mockAuditChanges.mock.callCount() - 1] + .arguments[0], + expectedChanges + ); + }); +}); diff --git a/packages/safe-chain/bin/aikido-bun.js b/packages/safe-chain/bin/aikido-bun.js index 9d11784..01e3972 100755 --- a/packages/safe-chain/bin/aikido-bun.js +++ b/packages/safe-chain/bin/aikido-bun.js @@ -2,13 +2,9 @@ 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 = "bun"; initializePackageManager(packageManagerName); +var exitCode = await main(process.argv.slice(2)); -(async () => { - var exitCode = await main(process.argv.slice(2)); - process.exit(exitCode); -})(); +process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-bunx.js b/packages/safe-chain/bin/aikido-bunx.js index bcc93a6..fb378e5 100755 --- a/packages/safe-chain/bin/aikido-bunx.js +++ b/packages/safe-chain/bin/aikido-bunx.js @@ -2,13 +2,9 @@ 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 = "bunx"; initializePackageManager(packageManagerName); +var exitCode = await main(process.argv.slice(2)); -(async () => { - var exitCode = await main(process.argv.slice(2)); - process.exit(exitCode); -})(); +process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-npm.js b/packages/safe-chain/bin/aikido-npm.js index 7916f7e..d8b8c3e 100755 --- a/packages/safe-chain/bin/aikido-npm.js +++ b/packages/safe-chain/bin/aikido-npm.js @@ -1,14 +1,21 @@ #!/usr/bin/env node +import { execSync } from "child_process"; 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 = "npm"; -initializePackageManager(packageManagerName); +initializePackageManager(packageManagerName, getNpmVersion()); +var exitCode = await main(process.argv.slice(2)); -(async () => { - var exitCode = await main(process.argv.slice(2)); - process.exit(exitCode); -})(); +process.exit(exitCode); + +function getNpmVersion() { + try { + return execSync("npm --version").toString().trim(); + } catch { + // Default to 0.0.0 if npm is not found + // That way we don't use any unsupported features + return "0.0.0"; + } +} diff --git a/packages/safe-chain/bin/aikido-npx.js b/packages/safe-chain/bin/aikido-npx.js index 58f3491..7f06c7c 100755 --- a/packages/safe-chain/bin/aikido-npx.js +++ b/packages/safe-chain/bin/aikido-npx.js @@ -2,13 +2,9 @@ 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 = "npx"; -initializePackageManager(packageManagerName); +initializePackageManager(packageManagerName, process.versions.node); +var exitCode = await main(process.argv.slice(2)); -(async () => { - var exitCode = await main(process.argv.slice(2)); - process.exit(exitCode); -})(); +process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-pdm.js b/packages/safe-chain/bin/aikido-pdm.js deleted file mode 100755 index 9c6cf94..0000000 --- a/packages/safe-chain/bin/aikido-pdm.js +++ /dev/null @@ -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); -})(); diff --git a/packages/safe-chain/bin/aikido-pip.js b/packages/safe-chain/bin/aikido-pip.js deleted file mode 100755 index 6eb3e4e..0000000 --- a/packages/safe-chain/bin/aikido-pip.js +++ /dev/null @@ -1,17 +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"; -import { PIP_PACKAGE_MANAGER, PIP_COMMAND } from "../src/packagemanager/pip/pipSettings.js"; - -// Set eco system -setEcoSystem(ECOSYSTEM_PY); - -initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PIP_COMMAND, args: process.argv.slice(2) }); - -(async () => { - // Pass through only user-supplied pip args - var exitCode = await main(process.argv.slice(2)); - process.exit(exitCode); -})(); diff --git a/packages/safe-chain/bin/aikido-pip3.js b/packages/safe-chain/bin/aikido-pip3.js deleted file mode 100755 index 510b688..0000000 --- a/packages/safe-chain/bin/aikido-pip3.js +++ /dev/null @@ -1,17 +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"; -import { PIP_PACKAGE_MANAGER, PIP3_COMMAND } from "../src/packagemanager/pip/pipSettings.js"; - -// Set eco system -setEcoSystem(ECOSYSTEM_PY); - -initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PIP3_COMMAND, args: process.argv.slice(2) }); - -(async () => { - // Pass through only user-supplied pip args - var exitCode = await main(process.argv.slice(2)); - process.exit(exitCode); -})(); diff --git a/packages/safe-chain/bin/aikido-pipx.js b/packages/safe-chain/bin/aikido-pipx.js deleted file mode 100755 index 13e78f0..0000000 --- a/packages/safe-chain/bin/aikido-pipx.js +++ /dev/null @@ -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); -})(); diff --git a/packages/safe-chain/bin/aikido-pnpm.js b/packages/safe-chain/bin/aikido-pnpm.js index 64bc755..7177159 100755 --- a/packages/safe-chain/bin/aikido-pnpm.js +++ b/packages/safe-chain/bin/aikido-pnpm.js @@ -2,13 +2,9 @@ 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 = "pnpm"; -initializePackageManager(packageManagerName); +initializePackageManager(packageManagerName, process.versions.node); +var exitCode = await main(process.argv.slice(2)); -(async () => { - var exitCode = await main(process.argv.slice(2)); - process.exit(exitCode); -})(); +process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-pnpx.js b/packages/safe-chain/bin/aikido-pnpx.js index 11ee45c..4bb6840 100755 --- a/packages/safe-chain/bin/aikido-pnpx.js +++ b/packages/safe-chain/bin/aikido-pnpx.js @@ -2,13 +2,9 @@ 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 = "pnpx"; -initializePackageManager(packageManagerName); +initializePackageManager(packageManagerName, process.versions.node); +var exitCode = await main(process.argv.slice(2)); -(async () => { - var exitCode = await main(process.argv.slice(2)); - process.exit(exitCode); -})(); +process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-poetry.js b/packages/safe-chain/bin/aikido-poetry.js deleted file mode 100755 index 63169be..0000000 --- a/packages/safe-chain/bin/aikido-poetry.js +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env node - -import { main } from "../src/main.js"; -import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; -import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; - -setEcoSystem(ECOSYSTEM_PY); -initializePackageManager("poetry"); - -(async () => { - var exitCode = await main(process.argv.slice(2)); - process.exit(exitCode); -})(); diff --git a/packages/safe-chain/bin/aikido-python.js b/packages/safe-chain/bin/aikido-python.js deleted file mode 100755 index b769b4a..0000000 --- a/packages/safe-chain/bin/aikido-python.js +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node - -import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; -import { PIP_PACKAGE_MANAGER, PYTHON_COMMAND } from "../src/packagemanager/pip/pipSettings.js"; -import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; -import { main } from "../src/main.js"; - -// Set eco system -setEcoSystem(ECOSYSTEM_PY); - -// Strip nodejs and wrapper script from args -let argv = process.argv.slice(2); - -initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PYTHON_COMMAND, args: argv }); - -(async () => { - var exitCode = await main(argv); - process.exit(exitCode); -})(); diff --git a/packages/safe-chain/bin/aikido-python3.js b/packages/safe-chain/bin/aikido-python3.js deleted file mode 100755 index c572a7b..0000000 --- a/packages/safe-chain/bin/aikido-python3.js +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node - -import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; -import { PIP_PACKAGE_MANAGER, PYTHON3_COMMAND } from "../src/packagemanager/pip/pipSettings.js"; -import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; -import { main } from "../src/main.js"; - -// Set eco system -setEcoSystem(ECOSYSTEM_PY); - -// Strip nodejs and wrapper script from args -let argv = process.argv.slice(2); - -initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PYTHON3_COMMAND, args: argv }); - -(async () => { - var exitCode = await main(argv); - process.exit(exitCode); -})(); diff --git a/packages/safe-chain/bin/aikido-rush.js b/packages/safe-chain/bin/aikido-rush.js deleted file mode 100755 index b5d8094..0000000 --- a/packages/safe-chain/bin/aikido-rush.js +++ /dev/null @@ -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); -})(); diff --git a/packages/safe-chain/bin/aikido-rushx.js b/packages/safe-chain/bin/aikido-rushx.js deleted file mode 100755 index dfa168c..0000000 --- a/packages/safe-chain/bin/aikido-rushx.js +++ /dev/null @@ -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); -})(); diff --git a/packages/safe-chain/bin/aikido-uv.js b/packages/safe-chain/bin/aikido-uv.js deleted file mode 100755 index 4e635de..0000000 --- a/packages/safe-chain/bin/aikido-uv.js +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env node - -import { main } from "../src/main.js"; -import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; -import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; - -// Set eco system -setEcoSystem(ECOSYSTEM_PY); - -initializePackageManager("uv"); - -(async () => { - // Pass through only user-supplied uv args - var exitCode = await main(process.argv.slice(2)); - process.exit(exitCode); -})(); diff --git a/packages/safe-chain/bin/aikido-uvx.js b/packages/safe-chain/bin/aikido-uvx.js deleted file mode 100755 index 10bb9f3..0000000 --- a/packages/safe-chain/bin/aikido-uvx.js +++ /dev/null @@ -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); -})(); diff --git a/packages/safe-chain/bin/aikido-yarn.js b/packages/safe-chain/bin/aikido-yarn.js index 6c428db..002a956 100755 --- a/packages/safe-chain/bin/aikido-yarn.js +++ b/packages/safe-chain/bin/aikido-yarn.js @@ -2,13 +2,9 @@ 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 = "yarn"; -initializePackageManager(packageManagerName); +initializePackageManager(packageManagerName, process.versions.node); +var exitCode = await main(process.argv.slice(2)); -(async () => { - var exitCode = await main(process.argv.slice(2)); - process.exit(exitCode); -})(); +process.exit(exitCode); diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 6ff33a0..5a7d94b 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -1,42 +1,10 @@ #!/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 } 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"; -import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; -import { main } from "../src/main.js"; -import path from "path"; -import { fileURLToPath } from "url"; -import fs from "fs"; -import { knownAikidoTools, getPackageManagerList } from "../src/shell-integration/helpers.js"; -import { getInstalledSafeChainDir } from "../src/installLocation.js"; - -/** @type {string} */ -// This checks the current file's dirname in a way that's compatible with: -// - Modulejs (import.meta.url) -// - ES modules (__dirname) -// This is needed because safe-chain's npm package is built using ES modules, -// but building the binaries requires commonjs. -let dirname; -if (import.meta.url) { - const filename = fileURLToPath(import.meta.url); - dirname = path.dirname(filename); -} else { - dirname = __dirname; -} if (process.argv.length < 3) { ui.writeError("No command provided. Please provide a command to execute."); @@ -45,50 +13,19 @@ if (process.argv.length < 3) { process.exit(1); } -initializeCliArguments(process.argv); - const command = process.argv[2]; -const tool = knownAikidoTools.find((tool) => tool.tool === command); - -if (tool) { - const args = process.argv.slice(3); - - setEcoSystem(tool.ecoSystem); - - // Provide tool context to PM (pip uses this; others ignore) - const toolContext = { tool: tool.tool, args }; - initializePackageManager(tool.internalPackageManagerName, toolContext); - - (async () => { - var exitCode = await main(args); - process.exit(exitCode); - })(); -} else if (command === "help" || command === "--help" || command === "-h") { +if (command === "help" || command === "--help" || command === "-h") { writeHelp(); process.exit(0); -} else if (command === "setup") { +} + +if (command === "setup") { setup(); } else if (command === "teardown") { teardown(); - teardownDirectories(); } 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()}`); - })(); } else { ui.writeError(`Unknown command: ${command}.`); ui.emptyLine(); @@ -100,54 +37,29 @@ if (tool) { function writeHelp() { ui.writeInformation( - chalk.bold("Usage: ") + chalk.cyan("safe-chain "), + chalk.bold("Usage: ") + chalk.cyan("safe-chain ") ); 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("help")}` ); 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 and pnpx.` ); 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.`, - ); - ui.writeInformation( - `- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan( - "-v", - )}): Display the current version of safe-chain.`, + "safe-chain setup-ci" + )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.` ); ui.emptyLine(); } - -async function getVersion() { - const packageJsonPath = path.join(dirname, "..", "package.json"); - - const data = await fs.promises.readFile(packageJsonPath); - const json = JSON.parse(data.toString("utf8")); - - if (json && json.version) { - return json.version; - } - - return "0.0.0"; -} diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index 72f9bac..c0d2115 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -4,8 +4,7 @@ "scripts": { "test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'", "test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'", - "lint": "oxlint --deny-warnings", - "typecheck": "tsc --noEmit" + "lint": "eslint ." }, "bin": { "aikido-npm": "bin/aikido-npm.js", @@ -13,19 +12,8 @@ "aikido-yarn": "bin/aikido-yarn.js", "aikido-pnpm": "bin/aikido-pnpm.js", "aikido-pnpx": "bin/aikido-pnpx.js", - "aikido-rush": "bin/aikido-rush.js", - "aikido-rushx": "bin/aikido-rushx.js", "aikido-bun": "bin/aikido-bun.js", "aikido-bunx": "bin/aikido-bunx.js", - "aikido-uv": "bin/aikido-uv.js", - "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,27 +28,17 @@ "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/), and [pnpx](https://pnpm.io/cli/dlx) 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, or pnpx from downloading or running the malware.", "dependencies": { - "certifi": "14.5.15", + "abbrev": "3.0.1", "chalk": "5.4.1", "https-proxy-agent": "7.0.6", - "ini": "6.0.0", - "make-fetch-happen": "15.0.3", - "node-forge": "1.3.2", - "npm-registry-fetch": "19.1.1", + "make-fetch-happen": "14.0.3", + "node-forge": "1.3.1", + "npm-registry-fetch": "18.0.2", + "ora": "8.2.0", "semver": "7.7.2" }, - "devDependencies": { - "@types/ini": "^4.1.1", - "@types/make-fetch-happen": "^10.0.4", - "@types/node": "^18.19.130", - "@types/node-forge": "^1.3.14", - "@types/npm-registry-fetch": "^8.0.9", - "@types/semver": "^7.7.1", - "esbuild": "^0.27.0", - "typescript": "^5.9.3" - }, "main": "src/main.js", "bugs": { "url": "https://github.com/AikidoSec/safe-chain/issues" diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index 25babb9..c9eeea0 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -1,187 +1,33 @@ import fetch from "make-fetch-happen"; -import { - getEcoSystem, - ECOSYSTEM_JS, - ECOSYSTEM_PY, - getMalwareListBaseUrl, -} from "../config/settings.js"; -import { ui } from "../environment/userInteraction.js"; -const malwareDatabasePaths = { - [ECOSYSTEM_JS]: "malware_predictions.json", - [ECOSYSTEM_PY]: "malware_pypi.json", -}; +const malwareDatabaseUrl = + "https://malware-list.aikido.dev/malware_predictions.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 - * @property {string} version - * @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 response = await fetch(malwareDatabaseUrl); - if (!response.ok) { - throw new Error( - `Error fetching ${ecosystem} malware database: ${response.statusText}` - ); - } - - try { - let malwareDatabase = await response.json(); - return { - malwareDatabase: malwareDatabase, - version: response.headers.get("etag") || undefined, - }; - } catch (/** @type {any} */ error) { - throw new Error(`Error parsing malware database: ${error.message}`); - } - }, DEFAULT_FETCH_RETRY_ATTEMPTS); -} - -/** - * @returns {Promise} - */ -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 response = await fetch(malwareDatabaseUrl, { - method: "HEAD", - }); - - if (!response.ok) { - throw new Error( - `Error fetching ${ecosystem} malware database version: ${response.statusText}` - ); - } - return response.headers.get("etag") || undefined; - }, DEFAULT_FETCH_RETRY_ATTEMPTS); -} - -/** - * @returns {Promise<{newPackagesList: NewPackageEntry[], version: string | undefined}>} - */ -export async function fetchNewPackagesList() { - return retry(async () => { - const ecosystem = getEcoSystem(); - const baseUrl = getMalwareListBaseUrl(); - const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)]; - - if (!path) { - return { newPackagesList: [], version: undefined }; - } - - const url = `${baseUrl}/${path}`; - - const response = await fetch(url); - if (!response.ok) { - throw new Error( - `Error fetching ${ecosystem} new packages list: ${response.statusText}` - ); - } - - try { - const newPackagesList = await response.json(); - return { - newPackagesList, - version: response.headers.get("etag") || undefined, - }; - } catch (/** @type {any} */ error) { - throw new Error(`Error parsing new packages list: ${error.message}`); - } - }, DEFAULT_FETCH_RETRY_ATTEMPTS); -} - -/** - * @returns {Promise} - */ -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} func - The asynchronous function to retry - * @param {number} attempts - The number of attempts - * @returns {Promise} 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)); - } + const response = await fetch(malwareDatabaseUrl); + if (!response.ok) { + throw new Error(`Error fetching malware database: ${response.statusText}`); } - throw lastError; + try { + let malwareDatabase = await response.json(); + return { + malwareDatabase: malwareDatabase, + version: response.headers.get("etag") || undefined, + }; + } catch (error) { + throw new Error(`Error parsing malware database: ${error.message}`); + } +} + +export async function fetchMalwareDatabaseVersion() { + const response = await fetch(malwareDatabaseUrl, { + method: "HEAD", + }); + if (!response.ok) { + throw new Error( + `Error fetching malware database version: ${response.statusText}` + ); + } + return response.headers.get("etag") || undefined; } diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js deleted file mode 100644 index f41b9d2..0000000 --- a/packages/safe-chain/src/api/aikido.spec.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/packages/safe-chain/src/api/npmApi.js b/packages/safe-chain/src/api/npmApi.js index c03abef..96bacc2 100644 --- a/packages/safe-chain/src/api/npmApi.js +++ b/packages/safe-chain/src/api/npmApi.js @@ -1,11 +1,6 @@ import * as semver from "semver"; import * as npmFetch from "npm-registry-fetch"; -/** - * @param {string} packageName - * @param {string | null} [versionRange] - * @returns {Promise} - */ export async function resolvePackageVersion(packageName, versionRange) { if (!versionRange) { versionRange = "latest"; @@ -16,10 +11,7 @@ export async function resolvePackageVersion(packageName, versionRange) { return versionRange; } - const packageInfo = ( - /** @type {{"dist-tags"?: Record, versions?: Record} | null} */ - await getPackageInfo(packageName) - ); + const packageInfo = await getPackageInfo(packageName); if (!packageInfo) { // It is possible that no version is found (could be a private package, or a package that doesn't exist) // In this case, we return null to indicate that we couldn't resolve the version @@ -27,16 +19,12 @@ export async function resolvePackageVersion(packageName, versionRange) { } const distTags = packageInfo["dist-tags"]; - if (distTags && isDistTags(distTags) && distTags[versionRange]) { + if (distTags && distTags[versionRange]) { // If the version range is a dist-tag, return the version associated with that tag // e.g., "latest", "next", etc. return distTags[versionRange]; } - if (!packageInfo.versions) { - return null; - } - // If the version range is not a dist-tag, we need to resolve the highest version matching the range. // This is useful for ranges like "^1.0.0" or "~2.3.4". const availableVersions = Object.keys(packageInfo.versions); @@ -49,19 +37,6 @@ export async function resolvePackageVersion(packageName, versionRange) { return null; } -/** - * - * @param {unknown} distTags - * @returns {distTags is Record} - */ -function isDistTags(distTags) { - return typeof distTags === "object"; -} - -/** - * @param {string} packageName - * @returns {Promise | null>} - */ async function getPackageInfo(packageName) { try { return await npmFetch.json(packageName); diff --git a/packages/safe-chain/src/api/npmApi.spec.js b/packages/safe-chain/src/api/npmApi.spec.js deleted file mode 100644 index 0c7585d..0000000 --- a/packages/safe-chain/src/api/npmApi.spec.js +++ /dev/null @@ -1,211 +0,0 @@ -import { describe, it, mock } from "node:test"; -import assert from "node:assert"; - -describe("resolvePackageVersion", async () => { - const mockNpmFetchJson = mock.fn(); - - mock.module("npm-registry-fetch", { - namedExports: { - json: mockNpmFetchJson, - }, - }); - - const { resolvePackageVersion } = await import("./npmApi.js"); - - it("should return the version if it is already a fixed version", async () => { - const result = await resolvePackageVersion("express", "4.17.1"); - - assert.strictEqual(result, "4.17.1"); - }); - - it("should use 'latest' as default version range when not provided", async () => { - mockNpmFetchJson.mock.mockImplementationOnce(() => ({ - "dist-tags": { - latest: "4.18.2", - }, - versions: { - "4.18.2": {}, - }, - })); - - const result = await resolvePackageVersion("express"); - - assert.strictEqual(result, "4.18.2"); - }); - - it("should resolve dist-tag versions", async () => { - mockNpmFetchJson.mock.mockImplementationOnce(() => ({ - "dist-tags": { - latest: "4.18.2", - next: "5.0.0-beta.1", - }, - versions: { - "4.18.2": {}, - "5.0.0-beta.1": {}, - }, - })); - - const result = await resolvePackageVersion("express", "next"); - - assert.strictEqual(result, "5.0.0-beta.1"); - }); - - it("should resolve version ranges using semver", async () => { - mockNpmFetchJson.mock.mockImplementationOnce(() => ({ - "dist-tags": { - latest: "4.18.2", - }, - versions: { - "4.16.0": {}, - "4.17.0": {}, - "4.17.1": {}, - "4.18.0": {}, - "4.18.2": {}, - }, - })); - - const result = await resolvePackageVersion("express", "^4.17.0"); - - assert.strictEqual(result, "4.18.2"); - }); - - it("should resolve tilde ranges correctly", async () => { - mockNpmFetchJson.mock.mockImplementationOnce(() => ({ - "dist-tags": { - latest: "4.18.2", - }, - versions: { - "4.17.0": {}, - "4.17.1": {}, - "4.17.3": {}, - "4.18.0": {}, - }, - })); - - const result = await resolvePackageVersion("express", "~4.17.0"); - - assert.strictEqual(result, "4.17.3"); - }); - - it("should return null if package info cannot be fetched", async () => { - mockNpmFetchJson.mock.mockImplementationOnce(() => { - throw new Error("Package not found"); - }); - - const result = await resolvePackageVersion("non-existent-package", "latest"); - - assert.strictEqual(result, null); - }); - - it("should return null if no versions match the range", async () => { - mockNpmFetchJson.mock.mockImplementationOnce(() => ({ - "dist-tags": { - latest: "1.0.0", - }, - versions: { - "1.0.0": {}, - "1.1.0": {}, - }, - })); - - const result = await resolvePackageVersion("express", "^5.0.0"); - - assert.strictEqual(result, null); - }); - - it("should return null if dist-tag does not exist", async () => { - mockNpmFetchJson.mock.mockImplementationOnce(() => ({ - "dist-tags": { - latest: "4.18.2", - }, - versions: { - "4.18.2": {}, - }, - })); - - const result = await resolvePackageVersion("express", "nonexistent-tag"); - - assert.strictEqual(result, null); - }); - - it("should return null if package info has no versions property (retracted package)", async () => { - mockNpmFetchJson.mock.mockImplementationOnce(() => ({ - _id: "zenn", - name: "zenn", - time: { - modified: "2021-04-20T16:20:56.084Z", - created: "2017-07-10T19:48:07.891Z", - unpublished: { - time: "2021-04-20T16:20:56.084Z", - versions: [ - "0.9.0", - "0.9.1", - "0.9.2", - "0.9.3", - "0.9.4", - "0.9.5", - "0.9.6", - "0.9.8", - "0.9.9", - "0.9.10", - "0.9.11", - "0.9.12", - "0.9.13", - "0.9.14", - ], - }, - }, - })); - - const result = await resolvePackageVersion("zenn", "^0.9.0"); - - assert.strictEqual(result, null); - }); - - it("should return dist-tag version even if versions property is missing", async () => { - mockNpmFetchJson.mock.mockImplementationOnce(() => ({ - "dist-tags": { - latest: "4.18.2", - }, - })); - - const result = await resolvePackageVersion("express", "latest"); - - assert.strictEqual(result, "4.18.2"); - }); - - it("should handle scoped packages", async () => { - mockNpmFetchJson.mock.mockImplementationOnce(() => ({ - "dist-tags": { - latest: "1.2.3", - }, - versions: { - "1.2.3": {}, - }, - })); - - const result = await resolvePackageVersion("@scope/package", "latest"); - - assert.strictEqual(result, "1.2.3"); - }); - - it("should handle complex version ranges", async () => { - mockNpmFetchJson.mock.mockImplementationOnce(() => ({ - "dist-tags": { - latest: "2.5.0", - }, - versions: { - "1.0.0": {}, - "2.0.0": {}, - "2.3.0": {}, - "2.4.0": {}, - "2.5.0": {}, - "3.0.0": {}, - }, - })); - - const result = await resolvePackageVersion("express", ">=2.0.0 <3.0.0"); - - assert.strictEqual(result, "2.5.0"); - }); -}); diff --git a/packages/safe-chain/src/config/cliArguments.js b/packages/safe-chain/src/config/cliArguments.js index 918761c..87abb7b 100644 --- a/packages/safe-chain/src/config/cliArguments.js +++ b/packages/safe-chain/src/config/cliArguments.js @@ -1,27 +1,12 @@ -import { ui } from "../environment/userInteraction.js"; - -/** - * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, malwareListBaseUrl: string | undefined}} - */ const state = { - loggingLevel: undefined, - skipMinimumPackageAge: undefined, - minimumPackageAgeHours: undefined, - malwareListBaseUrl: undefined, + malwareAction: undefined, }; const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-"; -/** - * @param {string[]} args - * @returns {string[]} - */ export function initializeCliArguments(args) { // Reset state on each call - state.loggingLevel = undefined; - state.skipMinimumPackageAge = undefined; - state.minimumPackageAgeHours = undefined; - state.malwareListBaseUrl = undefined; + state.malwareAction = undefined; const safeChainArgs = []; const remainingArgs = []; @@ -34,19 +19,21 @@ export function initializeCliArguments(args) { } } - setLoggingLevel(safeChainArgs); - setSkipMinimumPackageAge(safeChainArgs); - setMinimumPackageAgeHours(safeChainArgs); - setMalwareListBaseUrl(safeChainArgs); - checkDeprecatedPythonFlag(args); + setMalwareAction(safeChainArgs); + return remainingArgs; } -/** - * @param {string[]} args - * @param {string} prefix - * @returns {string | undefined} - */ +function setMalwareAction(args) { + const safeChainMalwareActionArg = SAFE_CHAIN_ARG_PREFIX + "malware-action="; + + const action = getLastArgEqualsValue(args, safeChainMalwareActionArg); + if (!action) { + return; + } + state.malwareAction = action.toLowerCase(); +} + function getLastArgEqualsValue(args, prefix) { for (var i = args.length - 1; i >= 0; i--) { const arg = args[i]; @@ -58,104 +45,6 @@ function getLastArgEqualsValue(args, prefix) { return undefined; } -/** - * @param {string[]} args - * @returns {void} - */ -function setLoggingLevel(args) { - const safeChainLoggingArg = SAFE_CHAIN_ARG_PREFIX + "logging="; - - const level = getLastArgEqualsValue(args, safeChainLoggingArg); - if (!level) { - return; - } - state.loggingLevel = level.toLowerCase(); -} - -export function getLoggingLevel() { - return state.loggingLevel; -} - -/** - * @param {string[]} args - * @returns {void} - */ -function setSkipMinimumPackageAge(args) { - const flagName = SAFE_CHAIN_ARG_PREFIX + "skip-minimum-package-age"; - - if (hasFlagArg(args, flagName)) { - state.skipMinimumPackageAge = true; - } -} - -export function getSkipMinimumPackageAge() { - return state.skipMinimumPackageAge; -} - -/** - * @param {string[]} args - * @returns {void} - */ -function setMinimumPackageAgeHours(args) { - const argName = SAFE_CHAIN_ARG_PREFIX + "minimum-package-age-hours="; - - const value = getLastArgEqualsValue(args, argName); - if (value) { - state.minimumPackageAgeHours = value; - } -} - -/** - * @returns {string | undefined} - */ -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 - * @returns {boolean} - */ -function hasFlagArg(args, flagName) { - for (const arg of args) { - if (arg.toLowerCase() === flagName.toLowerCase()) { - return true; - } - } - 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." - ); - } +export function getMalwareAction() { + return state.malwareAction; } diff --git a/packages/safe-chain/src/config/cliArguments.spec.js b/packages/safe-chain/src/config/cliArguments.spec.js index 8b505be..9d5c0ba 100644 --- a/packages/safe-chain/src/config/cliArguments.spec.js +++ b/packages/safe-chain/src/config/cliArguments.spec.js @@ -1,12 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert"; -import { - initializeCliArguments, - getLoggingLevel, - getSkipMinimumPackageAge, - getMinimumPackageAgeHours, -} from "./cliArguments.js"; -import { ui } from "../environment/userInteraction.js"; +import { initializeCliArguments, getMalwareAction } from "./cliArguments.js"; describe("initializeCliArguments", () => { it("should return all args when no safe-chain args are present", () => { @@ -63,249 +57,52 @@ describe("initializeCliArguments", () => { assert.deepEqual(result, ["install", "my--safe-chain-package", "--save"]); }); - it("should not set loggingLevel when no logging argument is passed", () => { + it("should not set malwareAction when no safe-chain arguments are passed", () => { const args = ["install", "express", "--save"]; - initializeCliArguments(args); + const result = initializeCliArguments(args); - assert.strictEqual(getLoggingLevel(), undefined); + assert.deepEqual(result, ["install", "express", "--save"]); + assert.strictEqual(getMalwareAction(), undefined); }); - it("should parse logging=silent and set state", () => { - const args = ["--safe-chain-logging=silent", "install", "package"]; + it("should parse malware-action=block and set state", () => { + const args = ["--safe-chain-malware-action=block", "install", "package"]; const result = initializeCliArguments(args); assert.deepEqual(result, ["install", "package"]); - assert.strictEqual(getLoggingLevel(), "silent"); + assert.strictEqual(getMalwareAction(), "block"); }); - it("should parse logging=normal and set state", () => { - const args = ["--safe-chain-logging=normal", "install", "package"]; + it("should parse malware-action=prompt and set state", () => { + const args = ["--safe-chain-malware-action=prompt", "install", "package"]; const result = initializeCliArguments(args); assert.deepEqual(result, ["install", "package"]); - assert.strictEqual(getLoggingLevel(), "normal"); + assert.strictEqual(getMalwareAction(), "prompt"); }); - it("should handle multiple logging args, using the last one", () => { + it("should handle multiple malware-action args, using the last valid one", () => { const args = [ - "--safe-chain-logging=normal", - "--safe-chain-logging=silent", + "--safe-chain-malware-action=block", + "--safe-chain-malware-action=prompt", "install", ]; const result = initializeCliArguments(args); assert.deepEqual(result, ["install"]); - assert.strictEqual(getLoggingLevel(), "silent"); + assert.strictEqual(getMalwareAction(), "prompt"); }); - it("should handle logging level case-insensitively", () => { - const args = ["--safe-chain-logging=SILENT", "install"]; - initializeCliArguments(args); - - assert.strictEqual(getLoggingLevel(), "silent"); - }); - - it("should capture invalid logging level as-is (lowercased)", () => { - const args = ["--safe-chain-logging=invalid", "install"]; - initializeCliArguments(args); - - assert.strictEqual(getLoggingLevel(), "invalid"); - }); - - it("should handle logging with other safe-chain args", () => { + it("should handle malware-action with other safe-chain args", () => { const args = [ "--safe-chain-debug", - "--safe-chain-logging=silent", "--safe-chain-malware-action=block", + "--safe-chain-verbose", "install", ]; const result = initializeCliArguments(args); assert.deepEqual(result, ["install"]); - assert.strictEqual(getLoggingLevel(), "silent"); - }); - - it("should not set skipMinimumPackageAge when flag is absent", () => { - const args = ["install", "express", "--save"]; - initializeCliArguments(args); - - assert.strictEqual(getSkipMinimumPackageAge(), undefined); - }); - - it("should set skipMinimumPackageAge to true when flag is present", () => { - const args = ["--safe-chain-skip-minimum-package-age", "install", "lodash"]; - const result = initializeCliArguments(args); - - assert.deepEqual(result, ["install", "lodash"]); - assert.strictEqual(getSkipMinimumPackageAge(), true); - }); - - it("should handle skip-minimum-package-age flag case-insensitively", () => { - const args = ["--SAFE-CHAIN-SKIP-MINIMUM-PACKAGE-AGE", "install"]; - initializeCliArguments(args); - - assert.strictEqual(getSkipMinimumPackageAge(), true); - }); - - it("should filter out skip-minimum-package-age flag from returned args", () => { - const args = [ - "install", - "--safe-chain-skip-minimum-package-age", - "express", - "--save", - ]; - const result = initializeCliArguments(args); - - assert.deepEqual(result, ["install", "express", "--save"]); - }); - - it("should handle skip-minimum-package-age with other safe-chain arguments", () => { - const args = [ - "--safe-chain-logging=verbose", - "--safe-chain-skip-minimum-package-age", - "install", - "lodash", - ]; - const result = initializeCliArguments(args); - - assert.deepEqual(result, ["install", "lodash"]); - assert.strictEqual(getLoggingLevel(), "verbose"); - assert.strictEqual(getSkipMinimumPackageAge(), true); - }); - - it("should handle skip-minimum-package-age flag in different positions", () => { - const args = ["install", "lodash", "--safe-chain-skip-minimum-package-age"]; - const result = initializeCliArguments(args); - - assert.deepEqual(result, ["install", "lodash"]); - assert.strictEqual(getSkipMinimumPackageAge(), true); - }); - - it("should return undefined when no minimum-package-age-hours argument is passed", () => { - const args = ["install", "express", "--save"]; - initializeCliArguments(args); - - assert.strictEqual(getMinimumPackageAgeHours(), undefined); - }); - - it("should parse minimum-package-age-hours value and set state", () => { - const args = [ - "--safe-chain-minimum-package-age-hours=48", - "install", - "lodash", - ]; - const result = initializeCliArguments(args); - - assert.deepEqual(result, ["install", "lodash"]); - assert.strictEqual(getMinimumPackageAgeHours(), "48"); - }); - - it("should handle minimum-package-age-hours with zero value", () => { - const args = ["--safe-chain-minimum-package-age-hours=0", "install"]; - initializeCliArguments(args); - - assert.strictEqual(getMinimumPackageAgeHours(), "0"); - }); - - it("should handle minimum-package-age-hours with decimal values", () => { - const args = ["--safe-chain-minimum-package-age-hours=1.5", "install"]; - initializeCliArguments(args); - - assert.strictEqual(getMinimumPackageAgeHours(), "1.5"); - }); - - it("should handle minimum-package-age-hours case-insensitively", () => { - const args = ["--SAFE-CHAIN-MINIMUM-PACKAGE-AGE-HOURS=72", "install"]; - initializeCliArguments(args); - - assert.strictEqual(getMinimumPackageAgeHours(), "72"); - }); - - it("should use the last minimum-package-age-hours argument when multiple are provided", () => { - const args = [ - "--safe-chain-minimum-package-age-hours=12", - "--safe-chain-minimum-package-age-hours=36", - "install", - ]; - initializeCliArguments(args); - - assert.strictEqual(getMinimumPackageAgeHours(), "36"); - }); - - it("should filter out minimum-package-age-hours argument from returned args", () => { - const args = [ - "install", - "--safe-chain-minimum-package-age-hours=48", - "express", - "--save", - ]; - const result = initializeCliArguments(args); - - assert.deepEqual(result, ["install", "express", "--save"]); - }); - - it("should handle minimum-package-age-hours with other safe-chain arguments", () => { - const args = [ - "--safe-chain-logging=verbose", - "--safe-chain-minimum-package-age-hours=96", - "install", - "lodash", - ]; - const result = initializeCliArguments(args); - - assert.deepEqual(result, ["install", "lodash"]); - assert.strictEqual(getLoggingLevel(), "verbose"); - assert.strictEqual(getMinimumPackageAgeHours(), "96"); - }); - - it("should handle non-numeric values without validation (validation in settings.js)", () => { - const args = ["--safe-chain-minimum-package-age-hours=invalid", "install"]; - initializeCliArguments(args); - - // cliArguments.js just captures the value; validation is in settings.js - assert.strictEqual(getMinimumPackageAgeHours(), "invalid"); - }); - - it("should handle negative values as strings (validation in settings.js)", () => { - const args = ["--safe-chain-minimum-package-age-hours=-24", "install"]; - initializeCliArguments(args); - - assert.strictEqual(getMinimumPackageAgeHours(), "-24"); - }); - - it("should warn on deprecated --include-python for setup", () => { - const warnings = []; - const originalWriteWarning = ui.writeWarning; - ui.writeWarning = (msg, ..._rest) => { - warnings.push(String(msg)); - }; - try { - const argv = ["node", "safe-chain", "setup", "--include-python"]; - initializeCliArguments(argv); - assert.ok( - warnings.some((m) => m.includes("--include-python is deprecated")), - "Expected a deprecation warning for --include-python in setup" - ); - } finally { - ui.writeWarning = originalWriteWarning; - } - }); - - it("should warn on deprecated --include-python for setup-ci", () => { - const warnings = []; - const originalWriteWarning = ui.writeWarning; - ui.writeWarning = (msg, ..._rest) => { - warnings.push(String(msg)); - }; - try { - const argv = ["node", "safe-chain", "setup-ci", "--include-python"]; - initializeCliArguments(argv); - assert.ok( - warnings.some((m) => m.includes("--include-python is deprecated")), - "Expected a deprecation warning for --include-python in setup-ci" - ); - } finally { - ui.writeWarning = originalWriteWarning; - } + assert.strictEqual(getMalwareAction(), "block"); }); }); diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index d340130..2feb307 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -2,176 +2,14 @@ import fs from "fs"; 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 - * We cannot trust the input and should add the necessary validations. - * @property {unknown | string[]} customRegistries - * @property {unknown | string[]} minimumPackageAgeExclusions - */ - -/** - * @returns {number} - */ export function getScanTimeout() { const config = readConfigFile(); - - if (process.env.AIKIDO_SCAN_TIMEOUT_MS) { - const scanTimeout = validateTimeout(process.env.AIKIDO_SCAN_TIMEOUT_MS); - if (scanTimeout != null) { - return scanTimeout; - } - } - - if (config.scanTimeout) { - const scanTimeout = validateTimeout(config.scanTimeout); - if (scanTimeout != null) { - return scanTimeout; - } - } - - return 10000; // Default to 10 seconds + return ( + parseInt(process.env.AIKIDO_SCAN_TIMEOUT_MS) || config.scanTimeout || 10000 // Default to 10 seconds + ); } -/** - * - * @param {any} value - * @returns {number?} - */ -function validateTimeout(value) { - const timeout = Number(value); - if (!Number.isNaN(timeout) && timeout > 0) { - return timeout; - } - return null; -} - -/** - * @param {any} value - * @returns {number | undefined} - */ -function validateMinimumPackageAgeHours(value) { - const hours = Number(value); - if (!Number.isNaN(hours)) { - return hours; - } - return undefined; -} - -/** - * Gets the minimum package age in hours from config file only - * @returns {number | undefined} - */ -export function getMinimumPackageAgeHours() { - const config = readConfigFile(); - if (config.minimumPackageAgeHours !== undefined) { - const validated = validateMinimumPackageAgeHours( - config.minimumPackageAgeHours - ); - if (validated !== undefined) { - return validated; - } - } - return undefined; -} - -/** - * Gets the malware list base URL from config file only - * @returns {string | undefined} - */ -export function getMalwareListBaseUrl() { - const config = readConfigFile(); - if (config.malwareListBaseUrl && typeof config.malwareListBaseUrl === "string") { - return config.malwareListBaseUrl; - } - return undefined; -} - -/** - * Gets the custom npm registries from the config file (format parsing only, no validation) - * @returns {string[]} - */ -export function getNpmCustomRegistries() { - const config = readConfigFile(); - - if (!config || !config.npm) { - return []; - } - - // TypeScript needs help understanding that config.npm exists and has customRegistries - const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm); - const customRegistries = npmConfig.customRegistries; - - if (!Array.isArray(customRegistries)) { - return []; - } - - return customRegistries.filter((item) => typeof item === "string"); -} - -/** - * Gets the custom npm registries from the config file (format parsing only, no validation) - * @returns {string[]} - */ -export function getPipCustomRegistries() { - const config = readConfigFile(); - - if (!config || !config.pip) { - return []; - } - - // TypeScript needs help understanding that config.pip exists and has customRegistries - const pipConfig = /** @type {SafeChainRegistryConfiguration} */ (config.pip); - const customRegistries = pipConfig.customRegistries; - - if (!Array.isArray(customRegistries)) { - return []; - } - - return customRegistries.filter((item) => typeof item === "string"); -} - -/** - * Gets the minimum package age exclusions from the config file for the current ecosystem - * @returns {string[]} - */ -export function getMinimumPackageAgeExclusions() { - const config = readConfigFile(); - const ecosystem = getEcoSystem(); - const registryConfig = ecosystem === "py" ? config.pip : config.npm; - - if (!config || !registryConfig) { - return []; - } - - const typedRegistryConfig = - /** @type {SafeChainRegistryConfiguration} */ (registryConfig); - const exclusions = typedRegistryConfig.minimumPackageAgeExclusions; - - if (!Array.isArray(exclusions)) { - return []; - } - - return exclusions.filter((item) => typeof item === "string"); -} - -/** - * @param {import("../api/aikido.js").MalwarePackage[]} data - * @param {string | number} version - * - * @returns {void} - */ export function writeDatabaseToLocalCache(data, version) { try { const databasePath = getDatabasePath(); @@ -186,9 +24,6 @@ export function writeDatabaseToLocalCache(data, version) { } } -/** - * @returns {{malwareDatabase: import("../api/aikido.js").MalwarePackage[] | null, version: string | null}} - */ export function readDatabaseFromLocalCache() { try { const databasePath = getDatabasePath(); @@ -220,102 +55,31 @@ 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 {}; } - try { - const data = fs.readFileSync(configFilePath, "utf8"); - return JSON.parse(data); - } catch { - return emptyConfig; - } + const data = fs.readFileSync(configFilePath, "utf8"); + return JSON.parse(data); } -/** - * @returns {string} - */ function getDatabasePath() { const aikidoDir = getAikidoDirectory(); - const ecosystem = getEcoSystem(); - return path.join(aikidoDir, `malwareDatabase_${ecosystem}.json`); + return path.join(aikidoDir, "malwareDatabase.json"); } function getDatabaseVersionPath() { const aikidoDir = getAikidoDirectory(); - const ecosystem = getEcoSystem(); - return path.join(aikidoDir, `version_${ecosystem}.txt`); + return path.join(aikidoDir, "version.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; + return path.join(getAikidoDirectory(), "config.json"); } -/** - * @returns {string} - */ -export function getSafeChainDirectory() { - const safeChainDir = getSafeChainBaseDir(); - - if (!fs.existsSync(safeChainDir)) { - fs.mkdirSync(safeChainDir, { recursive: true }); - } - return safeChainDir; -} - -/** - * @returns {string} - */ function getAikidoDirectory() { const homeDir = os.homedir(); const aikidoDir = path.join(homeDir, ".aikido"); diff --git a/packages/safe-chain/src/config/configFile.spec.js b/packages/safe-chain/src/config/configFile.spec.js deleted file mode 100644 index 8b36ff2..0000000 --- a/packages/safe-chain/src/config/configFile.spec.js +++ /dev/null @@ -1,380 +0,0 @@ -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} */ -let mockFiles = new Map(); -mock.module("fs", { - namedExports: { - existsSync: (filePath) => mockFiles.has(filePath), - readFileSync: (filePath) => { - if (!mockFiles.has(filePath)) { - throw new Error(`ENOENT: no such file: ${filePath}`); - } - return mockFiles.get(filePath); - }, - writeFileSync: (filePath, content) => mockFiles.set(filePath, content), - mkdirSync: () => {}, - }, -}); - -/** - * Helper to set config content at the primary (~/.safe-chain/) location. - * @param {string} content - */ -function setConfigContent(content) { - mockFiles.set(safeChainConfigPath, content); -} - -describe("getScanTimeout", async () => { - let originalEnv; - - const { getScanTimeout } = await import("./configFile.js"); - - beforeEach(async () => { - // Save original environment - originalEnv = process.env.AIKIDO_SCAN_TIMEOUT_MS; - }); - - afterEach(() => { - // Restore original environment - if (originalEnv !== undefined) { - process.env.AIKIDO_SCAN_TIMEOUT_MS = originalEnv; - } else { - delete process.env.AIKIDO_SCAN_TIMEOUT_MS; - } - - mockFiles.clear(); - }); - - it("should return default timeout of 10000ms when no config or env var is set", () => { - delete process.env.AIKIDO_SCAN_TIMEOUT_MS; - - const timeout = getScanTimeout(); - - assert.strictEqual(timeout, 10000); - }); - - it("should return timeout from config file when set", () => { - delete process.env.AIKIDO_SCAN_TIMEOUT_MS; - setConfigContent(JSON.stringify({ scanTimeout: 5000 })); - - const timeout = getScanTimeout(); - - assert.strictEqual(timeout, 5000); - }); - - it("should prioritize environment variable over config file", () => { - process.env.AIKIDO_SCAN_TIMEOUT_MS = "20000"; - setConfigContent(JSON.stringify({ scanTimeout: 5000 })); - - const timeout = getScanTimeout(); - - assert.strictEqual(timeout, 20000); - }); - - it("should handle invalid environment variable and fall back to config", () => { - process.env.AIKIDO_SCAN_TIMEOUT_MS = "invalid"; - setConfigContent(JSON.stringify({ scanTimeout: 7000 })); - - const timeout = getScanTimeout(); - - assert.strictEqual(timeout, 7000); - }); - - it("should ignore zero and negative values and fall back to default", () => { - process.env.AIKIDO_SCAN_TIMEOUT_MS = "0"; - - let timeout = getScanTimeout(); - assert.strictEqual(timeout, 10000); - - process.env.AIKIDO_SCAN_TIMEOUT_MS = "-5000"; - - timeout = getScanTimeout(); - assert.strictEqual(timeout, 10000); - }); - - 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 })); - - const timeout = getScanTimeout(); - - assert.strictEqual(timeout, 8000); - }); - - 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" })); - - const timeout = getScanTimeout(); - - assert.strictEqual(timeout, 10000); - }); - - 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" })); - - const timeout = getScanTimeout(); - - assert.strictEqual(timeout, 10000); - }); - - it("should ignore mixed alphanumeric strings in environment variable", () => { - process.env.AIKIDO_SCAN_TIMEOUT_MS = "5000ms"; - setConfigContent(JSON.stringify({ scanTimeout: 6000 })); - - const timeout = getScanTimeout(); - - assert.strictEqual(timeout, 6000); - }); - - it("should ignore mixed alphanumeric strings in config file", () => { - delete process.env.AIKIDO_SCAN_TIMEOUT_MS; - setConfigContent(JSON.stringify({ scanTimeout: "3000ms" })); - - const timeout = getScanTimeout(); - - assert.strictEqual(timeout, 10000); - }); -}); - -describe("getMinimumPackageAgeHours", async () => { - const { getMinimumPackageAgeHours } = await import("./configFile.js"); - - afterEach(() => { - mockFiles.clear(); - }); - - it("should return null when config file doesn't exist", () => { - const hours = getMinimumPackageAgeHours(); - - assert.strictEqual(hours, undefined); - }); - - it("should return null when config file exists but minimumPackageAgeHours is not set", () => { - setConfigContent(JSON.stringify({ scanTimeout: 5000 })); - - const hours = getMinimumPackageAgeHours(); - - assert.strictEqual(hours, undefined); - }); - - it("should return value from config file when set to valid number", () => { - setConfigContent(JSON.stringify({ minimumPackageAgeHours: 48 })); - - const hours = getMinimumPackageAgeHours(); - - assert.strictEqual(hours, 48); - }); - - it("should handle string numbers in config file", () => { - setConfigContent(JSON.stringify({ minimumPackageAgeHours: "72" })); - - const hours = getMinimumPackageAgeHours(); - - assert.strictEqual(hours, 72); - }); - - it("should handle decimal values", () => { - setConfigContent(JSON.stringify({ minimumPackageAgeHours: 1.5 })); - - const hours = getMinimumPackageAgeHours(); - - assert.strictEqual(hours, 1.5); - }); - - it("should return null for non-numeric strings", () => { - setConfigContent(JSON.stringify({ minimumPackageAgeHours: "invalid" })); - - const hours = getMinimumPackageAgeHours(); - - assert.strictEqual(hours, undefined); - }); - - it("should return undefined for values with units suffix", () => { - setConfigContent(JSON.stringify({ minimumPackageAgeHours: "48h" })); - - const hours = getMinimumPackageAgeHours(); - - assert.strictEqual(hours, undefined); - }); - - it("should handle malformed JSON and return null", () => { - setConfigContent("{ invalid json"); - - const hours = getMinimumPackageAgeHours(); - - assert.strictEqual(hours, undefined); - }); - - it("should return 0 when minimumPackageAgeHours is set to 0", () => { - setConfigContent(JSON.stringify({ minimumPackageAgeHours: 0 })); - - const hours = getMinimumPackageAgeHours(); - - assert.strictEqual(hours, 0); - }); - - it("should return 0 when minimumPackageAgeHours is set to string '0'", () => { - setConfigContent(JSON.stringify({ minimumPackageAgeHours: "0" })); - - const hours = getMinimumPackageAgeHours(); - - assert.strictEqual(hours, 0); - }); - - it("should handle negative numeric values", () => { - setConfigContent(JSON.stringify({ minimumPackageAgeHours: -24 })); - - const hours = getMinimumPackageAgeHours(); - - assert.strictEqual(hours, -24); - }); - - it("should handle negative string values", () => { - setConfigContent(JSON.stringify({ minimumPackageAgeHours: "-48" })); - - const hours = getMinimumPackageAgeHours(); - - assert.strictEqual(hours, -48); - }); -}); - -const { getNpmCustomRegistries, getPipCustomRegistries } = await import( - "./configFile.js" -); - -for (const { packageManager, getCustomRegistries } of [ - { - packageManager: "npm", - getCustomRegistries: getNpmCustomRegistries, - }, - { - packageManager: "pip", - getCustomRegistries: getPipCustomRegistries, - }, -]) -{ - describe(getCustomRegistries.name, async () => { - afterEach(() => { - mockFiles.clear(); - }); - - it("should return empty array when config file doesn't exist", () => { - const registries = getCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); - - it(`should return empty array when ${packageManager} config is not set`, () => { - setConfigContent(JSON.stringify({ scanTimeout: 5000 })); - - const registries = getCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); - - it("should return empty array when customRegistries is not an array", () => { - setConfigContent(JSON.stringify({ - [packageManager]: { customRegistries: "not-an-array" }, - })); - - const registries = getCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); - - it("should return array of custom registries when set", () => { - setConfigContent(JSON.stringify({ - [packageManager]: { - customRegistries: [`${packageManager}.company.com`, "registry.internal.net"], - }, - })); - - const registries = getCustomRegistries(); - - assert.deepStrictEqual(registries, [ - `${packageManager}.company.com`, - "registry.internal.net", - ]); - }); - - it("should filter out non-string values", () => { - setConfigContent(JSON.stringify({ - [packageManager]: { - customRegistries: [ - `${packageManager}.company.com`, - 123, - null, - "registry.internal.net", - undefined, - {}, - ], - }, - })); - - const registries = getCustomRegistries(); - - assert.deepStrictEqual(registries, [ - `${packageManager}.company.com`, - "registry.internal.net", - ]); - }); - - it("should return empty array for empty customRegistries array", () => { - setConfigContent(JSON.stringify({ - [packageManager]: { customRegistries: [] }, - })); - - const registries = getCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); - - it("should handle malformed JSON and return empty array", () => { - setConfigContent("{ invalid json"); - - const registries = getCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); - }); -} - -describe("config file location fallback", async () => { - const { getScanTimeout } = await import("./configFile.js"); - - afterEach(() => { - mockFiles.clear(); - delete process.env.AIKIDO_SCAN_TIMEOUT_MS; - }); - - it("should read config from ~/.safe-chain/config.json when it exists", () => { - mockFiles.set(safeChainConfigPath, JSON.stringify({ scanTimeout: 3000 })); - - assert.strictEqual(getScanTimeout(), 3000); - }); - - it("should fall back to ~/.aikido/config.json when primary does not exist", () => { - mockFiles.set(aikidoConfigPath, JSON.stringify({ scanTimeout: 4000 })); - - assert.strictEqual(getScanTimeout(), 4000); - }); - - it("should prefer ~/.safe-chain/config.json when both exist", () => { - mockFiles.set(safeChainConfigPath, JSON.stringify({ scanTimeout: 3000 })); - mockFiles.set(aikidoConfigPath, JSON.stringify({ scanTimeout: 4000 })); - - assert.strictEqual(getScanTimeout(), 3000); - }); - - it("should return default when neither config file exists", () => { - assert.strictEqual(getScanTimeout(), 10000); - }); -}); diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js deleted file mode 100644 index 932eff7..0000000 --- a/packages/safe-chain/src/config/environmentVariables.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Gets the minimum package age in hours from environment variable - * @returns {string | undefined} - */ -export function getMinimumPackageAgeHours() { - return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS; -} - -/** - * Gets the custom npm registries from environment variable - * Expected format: comma-separated list of registry domains - * Example: "npm.company.com,registry.internal.net" - * @returns {string | undefined} - */ -export function getNpmCustomRegistries() { - return process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; -} - -/** - * Gets the custom pip registries from environment variable - * Expected format: comma-separated list of registry domains - * Example: "pip.company.com,registry.internal.net" - * @returns {string | undefined} - */ -export function getPipCustomRegistries() { - return process.env.SAFE_CHAIN_PIP_CUSTOM_REGISTRIES; -} - -/** - * Gets the logging level from environment variable - * Valid values: "silent", "normal", "verbose" - * @returns {string | undefined} - */ -export function getLoggingLevel() { - return process.env.SAFE_CHAIN_LOGGING; -} - -/** - * Gets the minimum package age exclusions from environment variable - * Expected format: comma-separated list of package names - * Example: "react,@aikidosec/safe-chain,lodash" - * @returns {string | undefined} - */ -export function getMinimumPackageAgeExclusions() { - return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS || - process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS; -} - -/** - * Gets the malware list base URL from environment variable - * Expected format: full URL without trailing slash - * Example: "https://malware-list.aikido.dev" - * @returns {string | undefined} - */ -export function getMalwareListBaseUrl() { - return process.env.SAFE_CHAIN_MALWARE_LIST_BASE_URL; -} diff --git a/packages/safe-chain/src/config/safeChainDir.js b/packages/safe-chain/src/config/safeChainDir.js deleted file mode 100644 index 4d4f013..0000000 --- a/packages/safe-chain/src/config/safeChainDir.js +++ /dev/null @@ -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); -} diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index d04411e..ed2cae2 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -1,247 +1,14 @@ 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 getMalwareAction() { + const action = cliArguments.getMalwareAction(); -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; + if (action === MALWARE_ACTION_PROMPT) { + return MALWARE_ACTION_PROMPT; } - // Priority 2: Environment variable - const envLevel = environmentVariables.getLoggingLevel()?.toLowerCase(); - if (envLevel === LOGGING_SILENT || envLevel === LOGGING_VERBOSE) { - return envLevel; - } - - return LOGGING_NORMAL; + return MALWARE_ACTION_BLOCK; } -export const ECOSYSTEM_JS = "js"; -export const ECOSYSTEM_PY = "py"; - -// Default to JavaScript ecosystem -const ecosystemSettings = { - ecoSystem: ECOSYSTEM_JS, -}; - -/** @returns {string} - The current ecosystem setting (ECOSYSTEM_JS or ECOSYSTEM_PY) */ -export function getEcoSystem() { - return ecosystemSettings.ecoSystem; -} -/** - * @param {string} setting - The ecosystem to set (ECOSYSTEM_JS or ECOSYSTEM_PY) - */ -export function setEcoSystem(setting) { - ecosystemSettings.ecoSystem = setting; -} - -const defaultMinimumPackageAge = 48; -/** @returns {number} */ -export function getMinimumPackageAgeHours() { - // Priority 1: CLI argument - const cliValue = validateMinimumPackageAgeHours( - cliArguments.getMinimumPackageAgeHours() - ); - if (cliValue !== undefined) { - return cliValue; - } - - // Priority 2: Environment variable - const envValue = validateMinimumPackageAgeHours( - environmentVariables.getMinimumPackageAgeHours() - ); - if (envValue !== undefined) { - return envValue; - } - - // Priority 3: Config file - const configValue = configFile.getMinimumPackageAgeHours(); - if (configValue !== undefined) { - return configValue; - } - - return defaultMinimumPackageAge; -} - -/** - * @param {string | undefined} value - * @returns {number | undefined} - */ -function validateMinimumPackageAgeHours(value) { - if (!value) { - return undefined; - } - - const numericValue = Number(value); - if (Number.isNaN(numericValue)) { - return undefined; - } - - if (numericValue >= 0) { - return numericValue; - } - - return undefined; -} - -const defaultSkipMinimumPackageAge = false; -export function skipMinimumPackageAge() { - const cliValue = cliArguments.getSkipMinimumPackageAge(); - - if (cliValue === true) { - return true; - } - - 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(/\/+$/, ""); -} +export const MALWARE_ACTION_BLOCK = "block"; +export const MALWARE_ACTION_PROMPT = "prompt"; diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js deleted file mode 100644 index 48108c4..0000000 --- a/packages/safe-chain/src/config/settings.spec.js +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/safe-chain/src/environment/userInteraction.js b/packages/safe-chain/src/environment/userInteraction.js index 9115b58..5b1cb88 100644 --- a/packages/safe-chain/src/environment/userInteraction.js +++ b/packages/safe-chain/src/environment/userInteraction.js @@ -1,122 +1,96 @@ -// oxlint-disable no-console import chalk from "chalk"; +import ora from "ora"; +import { createInterface } from "readline"; import { isCi } from "./environment.js"; -import { - getLoggingLevel, - LOGGING_SILENT, - LOGGING_VERBOSE, -} from "../config/settings.js"; - -/** - * @type {{ bufferOutput: boolean, bufferedMessages:(() => void)[]}} - */ -const state = { - bufferOutput: false, - bufferedMessages: [], -}; - -function isSilentMode() { - return getLoggingLevel() === LOGGING_SILENT; -} - -function isVerboseMode() { - return getLoggingLevel() === LOGGING_VERBOSE; -} function emptyLine() { - if (isSilentMode()) return; - writeInformation(""); } -/** - * @param {string} message - * @param {...any} optionalParams - * @returns {void} - */ function writeInformation(message, ...optionalParams) { - if (isSilentMode()) return; - - writeOrBuffer(() => console.log(message, ...optionalParams)); + console.log(message, ...optionalParams); } -/** - * @param {string} message - * @param {...any} optionalParams - * @returns {void} - */ function writeWarning(message, ...optionalParams) { - if (isSilentMode()) return; - if (!isCi()) { message = chalk.yellow(message); } - writeOrBuffer(() => console.warn(message, ...optionalParams)); + console.warn(message, ...optionalParams); } -/** - * @param {string} message - * @param {...any} optionalParams - * @returns {void} - */ function writeError(message, ...optionalParams) { if (!isCi()) { message = chalk.red(message); } - writeOrBuffer(() => console.error(message, ...optionalParams)); + console.error(message, ...optionalParams); } -function writeExitWithoutInstallingMaliciousPackages() { - let message = "Safe-chain: Exiting without installing malicious packages."; - if (!isCi()) { - message = chalk.red(message); - } - writeOrBuffer(() => console.error(message)); -} - -/** - * @param {string} message - * @param {...any} optionalParams - * @returns {void} - */ -function writeVerbose(message, ...optionalParams) { - if (!isVerboseMode()) return; - - writeOrBuffer(() => console.log(message, ...optionalParams)); -} - -/** - * - * @param {() => void} messageFunction - */ -function writeOrBuffer(messageFunction) { - if (state.bufferOutput) { - state.bufferedMessages.push(messageFunction); +function startProcess(message) { + if (isCi()) { + return { + succeed: (message) => { + writeInformation(message); + }, + fail: (message) => { + writeError(message); + }, + stop: () => {}, + setText: (message) => { + writeInformation(message); + }, + }; } else { - messageFunction(); + const spinner = ora(message).start(); + return { + succeed: (message) => { + spinner.succeed(message); + }, + fail: (message) => { + spinner.fail(message); + }, + stop: () => { + spinner.stop(); + }, + setText: (message) => { + spinner.text = message; + }, + }; } } -function startBufferingLogs() { - state.bufferOutput = true; - state.bufferedMessages = []; -} - -function writeBufferedLogsAndStopBuffering() { - state.bufferOutput = false; - for (const log of state.bufferedMessages) { - log(); +async function confirm(config) { + if (isCi()) { + return Promise.resolve(config.default); } - state.bufferedMessages = []; + + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + const defaultText = config.default ? " (Y/n)" : " (y/N)"; + rl.question(`${config.message}${defaultText} `, (answer) => { + rl.close(); + + const normalizedAnswer = answer.trim().toLowerCase(); + + if (normalizedAnswer === "y" || normalizedAnswer === "yes") { + resolve(true); + } else if (normalizedAnswer === "n" || normalizedAnswer === "no") { + resolve(false); + } else { + resolve(config.default); + } + }); + }); } export const ui = { - writeVerbose, writeInformation, writeWarning, writeError, - writeExitWithoutInstallingMaliciousPackages, emptyLine, - startBufferingLogs, - writeBufferedLogsAndStopBuffering, + startProcess, + confirm, }; diff --git a/packages/safe-chain/src/installLocation.js b/packages/safe-chain/src/installLocation.js deleted file mode 100644 index 52125be..0000000 --- a/packages/safe-chain/src/installLocation.js +++ /dev/null @@ -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, - ); -} diff --git a/packages/safe-chain/src/installLocation.spec.js b/packages/safe-chain/src/installLocation.spec.js deleted file mode 100644 index 558a05f..0000000 --- a/packages/safe-chain/src/installLocation.spec.js +++ /dev/null @@ -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", - ); - }); -}); diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 74f8a25..e106e83 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -6,40 +6,11 @@ import { getPackageManager } from "./packagemanager/currentPackageManager.js"; import { initializeCliArguments } from "./config/cliArguments.js"; import { createSafeChainProxy } from "./registryProxy/registryProxy.js"; import chalk from "chalk"; -import { getAuditStats } from "./scanning/audit/index.js"; -/** - * @param {string[]} args - * @returns {Promise} - */ export async function main(args) { - if (isSafeChainVerify(args)) { - return 0; - } - - process.on("SIGINT", handleProcessTermination); - process.on("SIGTERM", handleProcessTermination); - const proxy = createSafeChainProxy(); await proxy.startServer(); - // Global error handlers to log unhandled errors - process.on("uncaughtException", (error) => { - ui.writeError(`Safe-chain: Uncaught exception: ${error.message}`); - ui.writeVerbose(`Stack trace: ${error.stack}`); - ui.writeBufferedLogsAndStopBuffering(); - process.exit(1); - }); - - process.on("unhandledRejection", (reason) => { - ui.writeError(`Safe-chain: Unhandled promise rejection: ${reason}`); - if (reason instanceof Error) { - ui.writeVerbose(`Stack trace: ${reason.stack}`); - } - ui.writeBufferedLogsAndStopBuffering(); - process.exit(1); - }); - try { // This parses all the --safe-chain arguments and removes them from the args array args = initializeCliArguments(args); @@ -54,52 +25,24 @@ export async function main(args) { } } - // Buffer logs during package manager execution, this avoids interleaving - // of logs from the package manager and safe-chain - // Not doing this could cause bugs to disappear when cursor movement codes - // are written by the package manager while safe-chain is writing logs - ui.startBufferingLogs(); const packageManagerResult = await getPackageManager().runCommand(args); - // Write all buffered logs - ui.writeBufferedLogsAndStopBuffering(); - - if (proxy.hasBlockedMaliciousPackages()) { + if (!proxy.verifyNoMaliciousPackages()) { return 1; } - if (proxy.hasBlockedMinimumAgeRequests()) { - return 1; - } - - const auditStats = getAuditStats(); - if (auditStats.totalPackages > 0) { - ui.writeVerbose( - `${chalk.green("✔")} Safe-chain: Scanned ${ - auditStats.totalPackages - } 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.`, - ); - ui.writeInformation( - ` To disable this check, use: ${chalk.cyan( - "--safe-chain-skip-minimum-package-age", - )}`, - ); - } + ui.emptyLine(); + ui.writeInformation( + `${chalk.green( + "✔" + )} Safe-chain: Command completed, no malicious packages found.` + ); // Returning the exit code back to the caller allows the promise // to be awaited in the bin files and return the correct exit code return packageManagerResult.status; - } catch (/** @type any */ error) { + } catch (error) { ui.writeError("Failed to check for malicious packages:", error.message); - ui.writeBufferedLogsAndStopBuffering(); // Returning the exit code back to the caller allows the promise // to be awaited in the bin files and return the correct exit code @@ -108,16 +51,3 @@ export async function main(args) { await proxy.stopServer(); } } - -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; - } -} diff --git a/packages/safe-chain/src/packagemanager/_shared/commandErrors.js b/packages/safe-chain/src/packagemanager/_shared/commandErrors.js deleted file mode 100644 index bee68e4..0000000 --- a/packages/safe-chain/src/packagemanager/_shared/commandErrors.js +++ /dev/null @@ -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 }; -} diff --git a/packages/safe-chain/src/packagemanager/_shared/commandErrors.spec.js b/packages/safe-chain/src/packagemanager/_shared/commandErrors.spec.js deleted file mode 100644 index 350228a..0000000 --- a/packages/safe-chain/src/packagemanager/_shared/commandErrors.spec.js +++ /dev/null @@ -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?", - ]); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/_shared/matchesCommand.js b/packages/safe-chain/src/packagemanager/_shared/matchesCommand.js index f939352..d72caca 100644 --- a/packages/safe-chain/src/packagemanager/_shared/matchesCommand.js +++ b/packages/safe-chain/src/packagemanager/_shared/matchesCommand.js @@ -1,8 +1,3 @@ -/** - * @param {string[]} args - * @param {...string} commandArgs - * @returns {boolean} - */ export function matchesCommand(args, ...commandArgs) { if (args.length < commandArgs.length) { return false; diff --git a/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js b/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js index a9279b9..14faa5f 100644 --- a/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js +++ b/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js @@ -1,10 +1,7 @@ +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} - */ export function createBunPackageManager() { return { runCommand: (args) => runBunCommand("bun", args), @@ -16,9 +13,6 @@ export function createBunPackageManager() { }; } -/** - * @returns {import("../currentPackageManager.js").PackageManager} - */ export function createBunxPackageManager() { return { runCommand: (args) => runBunCommand("bunx", args), @@ -30,11 +24,6 @@ export function createBunxPackageManager() { }; } -/** - * @param {string} command - * @param {string[]} args - * @returns {Promise<{status: number}>} - */ async function runBunCommand(command, args) { try { const result = await safeSpawn(command, args, { @@ -42,7 +31,12 @@ async function runBunCommand(command, args) { env: mergeSafeChainProxyEnvironmentVariables(process.env), }); return { status: result.status }; - } catch (/** @type any */ error) { - return reportCommandExecutionFailure(error, command); + } catch (error) { + if (error.status) { + return { status: error.status }; + } else { + ui.writeError("Error executing command:", error.message); + return { status: 1 }; + } } } diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index bf91d88..2a10d86 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -9,45 +9,14 @@ import { createPnpxPackageManager, } from "./pnpm/createPackageManager.js"; 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}} - */ const state = { packageManagerName: null, }; -/** - * @typedef {Object} GetDependencyUpdatesResult - * @property {string} name - * @property {string} version - * @property {string} type - */ - -/** - * @typedef {Object} PackageManager - * @property {(args: string[]) => Promise<{ status: number }>} runCommand - * @property {(args: string[]) => boolean} isSupportedCommand - * @property {(args: string[]) => Promise | GetDependencyUpdatesResult[]} getDependencyUpdatesForCommand - */ - -/** - * @param {string} packageManagerName - * @param {{ tool: string, args: string[] }} [context] - Optional tool context for package managers like pip - * - * @return {PackageManager} - */ -export function initializePackageManager(packageManagerName, context) { +export function initializePackageManager(packageManagerName, version) { if (packageManagerName === "npm") { - state.packageManagerName = createNpmPackageManager(); + state.packageManagerName = createNpmPackageManager(version); } else if (packageManagerName === "npx") { state.packageManagerName = createNpxPackageManager(); } else if (packageManagerName === "yarn") { @@ -60,22 +29,6 @@ export function initializePackageManager(packageManagerName, context) { state.packageManagerName = createBunPackageManager(); } else if (packageManagerName === "bunx") { state.packageManagerName = createBunxPackageManager(); - } else if (packageManagerName === "pip") { - 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); } diff --git a/packages/safe-chain/src/packagemanager/npm/createPackageManager.js b/packages/safe-chain/src/packagemanager/npm/createPackageManager.js index fa72276..bf38209 100644 --- a/packages/safe-chain/src/packagemanager/npm/createPackageManager.js +++ b/packages/safe-chain/src/packagemanager/npm/createPackageManager.js @@ -1,40 +1,34 @@ import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js"; +import { dryRunScanner } from "./dependencyScanner/dryRunScanner.js"; import { nullScanner } from "./dependencyScanner/nullScanner.js"; import { runNpm } from "./runNpmCommand.js"; import { getNpmCommandForArgs, npmInstallCommand, + npmCiCommand, + npmInstallTestCommand, + npmInstallCiTestCommand, npmUpdateCommand, + npmAuditCommand, npmExecCommand, } from "./utils/npmCommands.js"; -/** - * @returns {import("../currentPackageManager.js").PackageManager} - */ -export function createNpmPackageManager() { - /** - * @param {string[]} args - * - * @returns {boolean} - */ +export function createNpmPackageManager(version) { + // From npm v10.4.0 onwards, the npm commands output detailed information + // when using the --dry-run flag. + // We use that information to scan for dependency changes. + // For older versions of npm we have to rely on parsing the command arguments. + const supportedScanners = isPriorToNpm10_4(version) + ? npm10_3AndBelowSupportedScanners + : npm10_4AndAboveSupportedScanners; + function isSupportedCommand(args) { - const scanner = findDependencyScannerForCommand( - commandScannerMapping, - args - ); + const scanner = findDependencyScannerForCommand(supportedScanners, args); return scanner.shouldScan(args); } - /** - * @param {string[]} args - * - * @returns {ReturnType} - */ function getDependencyUpdatesForCommand(args) { - const scanner = findDependencyScannerForCommand( - commandScannerMapping, - args - ); + const scanner = findDependencyScannerForCommand(supportedScanners, args); return scanner.scan(args); } @@ -45,22 +39,40 @@ export function createNpmPackageManager() { }; } -/** - * @type {Record} - */ -const commandScannerMapping = { +const npm10_4AndAboveSupportedScanners = { + [npmInstallCommand]: dryRunScanner(), + [npmUpdateCommand]: dryRunScanner(), + [npmCiCommand]: dryRunScanner(), + [npmAuditCommand]: dryRunScanner({ + skipScanWhen: (args) => !args.includes("fix"), + }), + [npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run + + // Running dry-run on install-test and install-ci-test will install & run tests. + // We only want to know if there are changes in the dependencies. + // So we run change the dry-run command to only check the install. + [npmInstallTestCommand]: dryRunScanner({ dryRunCommand: npmInstallCommand }), + [npmInstallCiTestCommand]: dryRunScanner({ dryRunCommand: npmCiCommand }), +}; + +const npm10_3AndBelowSupportedScanners = { [npmInstallCommand]: commandArgumentScanner(), [npmUpdateCommand]: commandArgumentScanner(), [npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run }; -/** - * - * @param {Record} scanners - * @param {string[]} args - * - * @returns {import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner} - */ +function isPriorToNpm10_4(version) { + try { + const [major, minor] = version.split(".").map(Number); + if (major < 10) return true; + if (major === 10 && minor < 4) return true; + return false; + } catch { + // Default to true: if version parsing fails, assume it's an older version + return true; + } +} + function findDependencyScannerForCommand(scanners, args) { const command = getNpmCommandForArgs(args); if (!command) { diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/commandArgumentScanner.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/commandArgumentScanner.js index c4f6bb6..ae05f6d 100644 --- a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/commandArgumentScanner.js +++ b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/commandArgumentScanner.js @@ -2,29 +2,6 @@ import { resolvePackageVersion } from "../../../api/npmApi.js"; import { parsePackagesFromInstallArgs } from "../parsing/parsePackagesFromInstallArgs.js"; import { hasDryRunArg } from "../utils/npmCommands.js"; -/** - * @typedef {Object} ScanResult - * @property {string} name - * @property {string} version - * @property {string} type - */ - -/** - * @typedef {Object} ScannerOptions - * @property {boolean} [ignoreDryRun] - */ - -/** - * @typedef {Object} CommandArgumentScanner - * @property {(args: string[]) => Promise | ScanResult[]} scan - * @property {(args: string[]) => boolean} shouldScan - */ - -/** - * @param {ScannerOptions} [opts] - * - * @returns {CommandArgumentScanner} - */ export function commandArgumentScanner(opts) { const ignoreDryRun = opts?.ignoreDryRun ?? false; @@ -33,28 +10,14 @@ export function commandArgumentScanner(opts) { shouldScan: (args) => shouldScanDependencies(args, ignoreDryRun), }; } - -/** - * @param {string[]} args - * @returns {Promise} - */ function scanDependencies(args) { return checkChangesFromArgs(args); } -/** - * @param {string[]} args - * @param {boolean} ignoreDryRun - * @returns {boolean} - */ function shouldScanDependencies(args, ignoreDryRun) { return ignoreDryRun || !hasDryRunArg(args); } -/** - * @param {string[]} args - * @returns {Promise} - */ export async function checkChangesFromArgs(args) { const changes = []; const packageUpdates = parsePackagesFromInstallArgs(args); diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js new file mode 100644 index 0000000..6189b2f --- /dev/null +++ b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js @@ -0,0 +1,67 @@ +import { parseDryRunOutput } from "../parsing/parseNpmInstallDryRunOutput.js"; +import { dryRunNpmCommandAndOutput } from "../runNpmCommand.js"; +import { hasDryRunArg } from "../utils/npmCommands.js"; + +export function dryRunScanner(scannerOptions) { + return { + scan: (args) => scanDependencies(scannerOptions, args), + shouldScan: (args) => shouldScanDependencies(scannerOptions, args), + }; +} + +function scanDependencies(scannerOptions, args) { + let dryRunArgs = args; + + if (scannerOptions?.dryRunCommand) { + // Replace the first argument with the dryRunCommand (eg: "install" instead of "install-test") + dryRunArgs = [scannerOptions.dryRunCommand, ...args.slice(1)]; + } + + return checkChangesWithDryRun(dryRunArgs); +} + +function shouldScanDependencies(scannerOptions, args) { + if (hasDryRunArg(args)) { + return false; + } + + if (scannerOptions?.skipScanWhen && scannerOptions.skipScanWhen(args)) { + return false; + } + + return true; +} + +async function checkChangesWithDryRun(args) { + const dryRunOutput = await dryRunNpmCommandAndOutput(args); + + // Dry-run can return a non-zero status code in some cases + // e.g., when running "npm audit fix --dry-run", it returns exit code 1 + // when there are vulnerabilities that can be fixed. + if (dryRunOutput.status !== 0 && !canCommandReturnNonZeroOnSuccess(args)) { + throw new Error( + `Dry-run command failed with exit code ${dryRunOutput.status} and output:\n${dryRunOutput.output}` + ); + } + + if (dryRunOutput.status !== 0 && !dryRunOutput.output) { + throw new Error( + `Dry-run command failed with exit code ${dryRunOutput.status} and produced no output.` + ); + } + + const parsedOutput = parseDryRunOutput(dryRunOutput.output); + + // reverse the array to have the top-level packages first + return parsedOutput.reverse(); +} + +function canCommandReturnNonZeroOnSuccess(args) { + if (args.length < 2) { + return false; + } + + // `npm audit fix --dry-run` can return exit code 1 when it succesfully ran and + // there were vulnerabilities that could be fixed + return args[0] === "audit" && args[1] === "fix"; +} diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js new file mode 100644 index 0000000..88d7681 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js @@ -0,0 +1,139 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert/strict"; + +describe("dryRunScanner", async () => { + const mockWriteError = mock.fn(); + const mockDryRunNpmCommandAndOutput = mock.fn(); + + // Mock ui module + mock.module("../../../environment/userInteraction.js", { + namedExports: { + ui: { + writeError: mockWriteError, + }, + }, + }); + + // Mock dryRunNpmCommandAndOutput function + mock.module("../runNpmCommand.js", { + namedExports: { + dryRunNpmCommandAndOutput: mockDryRunNpmCommandAndOutput, + }, + }); + + const { dryRunScanner } = await import("./dryRunScanner.js"); + + describe("doesCommandReturnNonZero", () => { + // We need to access the internal function for testing + // Since it's not exported, we'll test it indirectly through the main functionality + + it("should handle npm audit fix commands that return non-zero", async () => { + mockDryRunNpmCommandAndOutput.mock.resetCalls(); + mockWriteError.mock.resetCalls(); + mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({ + status: 1, + output: "found 5 vulnerabilities that can be fixed", + })); + + const scanner = dryRunScanner(); + const result = await scanner.scan(["audit", "fix"]); + + // Should not throw an error for audit fix commands + assert.ok(Array.isArray(result)); + assert.equal(mockWriteError.mock.callCount(), 0); + }); + + it("should throw error for unexpected non-zero exit codes", async () => { + mockDryRunNpmCommandAndOutput.mock.resetCalls(); + mockWriteError.mock.resetCalls(); + mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({ + status: 1, + output: "some error output", + })); + + const scanner = dryRunScanner(); + + await assert.rejects(async () => { + await scanner.scan(["install", "lodash"]); + }, /Dry-run command failed with exit code 1/); + }); + + it("should handle zero exit codes normally", async () => { + mockDryRunNpmCommandAndOutput.mock.resetCalls(); + mockWriteError.mock.resetCalls(); + mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({ + status: 0, + output: "added 1 package", + })); + + const scanner = dryRunScanner(); + const result = await scanner.scan(["install", "lodash"]); + + assert.ok(Array.isArray(result)); + assert.equal(mockWriteError.mock.callCount(), 0); + }); + + it("should throw error for non-zero exit with no output for audit fix", async () => { + mockDryRunNpmCommandAndOutput.mock.resetCalls(); + mockWriteError.mock.resetCalls(); + mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({ + status: 1, + output: "", + })); + + const scanner = dryRunScanner(); + + await assert.rejects(async () => { + await scanner.scan(["audit", "fix"]); + }, /Dry-run command failed with exit code 1/); + }); + }); + + describe("scanner functionality", () => { + it("should use dryRunCommand option when provided", async () => { + mockDryRunNpmCommandAndOutput.mock.resetCalls(); + mockWriteError.mock.resetCalls(); + mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({ + status: 0, + output: "no changes", + })); + + const scanner = dryRunScanner({ dryRunCommand: "install" }); + await scanner.scan(["install-test", "lodash"]); + + // Should call with "install" instead of "install-test" + assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 1); + const calledArgs = + mockDryRunNpmCommandAndOutput.mock.calls[0].arguments[0]; + assert.deepEqual(calledArgs, ["install", "lodash"]); + }); + + it("should skip scanning when hasDryRunArg returns true", async () => { + mockDryRunNpmCommandAndOutput.mock.resetCalls(); + mockWriteError.mock.resetCalls(); + + const scanner = dryRunScanner(); + const shouldScan = scanner.shouldScan(["install", "--dry-run"]); + + assert.equal(shouldScan, false); + // Should not call dryRunNpmCommandAndOutput since scanning is skipped + assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 0); + }); + + it("should skip scanning when skipScanWhen returns true", async () => { + const scanner = dryRunScanner({ + skipScanWhen: (args) => args.includes("--skip"), + }); + const shouldScan = scanner.shouldScan(["install", "--skip"]); + + assert.equal(shouldScan, false); + }); + + it("should scan when conditions are met", async () => { + const scanner = dryRunScanner(); + const shouldScan = scanner.shouldScan(["install", "lodash"]); + + assert.equal(shouldScan, true); + }); + }); +}); diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/nullScanner.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/nullScanner.js index 5c1d3bd..a7b2ffd 100644 --- a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/nullScanner.js +++ b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/nullScanner.js @@ -1,6 +1,3 @@ -/** - * @returns {import("./commandArgumentScanner.js").CommandArgumentScanner} - */ export function nullScanner() { return { scan: () => [], diff --git a/packages/safe-chain/src/packagemanager/npm/parsing/parseNpmInstallDryRunOutput.js b/packages/safe-chain/src/packagemanager/npm/parsing/parseNpmInstallDryRunOutput.js new file mode 100644 index 0000000..3c1e673 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/npm/parsing/parseNpmInstallDryRunOutput.js @@ -0,0 +1,57 @@ +export function parseDryRunOutput(output) { + const lines = output.split(/\r?\n/); + const packageChanges = []; + + for (const line of lines) { + if (line.startsWith("add ")) { + packageChanges.push(parseAdd(line)); + } else if (line.startsWith("remove ")) { + packageChanges.push(parseRemove(line)); + } else if (line.startsWith("change ")) { + packageChanges.push(parseChange(line)); + } + } + + return packageChanges; +} + +function parseAdd(line) { + const splitLine = getLineParts(line); + const packageName = splitLine[1]; + const packageVersion = splitLine[splitLine.length - 1]; + return addedPackage(packageName, packageVersion); +} + +function addedPackage(name, version) { + return { type: "add", name, version }; +} + +function parseRemove(line) { + const splitLine = getLineParts(line); + const packageName = splitLine[1]; + const packageVersion = splitLine[splitLine.length - 1]; + return removedPackage(packageName, packageVersion); +} + +function removedPackage(name, version) { + return { type: "remove", name, version }; +} + +function parseChange(line) { + const splitLine = getLineParts(line); + const packageName = splitLine[1]; + const packageVersion = splitLine[splitLine.length - 1]; + const oldVersion = splitLine[2]; + return changedPackage(packageName, packageVersion, oldVersion); +} + +function getLineParts(line) { + return line + .split(" ") + .map((part) => part.trim()) + .filter((part) => part !== ""); +} + +function changedPackage(name, version, oldVersion) { + return { type: "change", name, version, oldVersion }; +} diff --git a/packages/safe-chain/src/packagemanager/npm/parsing/parseNpmInstallDryRunOutput.spec.js b/packages/safe-chain/src/packagemanager/npm/parsing/parseNpmInstallDryRunOutput.spec.js new file mode 100644 index 0000000..cd7c2b1 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/npm/parsing/parseNpmInstallDryRunOutput.spec.js @@ -0,0 +1,134 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { parseDryRunOutput } from "./parseNpmInstallDryRunOutput.js"; + +describe("parseNpmInstallDryRunOutput", () => { + it("should parse added packages", () => { + const output = ` +add @jest/transform 29.7.0 +add @jest/test-result 29.7.0 +add @jest/reporters 29.7.0 +add @jest/console 29.7.0 +add jest-cli 29.7.0 +add import-local 3.2.0 +add @jest/types 29.6.3 +add @jest/core 29.7.0 +add jest 29.7.0 + +added 267 packages in 831ms + +32 packages are looking for funding + run \`npm fund\` for details`; + + const expected = [ + { name: "@jest/transform", version: "29.7.0", type: "add" }, + { name: "@jest/test-result", version: "29.7.0", type: "add" }, + { name: "@jest/reporters", version: "29.7.0", type: "add" }, + { name: "@jest/console", version: "29.7.0", type: "add" }, + { name: "jest-cli", version: "29.7.0", type: "add" }, + { name: "import-local", version: "3.2.0", type: "add" }, + { name: "@jest/types", version: "29.6.3", type: "add" }, + { name: "@jest/core", version: "29.7.0", type: "add" }, + { name: "jest", version: "29.7.0", type: "add" }, + ]; + + const result = parseDryRunOutput(output); + + assert.deepEqual(result, expected); + }); + + it("should parse removed packages", () => { + const output = ` +remove react 19.1.0 + + removed 1 package in 115ms`; + + const expected = [{ name: "react", version: "19.1.0", type: "remove" }]; + + const result = parseDryRunOutput(output); + + assert.deepEqual(result, expected); + }); + + it("should parse changed packages", () => { + const output = ` +change react 19.0.0 => 19.1.0 + +changed 1 package in 204ms`; + + const expected = [ + { + name: "react", + version: "19.1.0", + oldVersion: "19.0.0", + type: "change", + }, + ]; + + const result = parseDryRunOutput(output); + + assert.deepEqual(result, expected); + }); + + it("should parse mixed package changes", () => { + const output = ` +add @jest/transform 29.7.0 +add @jest/test-result 29.7.0 +add @jest/reporters 29.7.0 +add @jest/console 29.7.0 +add jest-cli 29.7.0 +add import-local 3.2.0 +add @jest/types 29.6.3 +add @jest/core 29.7.0 +add jest 29.7.0 +remove react 19.1.0 +change lodash 4.17.0 => 4.18.0 + +removed 1 package in 115ms`; + + const expected = [ + { name: "@jest/transform", version: "29.7.0", type: "add" }, + { name: "@jest/test-result", version: "29.7.0", type: "add" }, + { name: "@jest/reporters", version: "29.7.0", type: "add" }, + { name: "@jest/console", version: "29.7.0", type: "add" }, + { name: "jest-cli", version: "29.7.0", type: "add" }, + { name: "import-local", version: "3.2.0", type: "add" }, + { name: "@jest/types", version: "29.6.3", type: "add" }, + { name: "@jest/core", version: "29.7.0", type: "add" }, + { name: "jest", version: "29.7.0", type: "add" }, + { name: "react", version: "19.1.0", type: "remove" }, + { + name: "lodash", + version: "4.18.0", + oldVersion: "4.17.0", + type: "change", + }, + ]; + + const result = parseDryRunOutput(output); + + assert.deepEqual(result, expected); + }); + + it("should work with npm v22.0.0", () => { + const output = ` +add @jest/types 29.6.3 +add @jest/core 29.7.0 +add jest 29.7.0 + +added 257 packages in 791ms + +44 packages are looking for funding + run \`npm fund\` for details`; + + const expected = [ + { name: "@jest/types", version: "29.6.3", type: "add" }, + { name: "@jest/core", version: "29.7.0", type: "add" }, + { name: "jest", version: "29.7.0", type: "add" }, + ]; + + const result = parseDryRunOutput(output); + + assert.deepEqual(result, expected); + }); +}); diff --git a/packages/safe-chain/src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js b/packages/safe-chain/src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js index b7277e7..e731240 100644 --- a/packages/safe-chain/src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js +++ b/packages/safe-chain/src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js @@ -1,22 +1,5 @@ -/** - * @typedef {Object} PackageDetail - * @property {string} name - * @property {string} version - */ - -/** - * @typedef {Object} NpmOption - * @property {string} name - * @property {number} numberOfParameters - */ - -/** - * @param {string[]} args - * @returns {PackageDetail[]} - */ export function parsePackagesFromInstallArgs(args) { - /** @type {{name: string, version: string | null}[]} */ - const changes = []; + const changes = []; let defaultTag = "latest"; // Skip first argument (install command) @@ -49,13 +32,9 @@ export function parsePackagesFromInstallArgs(args) { } } - return /** @type {PackageDetail[]} */ (changes); + return changes; } -/** - * @param {string} arg - * @returns {NpmOption | undefined} - */ function getNpmOption(arg) { if (isNpmOptionWithParameter(arg)) { return { @@ -75,10 +54,6 @@ function getNpmOption(arg) { return undefined; } -/** - * @param {string} arg - * @returns {boolean} - */ function isNpmOptionWithParameter(arg) { const optionsWithParameters = [ "--access", @@ -106,10 +81,6 @@ function isNpmOptionWithParameter(arg) { return optionsWithParameters.includes(arg); } -/** - * @param {string} arg - * @returns {{name: string, version: string | null}} - */ function parsePackagename(arg) { arg = removeAlias(arg); const lastAtIndex = arg.lastIndexOf("@"); @@ -131,10 +102,6 @@ function parsePackagename(arg) { }; } -/** - * @param {string} arg - * @returns {string} - */ function removeAlias(arg) { const aliasIndex = arg.indexOf("@npm:"); if (aliasIndex !== -1) { diff --git a/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js b/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js index 2622afc..26a4a9d 100644 --- a/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js +++ b/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js @@ -1,12 +1,7 @@ +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 - * - * @returns {Promise<{status: number}>} - */ export async function runNpm(args) { try { const result = await safeSpawn("npm", args, { @@ -14,7 +9,41 @@ export async function runNpm(args) { env: mergeSafeChainProxyEnvironmentVariables(process.env), }); return { status: result.status }; - } catch (/** @type any */ error) { - return reportCommandExecutionFailure(error, "npm"); + } catch (error) { + if (error.status) { + return { status: error.status }; + } else { + ui.writeError("Error executing command:", error.message); + return { status: 1 }; + } + } +} + +export async function dryRunNpmCommandAndOutput(args) { + try { + const result = await safeSpawn( + "npm", + [...args, "--ignore-scripts", "--dry-run"], + { + stdio: "pipe", + env: mergeSafeChainProxyEnvironmentVariables(process.env), + } + ); + return { + status: result.status, + output: result.status === 0 ? result.stdout : result.stderr, + }; + } catch (error) { + if (error.status) { + const output = + error.stdout?.toString() ?? + error.stderr?.toString() ?? + error.message ?? + ""; + return { status: error.status, output }; + } else { + ui.writeError("Error executing command:", error.message); + return { status: 1 }; + } } } diff --git a/packages/safe-chain/src/packagemanager/npm/utils/abbrevs-generated.js b/packages/safe-chain/src/packagemanager/npm/utils/abbrevs-generated.js deleted file mode 100644 index 8e76ad1..0000000 --- a/packages/safe-chain/src/packagemanager/npm/utils/abbrevs-generated.js +++ /dev/null @@ -1,359 +0,0 @@ -// This was ran with the abbrev package to generate the abbrevs object below -// console.log(abbrev(commands.concat(Object.keys(aliases)))); -/** @type {Record} */ -export const abbrevs = { - ac: "access", - acc: "access", - acce: "access", - acces: "access", - access: "access", - add: "add", - "add-": "add-user", - "add-u": "add-user", - "add-us": "add-user", - "add-use": "add-user", - "add-user": "add-user", - addu: "adduser", - addus: "adduser", - adduse: "adduser", - adduser: "adduser", - aud: "audit", - audi: "audit", - audit: "audit", - aut: "author", - auth: "author", - autho: "author", - author: "author", - b: "bugs", - bu: "bugs", - bug: "bugs", - bugs: "bugs", - c: "c", - ca: "cache", - cac: "cache", - cach: "cache", - cache: "cache", - ci: "ci", - cit: "cit", - "clean-install": "clean-install", - "clean-install-": "clean-install-test", - "clean-install-t": "clean-install-test", - "clean-install-te": "clean-install-test", - "clean-install-tes": "clean-install-test", - "clean-install-test": "clean-install-test", - com: "completion", - comp: "completion", - compl: "completion", - comple: "completion", - complet: "completion", - completi: "completion", - completio: "completion", - completion: "completion", - con: "config", - conf: "config", - confi: "config", - config: "config", - cr: "create", - cre: "create", - crea: "create", - creat: "create", - create: "create", - dd: "ddp", - ddp: "ddp", - ded: "dedupe", - dedu: "dedupe", - dedup: "dedupe", - dedupe: "dedupe", - dep: "deprecate", - depr: "deprecate", - depre: "deprecate", - deprec: "deprecate", - depreca: "deprecate", - deprecat: "deprecate", - deprecate: "deprecate", - dif: "diff", - diff: "diff", - "dist-tag": "dist-tag", - "dist-tags": "dist-tags", - docs: "docs", - doct: "doctor", - docto: "doctor", - doctor: "doctor", - ed: "edit", - edi: "edit", - edit: "edit", - exe: "exec", - exec: "exec", - expla: "explain", - explai: "explain", - explain: "explain", - explo: "explore", - explor: "explore", - explore: "explore", - find: "find", - "find-": "find-dupes", - "find-d": "find-dupes", - "find-du": "find-dupes", - "find-dup": "find-dupes", - "find-dupe": "find-dupes", - "find-dupes": "find-dupes", - fu: "fund", - fun: "fund", - fund: "fund", - g: "get", - ge: "get", - get: "get", - help: "help", - "help-": "help-search", - "help-s": "help-search", - "help-se": "help-search", - "help-sea": "help-search", - "help-sear": "help-search", - "help-searc": "help-search", - "help-search": "help-search", - hl: "hlep", - hle: "hlep", - hlep: "hlep", - ho: "home", - hom: "home", - home: "home", - i: "i", - ic: "ic", - in: "in", - inf: "info", - info: "info", - ini: "init", - init: "init", - inn: "innit", - inni: "innit", - innit: "innit", - ins: "ins", - inst: "inst", - insta: "insta", - instal: "instal", - install: "install", - "install-ci": "install-ci-test", - "install-ci-": "install-ci-test", - "install-ci-t": "install-ci-test", - "install-ci-te": "install-ci-test", - "install-ci-tes": "install-ci-test", - "install-ci-test": "install-ci-test", - "install-cl": "install-clean", - "install-cle": "install-clean", - "install-clea": "install-clean", - "install-clean": "install-clean", - "install-t": "install-test", - "install-te": "install-test", - "install-tes": "install-test", - "install-test": "install-test", - isnt: "isnt", - isnta: "isnta", - isntal: "isntal", - isntall: "isntall", - "isntall-": "isntall-clean", - "isntall-c": "isntall-clean", - "isntall-cl": "isntall-clean", - "isntall-cle": "isntall-clean", - "isntall-clea": "isntall-clean", - "isntall-clean": "isntall-clean", - iss: "issues", - issu: "issues", - issue: "issues", - issues: "issues", - it: "it", - la: "la", - lin: "link", - link: "link", - lis: "list", - list: "list", - ll: "ll", - ln: "ln", - logi: "login", - login: "login", - logo: "logout", - logou: "logout", - logout: "logout", - ls: "ls", - og: "ogr", - ogr: "ogr", - or: "org", - org: "org", - ou: "outdated", - out: "outdated", - outd: "outdated", - outda: "outdated", - outdat: "outdated", - outdate: "outdated", - outdated: "outdated", - ow: "owner", - own: "owner", - owne: "owner", - owner: "owner", - pa: "pack", - pac: "pack", - pack: "pack", - pi: "ping", - pin: "ping", - ping: "ping", - pk: "pkg", - pkg: "pkg", - pre: "prefix", - pref: "prefix", - prefi: "prefix", - prefix: "prefix", - pro: "profile", - prof: "profile", - profi: "profile", - profil: "profile", - profile: "profile", - pru: "prune", - prun: "prune", - prune: "prune", - pu: "publish", - pub: "publish", - publ: "publish", - publi: "publish", - publis: "publish", - publish: "publish", - q: "query", - qu: "query", - que: "query", - quer: "query", - query: "query", - r: "r", - rb: "rb", - reb: "rebuild", - rebu: "rebuild", - rebui: "rebuild", - rebuil: "rebuild", - rebuild: "rebuild", - rem: "remove", - remo: "remove", - remov: "remove", - remove: "remove", - rep: "repo", - repo: "repo", - res: "restart", - rest: "restart", - resta: "restart", - restar: "restart", - restart: "restart", - rm: "rm", - ro: "root", - roo: "root", - root: "root", - rum: "rum", - run: "run", - "run-": "run-script", - "run-s": "run-script", - "run-sc": "run-script", - "run-scr": "run-script", - "run-scri": "run-script", - "run-scrip": "run-script", - "run-script": "run-script", - s: "s", - sb: "sbom", - sbo: "sbom", - sbom: "sbom", - se: "se", - sea: "search", - sear: "search", - searc: "search", - search: "search", - set: "set", - sho: "show", - show: "show", - shr: "shrinkwrap", - shri: "shrinkwrap", - shrin: "shrinkwrap", - shrink: "shrinkwrap", - shrinkw: "shrinkwrap", - shrinkwr: "shrinkwrap", - shrinkwra: "shrinkwrap", - shrinkwrap: "shrinkwrap", - si: "sit", - sit: "sit", - star: "star", - stars: "stars", - start: "start", - sto: "stop", - stop: "stop", - t: "t", - tea: "team", - team: "team", - tes: "test", - test: "test", - to: "token", - tok: "token", - toke: "token", - token: "token", - ts: "tst", - tst: "tst", - ud: "udpate", - udp: "udpate", - udpa: "udpate", - udpat: "udpate", - udpate: "udpate", - un: "un", - und: "undeprecate", - unde: "undeprecate", - undep: "undeprecate", - undepr: "undeprecate", - undepre: "undeprecate", - undeprec: "undeprecate", - undepreca: "undeprecate", - undeprecat: "undeprecate", - undeprecate: "undeprecate", - uni: "uninstall", - unin: "uninstall", - unins: "uninstall", - uninst: "uninstall", - uninsta: "uninstall", - uninstal: "uninstall", - uninstall: "uninstall", - unl: "unlink", - unli: "unlink", - unlin: "unlink", - unlink: "unlink", - unp: "unpublish", - unpu: "unpublish", - unpub: "unpublish", - unpubl: "unpublish", - unpubli: "unpublish", - unpublis: "unpublish", - unpublish: "unpublish", - uns: "unstar", - unst: "unstar", - unsta: "unstar", - unstar: "unstar", - up: "up", - upd: "update", - upda: "update", - updat: "update", - update: "update", - upg: "upgrade", - upgr: "upgrade", - upgra: "upgrade", - upgrad: "upgrade", - upgrade: "upgrade", - ur: "urn", - urn: "urn", - v: "v", - veri: "verison", - veris: "verison", - veriso: "verison", - verison: "verison", - vers: "version", - versi: "version", - versio: "version", - version: "version", - vi: "view", - vie: "view", - view: "view", - who: "whoami", - whoa: "whoami", - whoam: "whoami", - whoami: "whoami", - why: "why", - x: "x", -}; diff --git a/packages/safe-chain/src/packagemanager/npm/utils/cmd-list.js b/packages/safe-chain/src/packagemanager/npm/utils/cmd-list.js index 3bcdd0d..187204d 100644 --- a/packages/safe-chain/src/packagemanager/npm/utils/cmd-list.js +++ b/packages/safe-chain/src/packagemanager/npm/utils/cmd-list.js @@ -1,6 +1,6 @@ // Based on https://github.com/npm/cli/blob/latest/lib/utils/cmd-list.js -import { abbrevs } from "./abbrevs-generated.js"; +import abbrev from "abbrev"; const commands = [ "access", @@ -73,7 +73,6 @@ const commands = [ ]; // These must resolve to an entry in commands -/** @type {Record} */ const aliases = { // aliases author: "owner", @@ -139,10 +138,6 @@ const aliases = { "add-user": "adduser", }; -/** - * @param {string} c - * @returns {string | undefined} - */ export function deref(c) { if (!c) { return; @@ -163,6 +158,8 @@ export function deref(c) { return aliases[c]; } + const abbrevs = abbrev(commands.concat(Object.keys(aliases))); + // first deref the abbrev, if there is one // then resolve any aliases // so `npm install-cl` will resolve to `install-clean` then to `ci` diff --git a/packages/safe-chain/src/packagemanager/npm/utils/npmCommands.js b/packages/safe-chain/src/packagemanager/npm/utils/npmCommands.js index b645369..3096144 100644 --- a/packages/safe-chain/src/packagemanager/npm/utils/npmCommands.js +++ b/packages/safe-chain/src/packagemanager/npm/utils/npmCommands.js @@ -1,9 +1,5 @@ import { deref } from "./cmd-list.js"; -/** - * @param {string[]} args - * @returns {string | null} - */ export function getNpmCommandForArgs(args) { if (args.length === 0) { return null; @@ -17,10 +13,6 @@ export function getNpmCommandForArgs(args) { return argCommand; } -/** - * @param {string[]} args - * @returns {boolean} - */ export function hasDryRunArg(args) { return args.some((arg) => arg === "--dry-run"); } diff --git a/packages/safe-chain/src/packagemanager/npx/createPackageManager.js b/packages/safe-chain/src/packagemanager/npx/createPackageManager.js index 96d495b..a3319fa 100644 --- a/packages/safe-chain/src/packagemanager/npx/createPackageManager.js +++ b/packages/safe-chain/src/packagemanager/npx/createPackageManager.js @@ -1,9 +1,6 @@ import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js"; import { runNpx } from "./runNpxCommand.js"; -/** - * @returns {import("../currentPackageManager.js").PackageManager} - */ export function createNpxPackageManager() { const scanner = commandArgumentScanner(); diff --git a/packages/safe-chain/src/packagemanager/npx/dependencyScanner/commandArgumentScanner.js b/packages/safe-chain/src/packagemanager/npx/dependencyScanner/commandArgumentScanner.js index 689e3f8..16328cb 100644 --- a/packages/safe-chain/src/packagemanager/npx/dependencyScanner/commandArgumentScanner.js +++ b/packages/safe-chain/src/packagemanager/npx/dependencyScanner/commandArgumentScanner.js @@ -1,28 +1,16 @@ import { resolvePackageVersion } from "../../../api/npmApi.js"; import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js"; -/** - * @returns {import("../../npm/dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner} - */ export function commandArgumentScanner() { return { scan: (args) => scanDependencies(args), shouldScan: () => true, // all npx commands need to be scanned, npx doesn't have dry-run }; } - -/** - * @param {string[]} args - * @returns {Promise} - */ function scanDependencies(args) { return checkChangesFromArgs(args); } -/** - * @param {string[]} args - * @returns {Promise} - */ export async function checkChangesFromArgs(args) { const changes = []; const packageUpdates = parsePackagesFromArguments(args); diff --git a/packages/safe-chain/src/packagemanager/npx/parsing/parsePackagesFromArguments.js b/packages/safe-chain/src/packagemanager/npx/parsing/parsePackagesFromArguments.js index 25fb249..efc8d81 100644 --- a/packages/safe-chain/src/packagemanager/npx/parsing/parsePackagesFromArguments.js +++ b/packages/safe-chain/src/packagemanager/npx/parsing/parsePackagesFromArguments.js @@ -1,8 +1,3 @@ -/** - * @param {string[]} args - * - * @returns {{name: string, version: string}[]} - */ export function parsePackagesFromArguments(args) { let defaultTag = "latest"; @@ -26,10 +21,6 @@ export function parsePackagesFromArguments(args) { return []; } -/** - * @param {string} arg - * @returns {{name: string, numberOfParameters: number} | undefined} - */ function getOption(arg) { if (isOptionWithParameter(arg)) { return { @@ -50,10 +41,6 @@ function getOption(arg) { return undefined; } -/** - * @param {string} arg - * @returns {boolean} - */ function isOptionWithParameter(arg) { const optionsWithParameters = [ "--access", @@ -81,11 +68,6 @@ function isOptionWithParameter(arg) { return optionsWithParameters.includes(arg); } -/** - * @param {string} arg - * @param {string} defaultTag - * @returns {{name: string, version: string}} - */ function parsePackagename(arg, defaultTag) { // format can be --package=name@version // in that case, we need to remove the --package= part @@ -115,10 +97,6 @@ function parsePackagename(arg, defaultTag) { }; } -/** - * @param {string} arg - * @returns {string} - */ function removeAlias(arg) { // removes the alias. // Eg.: server@npm:http-server@latest becomes http-server@latest diff --git a/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js b/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js index 7edbfd3..b8896b7 100644 --- a/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js +++ b/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js @@ -1,12 +1,7 @@ +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 - * - * @returns {Promise<{status: number}>} - */ export async function runNpx(args) { try { const result = await safeSpawn("npx", args, { @@ -14,7 +9,12 @@ export async function runNpx(args) { env: mergeSafeChainProxyEnvironmentVariables(process.env), }); return { status: result.status }; - } catch (/** @type any */ error) { - return reportCommandExecutionFailure(error, "npx"); + } catch (error) { + if (error.status) { + return { status: error.status }; + } else { + ui.writeError("Error executing command:", error.message); + return { status: 1 }; + } } } diff --git a/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.js b/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.js deleted file mode 100644 index 1649a89..0000000 --- a/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.js +++ /dev/null @@ -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"); - } -} diff --git a/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.spec.js b/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.spec.js deleted file mode 100644 index 2b2266b..0000000 --- a/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.spec.js +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/pip/createPackageManager.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.js deleted file mode 100644 index bd78605..0000000 --- a/packages/safe-chain/src/packagemanager/pip/createPackageManager.js +++ /dev/null @@ -1,25 +0,0 @@ -import { runPip } from "./runPipCommand.js"; -import { PIP_COMMAND } from "./pipSettings.js"; - -/** - * @param {{ tool: string, args: string[] }} [context] - Optional context with tool name and args - * @returns {import("../currentPackageManager.js").PackageManager} - */ -export function createPipPackageManager(context) { - const tool = context?.tool || PIP_COMMAND; - - return { - /** - * @param {string[]} args - */ - runCommand: (args) => { - // Args from main.js are already stripped of --safe-chain-* flags - // We just pass the tool (e.g. "python3") and the args (e.g. ["-m", "pip", "install", ...]) - return runPip(tool, args); - }, - // For pip, rely solely on MITM proxy to detect/deny downloads from known registries. - isSupportedCommand: () => false, - getDependencyUpdatesForCommand: () => [], - }; -} - diff --git a/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js deleted file mode 100644 index d2668c0..0000000 --- a/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js +++ /dev/null @@ -1,57 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert"; -import { createPipPackageManager } from "./createPackageManager.js"; - -test("createPipPackageManager", async (t) => { - await t.test("should create package manager with required interface", () => { - const pm = createPipPackageManager(); - - 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 accept pip3 as command parameter", () => { - const pm = createPipPackageManager("pip3"); - assert.ok(pm); - }); - - await t.test("should support install, download, and wheel commands", () => { - const pm = createPipPackageManager(); - // MITM-only approach, pip does not scan args - assert.strictEqual(pm.isSupportedCommand(["install", "requests"]), false); - assert.strictEqual(pm.isSupportedCommand(["download", "requests"]), false); - assert.strictEqual(pm.isSupportedCommand(["wheel", "requests"]), false); - }); - - await t.test("should not support uninstall and info commands", () => { - const pm = createPipPackageManager(); - - assert.strictEqual(pm.isSupportedCommand(["uninstall", "requests"]), false); - assert.strictEqual(pm.isSupportedCommand(["list"]), false); - assert.strictEqual(pm.isSupportedCommand(["show", "requests"]), false); - }); - - await t.test("should extract packages from install command", () => { - const pm = createPipPackageManager(); - const result = pm.getDependencyUpdatesForCommand(["install", "requests==2.28.0"]); - assert.ok(Array.isArray(result)); - assert.strictEqual(result.length, 0); - }); - - await t.test("should return empty array for unsupported commands", () => { - const pm = createPipPackageManager(); - - const result = pm.getDependencyUpdatesForCommand(["uninstall", "requests"]); - assert.ok(Array.isArray(result)); - assert.strictEqual(result.length, 0); - }); - - await t.test("should handle empty args gracefully", () => { - const pm = createPipPackageManager(); - - assert.strictEqual(pm.isSupportedCommand([]), false); - assert.deepStrictEqual(pm.getDependencyUpdatesForCommand([]), []); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/pip/pipSettings.js b/packages/safe-chain/src/packagemanager/pip/pipSettings.js deleted file mode 100644 index 1ef6720..0000000 --- a/packages/safe-chain/src/packagemanager/pip/pipSettings.js +++ /dev/null @@ -1,6 +0,0 @@ -export const PIP_PACKAGE_MANAGER = "pip"; - -export const PIP_COMMAND = "pip"; -export const PIP3_COMMAND = "pip3"; -export const PYTHON_COMMAND = "python"; -export const PYTHON3_COMMAND = "python3"; diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js deleted file mode 100644 index 4f4e401..0000000 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js +++ /dev/null @@ -1,209 +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 { PIP_COMMAND, PIP3_COMMAND, PYTHON_COMMAND, PYTHON3_COMMAND } from "./pipSettings.js"; -import fs from "node:fs/promises"; -import fsSync from "node:fs"; -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. - * Returns true if the tool is python/python3 but NOT being run with -m pip/pip3. - * @param {string} command - The command executable - * @param {string[]} args - The arguments - * @returns {boolean} - */ -export function shouldBypassSafeChain(command, args) { - if (command === PYTHON_COMMAND || command === PYTHON3_COMMAND) { - // Check if args start with -m pip - if (args.length >= 2 && args[0] === "-m" && (args[1] === PIP_COMMAND || args[1] === PIP3_COMMAND)) { - return false; - } - return true; - } - return false; -} - -/** - * Sets fallback CA bundle environment variables used by Python libraries. - * These are applied in addition to the PIP_CONFIG_FILE to ensure all Python - * network libraries respect the combined CA bundle, even if they don't read pip's config. - * - * @param {NodeJS.ProcessEnv} env - Environment object to modify - * @param {string} combinedCaPath - Path to the combined CA bundle - */ -function setFallbackCaBundleEnvironmentVariables(env, combinedCaPath) { - // REQUESTS_CA_BUNDLE: Used by the popular 'requests' library - 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; - - // SSL_CERT_FILE: Used by some Python SSL libraries and urllib - 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; - - // PIP_CERT: Pip's own environment variable for certificate verification - 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 pip command with safe-chain's certificate bundle and proxy configuration. - * - * Creates a temporary pip config file to configure: - * - Cert bundle for HTTPS verification - * - Proxy settings - * - * If the user has an existing PIP_CONFIG_FILE, a new temporary config is created that merges - * their settings with safe-chain's, leaving the original file unchanged. - * - * Special handling for commands that modify config/cache/state: PIP_CONFIG_FILE is NOT overridden to allow - * users to read/write persistent config. Only CA environment variables are set for these commands. - * - * @param {string} command - The pip command executable (e.g., 'pip3' or 'python3') - * @param {string[]} args - Command line arguments to pass to pip - * @returns {Promise<{status: number}>} Exit status of the pip command - */ -export async function runPip(command, args) { - // Check if we should bypass safe-chain (python/python3 without -m pip) - if (shouldBypassSafeChain(command, args)) { - ui.writeVerbose(`Safe-chain: Bypassing safe-chain for non-pip invocation: ${command} ${args.join(" ")}`); - // Spawn the ORIGINAL command with ORIGINAL args - return new Promise((_resolve) => { - const proc = spawn(command, args, { stdio: "inherit" }); - proc.on("exit", (/** @type {number | null} */ code) => { - ui.writeVerbose(`${command} ${args.join(" ")} exited with status ${code}`); - ui.writeBufferedLogsAndStopBuffering(); - process.exit(code ?? 0); - }); - proc.on("error", (/** @type {Error} */ err) => { - ui.writeError(`Error executing command: ${err.message}`); - ui.writeBufferedLogsAndStopBuffering(); - process.exit(1); - }); - }); - } - - try { - const env = mergeSafeChainProxyEnvironmentVariables(process.env); - - // Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots + user certs) - // so that any network request made by pip, including those outside explicit CLI args, - // validates correctly under both MITM'd and tunneled HTTPS. - const combinedCaPath = getCombinedCaBundlePath(); - - // Commands that need access to persistent config/cache/state files - // These should not have PIP_CONFIG_FILE overridden as it would prevent them from - // reading/writing to the user's actual pip configuration and cache directories - const configRelatedCommands = ['config', 'cache', 'debug', 'completion']; - const isConfigRelatedCommand = args.length > 0 && configRelatedCommands.includes(args[0]); - - // https://pip.pypa.io/en/stable/topics/https-certificates/ explains that the 'cert' param (which we're providing via INI file) - // will tell pip to use the provided CA bundle for HTTPS verification. - - // Proxy settings: GLOBAL_AGENT_HTTP_PROXY is our safe-chain proxy (if active), - // otherwise fall back to user-defined HTTPS_PROXY or HTTP_PROXY environment variables - const proxy = env.GLOBAL_AGENT_HTTP_PROXY || env.HTTPS_PROXY || env.HTTP_PROXY || ''; - - const tmpDir = os.tmpdir(); - const pipConfigPath = path.join(tmpDir, `safe-chain-pip-${Date.now()}.ini`); - let cleanupConfigPath = null; // Track temp file for cleanup - - if (isConfigRelatedCommand) { - ui.writeVerbose(`Safe-chain: Skipping PIP_CONFIG_FILE override for 'pip ${args[0]}' command to allow persistent config/cache access.`); - - // Still set the fallback CA bundle environment variables to avoid edge cases where a - // plugin or extension triggers a network call during config introspection - // This can do no harm - setFallbackCaBundleEnvironmentVariables(env, combinedCaPath); - - const result = await safeSpawn(command, args, { - stdio: "inherit", - env, - }); - - return { status: result.status }; - } - - // Note: Setting PIP_CONFIG_FILE overrides all pip config levels (Global/User/Site) per pip's loading order - if (!env.PIP_CONFIG_FILE) { - /** @type {{ global: { cert: string, proxy?: string } }} */ - const configObj = { global: { cert: combinedCaPath } }; - if (proxy) { - configObj.global.proxy = proxy; - } - const pipConfig = ini.stringify(configObj); - await fs.writeFile(pipConfigPath, pipConfig); - env.PIP_CONFIG_FILE = pipConfigPath; - cleanupConfigPath = pipConfigPath; - - } else if (fsSync.existsSync(env.PIP_CONFIG_FILE)) { - ui.writeVerbose("Safe-chain: Merging user provided PIP_CONFIG_FILE with safe-chain certificate and proxy settings."); - const userConfig = env.PIP_CONFIG_FILE; - - // Read the existing config without modifying it - let content = await fs.readFile(userConfig, "utf-8"); - const parsed = ini.parse(content); - - // Ensure [global] section exists - parsed.global = parsed.global || {}; - - // Cert - if (typeof parsed.global.cert !== "undefined") { - ui.writeWarning("Safe-chain: User defined cert found in PIP_CONFIG_FILE. It will be overwritten in the temporary config."); - } - parsed.global.cert = combinedCaPath; - - // Proxy - if (typeof parsed.global.proxy !== "undefined") { - ui.writeWarning("Safe-chain: User defined proxy found in PIP_CONFIG_FILE. It will be overwritten in the temporary config."); - } - if (proxy) { - parsed.global.proxy = proxy; - } - - const updated = ini.stringify(parsed); - - // Save to a new temp file to avoid overwriting user's original config - await fs.writeFile(pipConfigPath, updated, "utf-8"); - env.PIP_CONFIG_FILE = pipConfigPath; - cleanupConfigPath = pipConfigPath; - - } else { - // The user provided PIP_CONFIG_FILE does not exist on disk - // PIP will handle this as an error and inform the user - } - - // Set fallback CA bundle environment variables for Python libraries that don't read pip config - setFallbackCaBundleEnvironmentVariables(env, combinedCaPath); - - const result = await safeSpawn(command, args, { - stdio: "inherit", - env, - }); - - // Cleanup temporary config file if we created one - if (cleanupConfigPath) { - try { - await fs.unlink(cleanupConfigPath); - } catch { - // Ignore cleanup errors - the file may have already been deleted or is inaccessible - // Temp files in os.tmpdir() may eventually be cleaned by the OS, but timing varies by platform - } - } - - return { status: result.status }; - } catch (/** @type any */ error) { - return reportCommandExecutionFailure(error, command); - } -} diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js deleted file mode 100644 index 0707333..0000000 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js +++ /dev/null @@ -1,419 +0,0 @@ -import { describe, it, beforeEach, afterEach, mock } from "node:test"; -import assert from "node:assert"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import ini from "ini"; - -describe("runPipCommand environment variable handling", () => { - let runPip; - let shouldBypassSafeChain; - let capturedArgs = null; - let customEnv = null; - let capturedConfigContent = null; // Capture config file content before cleanup - - beforeEach(async () => { - capturedArgs = null; - capturedConfigContent = null; - - // Mock safeSpawn to capture args and config file content before cleanup - mock.module("../../utils/safeSpawn.js", { - namedExports: { - safeSpawn: async (command, args, options) => { - capturedArgs = { command, args, options }; - // Capture the config file content before the function cleans it up - if (options.env.PIP_CONFIG_FILE) { - try { - capturedConfigContent = await fs.readFile(options.env.PIP_CONFIG_FILE, "utf-8"); - } catch { - // Ignore if file doesn't exist or can't be read - } - } - return { status: 0 }; - }, - }, - }); - - // Mock proxy env merge, allow custom env override - mock.module("../../registryProxy/registryProxy.js", { - namedExports: { - mergeSafeChainProxyEnvironmentVariables: (env) => ({ - ...env, - ...customEnv, - // Force deterministic proxy for tests regardless of ambient env - GLOBAL_AGENT_HTTP_PROXY: "http://localhost:8080", - HTTPS_PROXY: "http://localhost:8080", - HTTP_PROXY: "", - }), - }, - }); - - // Mock certBundle to return a test combined bundle path - mock.module("../../registryProxy/certBundle.js", { - namedExports: { - getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem", - }, - }); - - const mod = await import("./runPipCommand.js"); - runPip = mod.runPip; - shouldBypassSafeChain = mod.shouldBypassSafeChain; - }); - - afterEach(() => { - mock.reset(); - }); - - it("should NOT set PIP_CONFIG_FILE for 'pip config' commands to allow persistent config access", async () => { - const res = await runPip("pip3", ["config", "set", "global.index-url", "https://test.pypi.org/simple"]); - assert.strictEqual(res.status, 0); - assert.ok(capturedArgs, "safeSpawn should have been called"); - - // PIP_CONFIG_FILE should NOT be set for config commands - assert.strictEqual( - capturedArgs.options.env.PIP_CONFIG_FILE, - undefined, - "PIP_CONFIG_FILE should NOT be set for pip config commands" - ); - - // But CA environment variables should still be set - assert.strictEqual( - capturedArgs.options.env.REQUESTS_CA_BUNDLE, - "/tmp/test-combined-ca.pem", - "REQUESTS_CA_BUNDLE should still be set" - ); - assert.strictEqual( - capturedArgs.options.env.SSL_CERT_FILE, - "/tmp/test-combined-ca.pem", - "SSL_CERT_FILE should still be set" - ); - assert.strictEqual( - capturedArgs.options.env.PIP_CERT, - "/tmp/test-combined-ca.pem", - "PIP_CERT should still be set" - ); - }); - - it("should NOT set PIP_CONFIG_FILE for 'pip config get' commands", async () => { - const res = await runPip("pip3", ["config", "get", "global.index-url"]); - assert.strictEqual(res.status, 0); - assert.ok(capturedArgs, "safeSpawn should have been called"); - - assert.strictEqual( - capturedArgs.options.env.PIP_CONFIG_FILE, - undefined, - "PIP_CONFIG_FILE should NOT be set for pip config get" - ); - }); - - it("should NOT set PIP_CONFIG_FILE for 'pip config list' commands", async () => { - const res = await runPip("pip3", ["config", "list"]); - assert.strictEqual(res.status, 0); - assert.ok(capturedArgs, "safeSpawn should have been called"); - - assert.strictEqual( - capturedArgs.options.env.PIP_CONFIG_FILE, - undefined, - "PIP_CONFIG_FILE should NOT be set for pip config list" - ); - }); - - it("should NOT set PIP_CONFIG_FILE for 'pip cache' commands", async () => { - const res = await runPip("pip3", ["cache", "dir"]); - assert.strictEqual(res.status, 0); - assert.ok(capturedArgs, "safeSpawn should have been called"); - - assert.strictEqual( - capturedArgs.options.env.PIP_CONFIG_FILE, - undefined, - "PIP_CONFIG_FILE should NOT be set for pip cache commands" - ); - - // CA env vars should still be set - assert.strictEqual( - capturedArgs.options.env.SSL_CERT_FILE, - "/tmp/test-combined-ca.pem", - "SSL_CERT_FILE should still be set" - ); - }); - - it("should NOT set PIP_CONFIG_FILE for 'pip debug' commands", async () => { - const res = await runPip("pip3", ["debug"]); - assert.strictEqual(res.status, 0); - assert.ok(capturedArgs, "safeSpawn should have been called"); - - assert.strictEqual( - capturedArgs.options.env.PIP_CONFIG_FILE, - undefined, - "PIP_CONFIG_FILE should NOT be set for pip debug" - ); - }); - - it("should NOT set PIP_CONFIG_FILE for 'pip completion' commands", async () => { - const res = await runPip("pip3", ["completion", "--bash"]); - assert.strictEqual(res.status, 0); - assert.ok(capturedArgs, "safeSpawn should have been called"); - - assert.strictEqual( - capturedArgs.options.env.PIP_CONFIG_FILE, - undefined, - "PIP_CONFIG_FILE should NOT be set for pip completion" - ); - }); - - it("should set PIP_CERT env var and create config file", async () => { - const res = await runPip("pip3", ["install", "requests"]); - assert.strictEqual(res.status, 0); - assert.ok(capturedArgs, "safeSpawn should have been called"); - // Check PIP_CERT env var - assert.strictEqual( - capturedArgs.options.env.PIP_CERT, - "/tmp/test-combined-ca.pem", - "PIP_CERT should be set to combined bundle path" - ); - // Check PIP_CONFIG_FILE env var exists and is a non-empty string - const configPath = capturedArgs.options.env.PIP_CONFIG_FILE; - assert.ok(configPath, "PIP_CONFIG_FILE should be set"); - assert.strictEqual(typeof configPath, "string", "PIP_CONFIG_FILE should be a string"); - assert.ok(configPath.length > 0, "PIP_CONFIG_FILE should be a non-empty path"); - }); - - it("should set REQUESTS_CA_BUNDLE and SSL_CERT_FILE for default PyPI (no explicit index)", async () => { - const res = await runPip("pip3", ["install", "requests"]); - assert.strictEqual(res.status, 0); - - assert.ok(capturedArgs, "safeSpawn should have been called"); - - // Check environment variables are set - assert.strictEqual( - capturedArgs.options.env.REQUESTS_CA_BUNDLE, - "/tmp/test-combined-ca.pem", - "REQUESTS_CA_BUNDLE should be set to combined bundle path" - ); - assert.strictEqual( - capturedArgs.options.env.SSL_CERT_FILE, - "/tmp/test-combined-ca.pem", - "SSL_CERT_FILE should be set to combined bundle path" - ); - }); - - it("should set CA environment variables even for external/test PyPI mirror (covers non-CLI traffic)", async () => { - const res = await runPip("pip3", [ - "install", - "certifi", - "--index-url", - "https://test.pypi.org/simple", - ]); - assert.strictEqual(res.status, 0); - // Env vars should be set unconditionally - assert.strictEqual( - capturedArgs.options.env.REQUESTS_CA_BUNDLE, - "/tmp/test-combined-ca.pem" - ); - assert.strictEqual( - capturedArgs.options.env.SSL_CERT_FILE, - "/tmp/test-combined-ca.pem" - ); - }); - - it("should still set CA env vars for PyPI even with user --cert flag", async () => { - // For default PyPI, we still set env vars; pip CLI --cert takes precedence - const res = await runPip("pip3", ["install", "requests"]); - assert.strictEqual(res.status, 0); - - // Environment variables still set (pip CLI --cert takes precedence) - assert.strictEqual( - capturedArgs.options.env.REQUESTS_CA_BUNDLE, - "/tmp/test-combined-ca.pem" - ); - assert.strictEqual( - capturedArgs.options.env.SSL_CERT_FILE, - "/tmp/test-combined-ca.pem" - ); - }); - - it("should preserve HTTPS_PROXY from proxy merge", async () => { - const res = await runPip("pip3", ["install", "requests"]); - assert.strictEqual(res.status, 0); - - assert.strictEqual( - capturedArgs.options.env.HTTPS_PROXY, - "http://localhost:8080", - "HTTPS_PROXY should be set by proxy merge" - ); - }); - - it("should create a new temp config when existing config exists (original file untouched)", async () => { - const tmpDir = os.tmpdir(); - const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`); - const initial = "[global]\nindex-url = https://example.com/simple\n"; - await fs.writeFile(userCfgPath, initial, "utf-8"); - - customEnv = { PIP_CONFIG_FILE: userCfgPath }; - const res = await runPip("pip3", ["install", "requests"]); - assert.strictEqual(res.status, 0); - const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE; - assert.notStrictEqual(newCfgPath, userCfgPath, "should point to a new temp config file"); - - // Original file unchanged - const originalContent = await fs.readFile(userCfgPath, "utf-8"); - const originalParsed = ini.parse(originalContent); - assert.strictEqual(originalParsed.global.cert, undefined, "original file should not gain cert"); - - // New file has merged settings (read from captured content before cleanup) - assert.ok(capturedConfigContent, "config content should have been captured"); - const newParsed = ini.parse(capturedConfigContent); - assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "new config should include cert"); - assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "new config should include proxy from env"); - assert.strictEqual(newParsed.global["index-url"], "https://example.com/simple", "index-url should be preserved"); - customEnv = null; - }); - - it("should create new config with proxy set from env (ini-validated)", async () => { - // No PIP_CONFIG_FILE in env => creation path - const res = await runPip("pip3", ["install", "requests"]); - assert.strictEqual(res.status, 0); - - assert.ok(capturedConfigContent, "config content should have been captured"); - const parsed = ini.parse(capturedConfigContent); - assert.ok(parsed.global, "[global] should exist after creation"); - assert.strictEqual( - parsed.global.proxy, - "http://localhost:8080", - "proxy should be set from merged env" - ); - assert.strictEqual( - parsed.global.cert, - "/tmp/test-combined-ca.pem", - "cert should be set during creation" - ); - }); - - it("should create new temp config adding cert but preserving existing proxy (original file unchanged)", async () => { - const tmpDir = os.tmpdir(); - const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`); - const initial = "[global]\nproxy = http://original:9999\n"; - await fs.writeFile(userCfgPath, initial, "utf-8"); - - customEnv = { PIP_CONFIG_FILE: userCfgPath }; - const res = await runPip("pip3", ["install", "requests"]); - assert.strictEqual(res.status, 0); - const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE; - assert.notStrictEqual(newCfgPath, userCfgPath, "should use a new temp config file"); - - // Original file unchanged - const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8")); - assert.strictEqual(originalParsed.global.cert, undefined, "original file should not gain cert"); - assert.strictEqual(originalParsed.global.proxy, "http://original:9999", "original proxy remains"); - - // New file: cert and proxy always overwritten (read from captured content) - assert.ok(capturedConfigContent, "config content should have been captured"); - const newParsed = ini.parse(capturedConfigContent); - assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config"); - assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config"); - customEnv = null; - }); - - it("should create new temp config preserving existing cert and proxy while leaving original file unchanged", async () => { - const tmpDir = os.tmpdir(); - const cfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`); - const initialIni = [ - "[global]", - "cert = /path/to/existing.pem", - "proxy = http://original:9999", - "" - ].join("\n"); - await fs.writeFile(cfgPath, initialIni, "utf-8"); - - customEnv = { PIP_CONFIG_FILE: cfgPath }; - const res = await runPip("pip3", ["install", "requests"]); - assert.strictEqual(res.status, 0, "execution should succeed"); - const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE; - assert.notStrictEqual(newCfgPath, cfgPath, "should use a newly generated temp config file"); - - // Original file stays untouched - const originalContent = await fs.readFile(cfgPath, "utf-8"); - const originalParsed = ini.parse(originalContent); - assert.strictEqual(originalParsed.global.cert, "/path/to/existing.pem", "original cert preserved"); - assert.strictEqual(originalParsed.global.proxy, "http://original:9999", "original proxy preserved"); - - // New temp config: cert and proxy always overwritten (read from captured content) - assert.ok(capturedConfigContent, "config content should have been captured"); - const newParsed = ini.parse(capturedConfigContent); - assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config"); - assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config"); - customEnv = null; - }); - - it("should create new temp config preserving existing cert and adding missing proxy", async () => { - const tmpDir = os.tmpdir(); - const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`); - const initial = "[global]\ncert = /path/to/existing.pem\n"; - await fs.writeFile(userCfgPath, initial, "utf-8"); - - customEnv = { PIP_CONFIG_FILE: userCfgPath }; - const res = await runPip("pip3", ["install", "requests"]); - assert.strictEqual(res.status, 0); - const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE; - assert.notStrictEqual(newCfgPath, userCfgPath, "should produce a new temp config file"); - - // Original remains unchanged - const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8")); - assert.strictEqual(originalParsed.global.cert, "/path/to/existing.pem", "original cert unchanged"); - assert.strictEqual(originalParsed.global.proxy, undefined, "original proxy still missing"); - - // New file: cert and proxy always overwritten (read from captured content) - assert.ok(capturedConfigContent, "config content should have been captured"); - const newParsed = ini.parse(capturedConfigContent); - assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config"); - assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config"); - customEnv = null; - }); - - it("should log warnings when cert and proxy are already set in user config file", async () => { - const tmpDir = os.tmpdir(); - const cfgPath = path.join(tmpDir, `safe-chain-test-pip-warn-${Date.now()}.ini`); - const initialIni = [ - "[global]", - "cert = /user/cert.pem", - "proxy = http://user-proxy:9999", - "" - ].join("\n"); - await fs.writeFile(cfgPath, initialIni, "utf-8"); - - customEnv = { PIP_CONFIG_FILE: cfgPath }; - - // Capture stdout/stderr - let output = ""; - const originalWrite = process.stdout.write; - const originalError = process.stderr.write; - process.stdout.write = (chunk, ...args) => { output += chunk; return originalWrite.apply(process.stdout, [chunk, ...args]); }; - process.stderr.write = (chunk, ...args) => { output += chunk; return originalError.apply(process.stderr, [chunk, ...args]); }; - - await runPip("pip3", ["install", "requests"]); - - process.stdout.write = originalWrite; - process.stderr.write = originalError; - - assert.ok(output.includes("cert found in PIP_CONFIG_FILE"), "Should warn about cert overwrite in output"); - assert.ok(output.includes("proxy found in PIP_CONFIG_FILE"), "Should warn about proxy overwrite in output"); - customEnv = null; - }); - - it("should bypass safe-chain for python correctly", async () => { - assert.strictEqual(shouldBypassSafeChain("python", []), true); - assert.strictEqual(shouldBypassSafeChain("python3", []), true); - - assert.strictEqual(shouldBypassSafeChain("python", ["--version"]), true); - assert.strictEqual(shouldBypassSafeChain("python3", ["--version"]), true); - - assert.strictEqual(shouldBypassSafeChain("python", ["-m", "http.server"]), true); - assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "http.server"]), true); - - assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip"]), false); - assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip"]), false); - assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip3"]), false); - assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip3"]), false); - }); - -}); diff --git a/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.js b/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.js deleted file mode 100644 index cc536f8..0000000 --- a/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.js +++ /dev/null @@ -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: () => [], - }; -} diff --git a/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.spec.js b/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.spec.js deleted file mode 100644 index 1932384..0000000 --- a/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.spec.js +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js deleted file mode 100644 index c374e2a..0000000 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js +++ /dev/null @@ -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); - } -} diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js deleted file mode 100644 index dd04dc2..0000000 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/pnpm/createPackageManager.js b/packages/safe-chain/src/packagemanager/pnpm/createPackageManager.js index c3046c8..15cb628 100644 --- a/packages/safe-chain/src/packagemanager/pnpm/createPackageManager.js +++ b/packages/safe-chain/src/packagemanager/pnpm/createPackageManager.js @@ -4,9 +4,6 @@ import { runPnpmCommand } from "./runPnpmCommand.js"; const scanner = commandArgumentScanner(); -/** - * @returns {import("../currentPackageManager.js").PackageManager} - */ export function createPnpmPackageManager() { return { runCommand: (args) => runPnpmCommand(args, "pnpm"), @@ -26,9 +23,6 @@ export function createPnpmPackageManager() { }; } -/** - * @returns {import("../currentPackageManager.js").PackageManager} - */ export function createPnpxPackageManager() { return { runCommand: (args) => runPnpmCommand(args, "pnpx"), @@ -38,11 +32,6 @@ export function createPnpxPackageManager() { }; } -/** - * @param {string[]} args - * @param {boolean} isPnpx - * @returns {ReturnType} - */ function getDependencyUpdatesForCommand(args, isPnpx) { if (isPnpx) { return scanner.scan(args); diff --git a/packages/safe-chain/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js b/packages/safe-chain/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js index e46d2db..c184b38 100644 --- a/packages/safe-chain/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js +++ b/packages/safe-chain/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js @@ -1,9 +1,6 @@ import { resolvePackageVersion } from "../../../api/npmApi.js"; import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js"; -/** - * @returns {import("../../npm/dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner} - */ export function commandArgumentScanner() { return { scan: (args) => scanDependencies(args), @@ -11,10 +8,6 @@ export function commandArgumentScanner() { }; } -/** - * @param {string[]} args - * @returns {Promise} - */ async function scanDependencies(args) { const changes = []; const packageUpdates = parsePackagesFromArguments(args); diff --git a/packages/safe-chain/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js b/packages/safe-chain/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js index b8a6f39..d0383c2 100644 --- a/packages/safe-chain/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js +++ b/packages/safe-chain/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js @@ -1,7 +1,3 @@ -/** - * @param {string[]} args - * @returns {{name: string, version: string}[]} - */ export function parsePackagesFromArguments(args) { const changes = []; let defaultTag = "latest"; @@ -26,10 +22,6 @@ export function parsePackagesFromArguments(args) { return changes; } -/** - * @param {string} arg - * @returns {{name: string, numberOfParameters: number} | undefined} - */ function getOption(arg) { if (isOptionWithParameter(arg)) { return { @@ -50,21 +42,12 @@ function getOption(arg) { return undefined; } -/** - * @param {string} arg - * @returns {boolean} - */ function isOptionWithParameter(arg) { const optionsWithParameters = ["--C", "--dir"]; return optionsWithParameters.includes(arg); } -/** - * @param {string} arg - * @param {string} defaultTag - * @returns {{name: string, version: string}} - */ function parsePackagename(arg, defaultTag) { // format can be --package=name@version // in that case, we need to remove the --package= part @@ -94,10 +77,6 @@ function parsePackagename(arg, defaultTag) { }; } -/** - * @param {string} arg - * @returns {string} - */ function removeAlias(arg) { // removes the alias. // Eg.: server@npm:http-server@latest becomes http-server@latest diff --git a/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js b/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js index 3b90422..794d6e3 100644 --- a/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js +++ b/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js @@ -1,12 +1,7 @@ +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 - * @param {string} [toolName] - * @returns {Promise<{status: number}>} - */ export async function runPnpmCommand(args, toolName = "pnpm") { try { let result; @@ -25,8 +20,12 @@ 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); + } catch (error) { + if (error.status) { + return { status: error.status }; + } else { + ui.writeError("Error executing command:", error.message); + return { status: 1 }; + } } } diff --git a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js deleted file mode 100644 index 567fb43..0000000 --- a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js +++ /dev/null @@ -1,72 +0,0 @@ -import { ui } from "../../environment/userInteraction.js"; -import { safeSpawn } from "../../utils/safeSpawn.js"; -import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; -import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; - -/** - * @returns {import("../currentPackageManager.js").PackageManager} - */ -export function createPoetryPackageManager() { - return { - runCommand: (args) => runPoetryCommand(args), - - // MITM only approach for Poetry - isSupportedCommand: () => false, - getDependencyUpdatesForCommand: () => [], - }; -} - -/** - * Sets CA bundle environment variables used by Poetry and Python libraries. - * Poetry uses the Python requests library which respects these environment variables. - * - * @param {NodeJS.ProcessEnv} env - Environment object to modify - * @param {string} combinedCaPath - Path to the combined CA bundle - */ -function setPoetryCaBundleEnvironmentVariables(env, combinedCaPath) { - // SSL_CERT_FILE: Used by Python SSL libraries and requests - if (env.SSL_CERT_FILE) { - ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); - } - env.SSL_CERT_FILE = combinedCaPath; - - // REQUESTS_CA_BUNDLE: Used by the requests library (which Poetry uses) - if (env.REQUESTS_CA_BUNDLE) { - ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); - } - env.REQUESTS_CA_BUNDLE = combinedCaPath; - - // PIP_CERT: Poetry may use pip internally - if (env.PIP_CERT) { - ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); - } - env.PIP_CERT = combinedCaPath; -} - -/** - * Runs a poetry command with safe-chain's certificate bundle and proxy configuration. - * - * Poetry respects standard HTTP_PROXY/HTTPS_PROXY environment variables through - * the Python requests library. - * - * @param {string[]} args - Command line arguments to pass to poetry - * @returns {Promise<{status: number}>} Exit status of the poetry command - */ -async function runPoetryCommand(args) { - try { - const env = mergeSafeChainProxyEnvironmentVariables(process.env); - - const combinedCaPath = getCombinedCaBundlePath(); - setPoetryCaBundleEnvironmentVariables(env, combinedCaPath); - - const result = await safeSpawn("poetry", args, { - stdio: "inherit", - env, - }); - - return { status: result.status }; - } catch (/** @type any */ error) { - return reportCommandExecutionFailure(error, "poetry"); - } -} diff --git a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.spec.js b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.spec.js deleted file mode 100644 index a49cd27..0000000 --- a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.spec.js +++ /dev/null @@ -1,14 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert"; -import { createPoetryPackageManager } from "./createPoetryPackageManager.js"; - -test("createPoetryPackageManager", async (t) => { - await t.test("should create package manager with required interface", () => { - const pm = createPoetryPackageManager(); - - assert.ok(pm); - assert.strictEqual(typeof pm.runCommand, "function"); - assert.strictEqual(typeof pm.isSupportedCommand, "function"); - assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js deleted file mode 100644 index 85ec4d5..0000000 --- a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js +++ /dev/null @@ -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} - */ -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(); -} diff --git a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.spec.js b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.spec.js deleted file mode 100644 index 5c02f52..0000000 --- a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.spec.js +++ /dev/null @@ -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(); - } -}); diff --git a/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.js b/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.js deleted file mode 100644 index 3e82085..0000000 --- a/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.js +++ /dev/null @@ -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; -} diff --git a/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.spec.js b/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.spec.js deleted file mode 100644 index 0607c82..0000000 --- a/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.spec.js +++ /dev/null @@ -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" }]); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js deleted file mode 100644 index 340e3f6..0000000 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js +++ /dev/null @@ -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); - } -} diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js deleted file mode 100644 index fa2c35a..0000000 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js +++ /dev/null @@ -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", - }); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.js b/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.js deleted file mode 100644 index af89d21..0000000 --- a/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.js +++ /dev/null @@ -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: () => [], - }; -} diff --git a/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.spec.js b/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.spec.js deleted file mode 100644 index 20b4a32..0000000 --- a/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.spec.js +++ /dev/null @@ -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(), []); -}); diff --git a/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.js b/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.js deleted file mode 100644 index 76f642b..0000000 --- a/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.js +++ /dev/null @@ -1,18 +0,0 @@ -import { runUv } from "./runUvCommand.js"; - -/** - * @returns {import("../currentPackageManager.js").PackageManager} - */ -export function createUvPackageManager() { - return { - /** - * @param {string[]} args - */ - runCommand: (args) => { - return runUv("uv", args); - }, - // For uv, rely solely on MITM - isSupportedCommand: () => false, - getDependencyUpdatesForCommand: () => [], - }; -} diff --git a/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.spec.js b/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.spec.js deleted file mode 100644 index eb42924..0000000 --- a/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.spec.js +++ /dev/null @@ -1,14 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert"; -import { createUvPackageManager } from "./createUvPackageManager.js"; - -test("createUvPackageManager", async (t) => { - await t.test("should create package manager with required interface", () => { - const pm = createUvPackageManager(); - - assert.ok(pm); - assert.strictEqual(typeof pm.runCommand, "function"); - assert.strictEqual(typeof pm.isSupportedCommand, "function"); - assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/uv/runUvCommand.js b/packages/safe-chain/src/packagemanager/uv/runUvCommand.js deleted file mode 100644 index 7c22518..0000000 --- a/packages/safe-chain/src/packagemanager/uv/runUvCommand.js +++ /dev/null @@ -1,66 +0,0 @@ -import { ui } from "../../environment/userInteraction.js"; -import { safeSpawn } from "../../utils/safeSpawn.js"; -import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; -import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; - -/** - * Sets CA bundle environment variables used by Python libraries and uv. - * - * @param {NodeJS.ProcessEnv} env - Env object - * @param {string} combinedCaPath - Path to the combined CA bundle - */ -function setUvCaBundleEnvironmentVariables(env, combinedCaPath) { - // SSL_CERT_FILE: Used by Python SSL libraries and underlying HTTP clients - if (env.SSL_CERT_FILE) { - ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); - } - env.SSL_CERT_FILE = combinedCaPath; - - // REQUESTS_CA_BUNDLE: Used by the requests library (which uv may use internally) - if (env.REQUESTS_CA_BUNDLE) { - ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); - } - env.REQUESTS_CA_BUNDLE = combinedCaPath; - - // PIP_CERT: Some underlying pip operations may respect this - if (env.PIP_CERT) { - ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); - } - env.PIP_CERT = combinedCaPath; -} - -/** - * Runs a uv command with safe-chain's certificate bundle and proxy configuration. - * - * uv respects standard environment variables for proxy and TLS configuration: - * - HTTP_PROXY / HTTPS_PROXY: Proxy settings - * - SSL_CERT_FILE / REQUESTS_CA_BUNDLE: CA bundle for TLS verification - * - * Unlike pip (which requires a temporary config file for cert configuration), uv directly - * honors environment variables, so no config/ini file is needed. - * - * @param {string} command - The uv command to execute (typically 'uv') - * @param {string[]} args - Command line arguments to pass to uv - * @returns {Promise<{status: number}>} Exit status of the uv command - */ -export async function runUv(command, args) { - try { - const env = mergeSafeChainProxyEnvironmentVariables(process.env); - - const combinedCaPath = getCombinedCaBundlePath(); - setUvCaBundleEnvironmentVariables(env, combinedCaPath); - - // Note: uv uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration - // These are already set by mergeSafeChainProxyEnvironmentVariables - - const result = await safeSpawn(command, args, { - stdio: "inherit", - env, - }); - - return { status: result.status }; - } catch (/** @type any */ error) { - return reportCommandExecutionFailure(error, command); - } -} diff --git a/packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.js b/packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.js deleted file mode 100644 index 18a7089..0000000 --- a/packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.js +++ /dev/null @@ -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: () => [], - }; -} diff --git a/packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.spec.js b/packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.spec.js deleted file mode 100644 index 6eb87a0..0000000 --- a/packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.spec.js +++ /dev/null @@ -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(), []); -}); diff --git a/packages/safe-chain/src/packagemanager/yarn/createPackageManager.js b/packages/safe-chain/src/packagemanager/yarn/createPackageManager.js index f8a0c84..f49c763 100644 --- a/packages/safe-chain/src/packagemanager/yarn/createPackageManager.js +++ b/packages/safe-chain/src/packagemanager/yarn/createPackageManager.js @@ -3,9 +3,6 @@ import { runYarnCommand } from "./runYarnCommand.js"; const scanner = commandArgumentScanner(); -/** - * @returns {import("../currentPackageManager.js").PackageManager} - */ export function createYarnPackageManager() { return { runCommand: runYarnCommand, @@ -21,11 +18,6 @@ export function createYarnPackageManager() { }; } -/** - * @param {string[]} args - * @param {...string} commandArgs - * @returns {boolean} - */ function matchesCommand(args, ...commandArgs) { if (args.length < commandArgs.length) { return false; diff --git a/packages/safe-chain/src/packagemanager/yarn/dependencyScanner/commandArgumentScanner.js b/packages/safe-chain/src/packagemanager/yarn/dependencyScanner/commandArgumentScanner.js index 5141d54..f5bdd9f 100644 --- a/packages/safe-chain/src/packagemanager/yarn/dependencyScanner/commandArgumentScanner.js +++ b/packages/safe-chain/src/packagemanager/yarn/dependencyScanner/commandArgumentScanner.js @@ -1,9 +1,6 @@ import { resolvePackageVersion } from "../../../api/npmApi.js"; import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js"; -/** - * @returns {import("../../npm/dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner} - */ export function commandArgumentScanner() { return { scan: (args) => scanDependencies(args), @@ -11,10 +8,6 @@ export function commandArgumentScanner() { }; } -/** - * @param {string[]} args - * @returns {Promise} - */ async function scanDependencies(args) { const changes = []; const packageUpdates = parsePackagesFromArguments(args); diff --git a/packages/safe-chain/src/packagemanager/yarn/parsing/parsePackagesFromArguments.js b/packages/safe-chain/src/packagemanager/yarn/parsing/parsePackagesFromArguments.js index 8f97de5..7b0255e 100644 --- a/packages/safe-chain/src/packagemanager/yarn/parsing/parsePackagesFromArguments.js +++ b/packages/safe-chain/src/packagemanager/yarn/parsing/parsePackagesFromArguments.js @@ -1,7 +1,3 @@ -/** - * @param {string[]} args - * @returns {{name: string, version: string}[]} - */ export function parsePackagesFromArguments(args) { const changes = []; let defaultTag = "latest"; @@ -26,11 +22,6 @@ export function parsePackagesFromArguments(args) { return changes; } -/** - * @param {string} arg - * - * @returns {{name: string, numberOfParameters: number} | undefined} - */ function getOption(arg) { if (isOptionWithParameter(arg)) { return { @@ -51,11 +42,6 @@ function getOption(arg) { return undefined; } -/** - * @param {string} arg - * - * @returns {boolean} - */ function isOptionWithParameter(arg) { const optionsWithParameters = [ "--use-yarnrc", @@ -78,12 +64,6 @@ function isOptionWithParameter(arg) { return optionsWithParameters.includes(arg); } -/** - * @param {string} arg - * @param {string} defaultTag - * - * @returns {{name: string, version: string}} - */ function parsePackagename(arg, defaultTag) { // format can be --package=name@version // in that case, we need to remove the --package= part @@ -113,10 +93,6 @@ function parsePackagename(arg, defaultTag) { }; } -/** - * @param {string} arg - * @returns {string} - */ function removeAlias(arg) { // removes the alias. // Eg.: server@npm:http-server@latest becomes http-server@latest diff --git a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js index fdf601a..2c3795c 100644 --- a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js +++ b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js @@ -1,12 +1,7 @@ +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 - * - * @returns {Promise<{status: number}>} - */ export async function runYarnCommand(args) { try { const env = mergeSafeChainProxyEnvironmentVariables(process.env); @@ -17,20 +12,42 @@ export async function runYarnCommand(args) { env, }); return { status: result.status }; - } catch (/** @type any */ error) { - return reportCommandExecutionFailure(error, "yarn"); + } catch (error) { + if (error.status) { + return { status: error.status }; + } else { + ui.writeError("Error executing command:", error.message); + return { status: 1 }; + } } } -/** - * @param {Record} env - * - * @returns {Promise} - */ async function fixYarnProxyEnvironmentVariables(env) { - // Yarn ignores standard proxy environment variable HTTPS_PROXY - // It does respect NODE_EXTRA_CA_CERTS for custom CA certificates though. - // Don't use YARN_HTTPS_CA_FILE_PATH or YARN_CA_FILE_PATH though, it causes yarn to ignore all system CAs + // Yarn ignores standard proxy environment variables HTTPS_PROXY and NODE_EXTRA_CA_CERTS - env.YARN_HTTPS_PROXY = env.HTTPS_PROXY; + // Yarn v2/v3 and v4+ use different environment variables for proxy and CA certs + // When setting all variables, yarn returns an error about conflicting variables + // - v2/v3: "Usage Error: Unrecognized or legacy configuration settings found: httpsCaFilePath" + // - v4+: "Usage Error: Unrecognized or legacy configuration settings found: caFilePath" + + const version = await yarnVersion(); + const majorVersion = parseInt(version.split(".")[0]); + + if (majorVersion >= 4) { + env.YARN_HTTPS_PROXY = env.HTTPS_PROXY; + env.YARN_HTTPS_CA_FILE_PATH = env.NODE_EXTRA_CA_CERTS; + } else if (majorVersion === 2 || majorVersion === 3) { + env.YARN_HTTPS_PROXY = env.HTTPS_PROXY; + env.YARN_CA_FILE_PATH = env.NODE_EXTRA_CA_CERTS; + } +} + +async function yarnVersion() { + const result = await safeSpawn("yarn", ["--version"], { + stdio: "pipe", + }); + if (result.status !== 0) { + throw new Error("Failed to get yarn version"); + } + return result.stdout.trim(); } diff --git a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.spec.js b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.spec.js deleted file mode 100644 index 21475f9..0000000 --- a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.spec.js +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, it, beforeEach, afterEach, mock } from "node:test"; -import assert from "node:assert"; - -describe("runYarnCommand", () => { - let runYarnCommand; - let capturedEnv; - let yarnVersion; - - beforeEach(async () => { - capturedEnv = null; - yarnVersion = "4.1.0"; // Default to v4 - - // Mock safeSpawn to capture env and control yarn version - mock.module("../../utils/safeSpawn.js", { - namedExports: { - safeSpawn: async (command, args, options) => { - if (args.includes("--version")) { - // Mock yarn version check - return { status: 0, stdout: yarnVersion }; - } - // Capture the env for assertions - capturedEnv = options.env; - return { status: 0 }; - }, - }, - }); - - // Mock mergeSafeChainProxyEnvironmentVariables to return test env - mock.module("../../registryProxy/registryProxy.js", { - namedExports: { - mergeSafeChainProxyEnvironmentVariables: (env) => { - return { - ...env, - HTTPS_PROXY: "http://localhost:8080", - NODE_EXTRA_CA_CERTS: "/path/to/ca-cert.pem", - }; - }, - }, - }); - - // Mock ui to prevent console output - mock.module("../../environment/userInteraction.js", { - namedExports: { - ui: { - writeError: () => {}, - }, - }, - }); - - const module = await import("./runYarnCommand.js"); - runYarnCommand = module.runYarnCommand; - }); - - afterEach(() => { - mock.reset(); - }); - - it("should set YARN_HTTPS_PROXY for Yarn v4+", async () => { - yarnVersion = "4.1.0"; - await runYarnCommand(["add", "lodash"]); - - assert.strictEqual( - capturedEnv.YARN_HTTPS_PROXY, - "http://localhost:8080", - "YARN_HTTPS_PROXY should be set to the HTTPS_PROXY value" - ); - assert.strictEqual( - capturedEnv.YARN_HTTPS_CA_FILE_PATH, - undefined, - "YARN_HTTPS_CA_FILE_PATH should NOT be set to avoid overriding system CAs" - ); - }); - - it("should set YARN_HTTPS_PROXY for Yarn v3", async () => { - yarnVersion = "3.6.4"; - await runYarnCommand(["add", "lodash"]); - - assert.strictEqual( - capturedEnv.YARN_HTTPS_PROXY, - "http://localhost:8080", - "YARN_HTTPS_PROXY should be set to the HTTPS_PROXY value" - ); - assert.strictEqual( - capturedEnv.YARN_CA_FILE_PATH, - undefined, - "YARN_CA_FILE_PATH should NOT be set to avoid overriding system CAs" - ); - }); - - it("should set YARN_HTTPS_PROXY for Yarn v2", async () => { - yarnVersion = "2.4.3"; - await runYarnCommand(["add", "lodash"]); - - assert.strictEqual( - capturedEnv.YARN_HTTPS_PROXY, - "http://localhost:8080", - "YARN_HTTPS_PROXY should be set to the HTTPS_PROXY value" - ); - assert.strictEqual( - capturedEnv.YARN_CA_FILE_PATH, - undefined, - "YARN_CA_FILE_PATH should NOT be set to avoid overriding system CAs" - ); - }); - - it("should set YARN_HTTPS_PROXY for Yarn v1", async () => { - yarnVersion = "1.22.19"; - await runYarnCommand(["add", "lodash"]); - - assert.strictEqual( - capturedEnv.YARN_HTTPS_PROXY, - "http://localhost:8080", - "YARN_HTTPS_PROXY should not be set for Yarn v1" - ); - assert.strictEqual( - capturedEnv.YARN_HTTPS_CA_FILE_PATH, - undefined, - "YARN_HTTPS_CA_FILE_PATH should not be set for Yarn v1" - ); - assert.strictEqual( - capturedEnv.YARN_CA_FILE_PATH, - undefined, - "YARN_CA_FILE_PATH should not be set for Yarn v1" - ); - }); - - it("should preserve NODE_EXTRA_CA_CERTS for all Yarn versions", async () => { - for (const version of ["4.1.0", "3.6.4", "2.4.3", "1.22.19"]) { - yarnVersion = version; - await runYarnCommand(["add", "lodash"]); - - assert.strictEqual( - capturedEnv.NODE_EXTRA_CA_CERTS, - "/path/to/ca-cert.pem", - `NODE_EXTRA_CA_CERTS should be preserved for Yarn ${version}` - ); - } - }); - - it("should preserve HTTPS_PROXY for all Yarn versions", async () => { - for (const version of ["4.1.0", "3.6.4", "2.4.3", "1.22.19"]) { - yarnVersion = version; - await runYarnCommand(["add", "lodash"]); - - assert.strictEqual( - capturedEnv.HTTPS_PROXY, - "http://localhost:8080", - `HTTPS_PROXY should be preserved for Yarn ${version}` - ); - } - }); -}); diff --git a/packages/safe-chain/src/registryProxy/certBundle.js b/packages/safe-chain/src/registryProxy/certBundle.js deleted file mode 100644 index 19dc800..0000000 --- a/packages/safe-chain/src/registryProxy/certBundle.js +++ /dev/null @@ -1,203 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -// @ts-ignore - certifi has no type definitions -import certifi from "certifi"; -import tls from "node:tls"; -import { X509Certificate } from "node:crypto"; -import { getCaCertPath } from "./certUtils.js"; -import { ui } from "../environment/userInteraction.js"; - -/** @type {string | null} */ -let bundlePath = null; - -/** - * Check if a PEM string contains only parsable cert blocks. - * @param {string} pem - PEM-encoded certificate string - * @returns {boolean} - */ -function isParsable(pem) { - if (!pem || typeof pem !== "string") return false; - pem = normalizeLineEndings(pem); - const begin = "-----BEGIN CERTIFICATE-----"; - const end = "-----END CERTIFICATE-----"; - const blocks = []; - - let idx = 0; - while (idx < pem.length) { - const start = pem.indexOf(begin, idx); - if (start === -1) break; - const stop = pem.indexOf(end, start + begin.length); - if (stop === -1) break; - const blockEnd = stop + end.length; - blocks.push(pem.slice(start, blockEnd)); - idx = blockEnd; - } - - if (blocks.length === 0) return false; - try { - for (const b of blocks) { - // throw if invalid - new X509Certificate(b); - } - return true; - } catch { - return false; - } -} - -/** - * Build a combined CA bundle. - * Automatically includes: - * - Safe Chain CA (for MITM of known registries) - * - Mozilla roots via certifi (for public HTTPS) - * - Node's built-in root certificates (fallback) - * - User's custom certificates (if NODE_EXTRA_CA_CERTS environment variable is set) - * - * @returns {string} Path to the combined CA bundle PEM file - */ -export function getCombinedCaBundlePath() { - if (bundlePath) - { - return bundlePath; - } - - const parts = []; - - // 1) Safe Chain CA (for MITM'd registries) - const safeChainPath = getCaCertPath(); - try { - const safeChainPem = fs.readFileSync(safeChainPath, "utf8"); - if (isParsable(safeChainPem)) parts.push(safeChainPem.trim()); - } catch { - // Ignore if Safe Chain CA is not available - } - - // 2) certifi (Mozilla CA bundle for all public HTTPS) - try { - const certifiPem = fs.readFileSync(certifi, "utf8"); - if (isParsable(certifiPem)) parts.push(certifiPem.trim()); - } catch { - // Ignore if certifi bundle is not available - } - - // 3) Node's built-in root certificates - try { - const nodeRoots = tls.rootCertificates; - if (Array.isArray(nodeRoots) && nodeRoots.length) { - for (const rootPem of nodeRoots) { - if (typeof rootPem !== "string") continue; - if (isParsable(rootPem)) parts.push(rootPem.trim()); - } - } - } catch { - // Ignore if unavailable - } - - // 4) User's NODE_EXTRA_CA_CERTS (if set) - const userCertPath = process.env.NODE_EXTRA_CA_CERTS; - if (userCertPath) { - const userPem = readUserCertificateFile(userCertPath); - if (userPem) { - parts.push(userPem.trim()); - ui.writeVerbose(`Safe-chain: Merging user's NODE_EXTRA_CA_CERTS from ${userCertPath}`); - } else { - ui.writeWarning(`Safe-chain: Could not read or parse user's NODE_EXTRA_CA_CERTS from ${userCertPath}`); - } - } - - const combined = parts.filter(Boolean).join("\n"); - 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; - } -} - -/** - * Normalize path - * @param {string} p - Path to normalize - * @returns {string} - */ -function normalizePathF(p) { - return p.replace(/\\/g, "/"); -} - -/** - * Normalize line endings to LF - * @param {string} text - Text with mixed line endings - * @returns {string} - */ -function normalizeLineEndings(text) { - return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); -} - -/** - * Read and validate user certificate file - * @param {string} certPath - Path to certificate file - * @returns {string | null} Certificate PEM content or null if invalid/unreadable - */ -function readUserCertificateFile(certPath) { - try { - // 1) Basic validation - if (typeof certPath !== "string" || certPath.trim().length === 0) { - return null; - } - - // 2) Reject path traversal attempts (normalize backslashes first for Windows paths) - const normalizedPath = normalizePathF(certPath); - if (normalizedPath.includes("..")) { - return null; - } - - // 3) Check if file exists and is not a directory or symlink - let stats; - try { - stats = fs.lstatSync(certPath); - } catch { - // File doesn't exist or can't be accessed - return null; - } - - if (!stats.isFile()) { - // Reject directories and symlinks - return null; - } - - // 4) Read file content - let content; - try { - content = fs.readFileSync(certPath, "utf8"); - } catch { - return null; - } - - if (!content || typeof content !== "string") { - return null; - } - - // 5) Validate PEM format - if (!isParsable(content)) { - return null; - } - - return content; - } catch { - // Silently fail on any errors - return null; - } -} - - diff --git a/packages/safe-chain/src/registryProxy/certBundle.spec.js b/packages/safe-chain/src/registryProxy/certBundle.spec.js deleted file mode 100644 index e3b58fb..0000000 --- a/packages/safe-chain/src/registryProxy/certBundle.spec.js +++ /dev/null @@ -1,379 +0,0 @@ -import { describe, it, beforeEach, mock } from "node:test"; -import assert from "node:assert"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import tls from "node:tls"; - -// Utility to remove the generated bundle so the module rebuilds it on demand -function removeBundleIfExists() { - const target = path.join(os.tmpdir(), "safe-chain-ca-bundle.pem"); - try { - if (fs.existsSync(target)) fs.unlinkSync(target); - } catch { - // ignore - } -} - -// Utility to get a valid PEM certificate for testing -function getValidCert() { - const cert = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : ""; - assert.ok(cert.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test"); - return cert; -} - -describe("certBundle.getCombinedCaBundlePath", () => { - beforeEach(() => { - mock.restoreAll(); - removeBundleIfExists(); - }); - - it("includes Safe Chain CA when parsable and produces a PEM bundle", async () => { - // Prepare a temporary Safe Chain CA file with a recognizable marker and a valid cert block - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-")); - const safeChainPath = path.join(tmpDir, "safechain-ca.pem"); - const marker = "# SAFE_CHAIN_TEST_MARKER"; - const rootPem = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : ""; - assert.ok(rootPem.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test"); - fs.writeFileSync(safeChainPath, `${marker}\n${rootPem}`, "utf8"); - - // Mock the certUtils.getCaCertPath to return our temp file - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - assert.match(contents, /-----BEGIN CERTIFICATE-----/); - assert.ok(contents.includes(marker), "Bundle should include Safe Chain CA content when parsable"); - }); - - it("ignores invalid Safe Chain CA but still builds from other sources", async () => { - // Write an invalid file (no cert blocks) - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-")); - const safeChainPath = path.join(tmpDir, "safechain-invalid.pem"); - const invalidMarker = "INVALID_SAFE_CHAIN_CONTENT"; - fs.writeFileSync(safeChainPath, invalidMarker, "utf8"); - - // Mock the certUtils.getCaCertPath to return our invalid file - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - // Ensure fresh build - removeBundleIfExists(); - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Bundle should contain certificate blocks from certifi/Node roots"); - assert.ok(!contents.includes(invalidMarker), "Bundle should not include invalid Safe Chain content"); - }); -}); - -describe("certBundle.getCombinedCaBundlePath with user certs", () => { - beforeEach(() => { - mock.restoreAll(); - delete process.env.NODE_EXTRA_CA_CERTS; - }); - - it("returns a path with full CA bundle (Safe Chain + Mozilla + Node roots) when no user cert in env", async () => { - // Mock getCaCertPath to return valid cert - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); - - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain certificate blocks"); - // Should include base bundle (Safe Chain + Mozilla/Node roots) - assert.ok(contents.length > 1000, "Bundle should be substantial with Mozilla/Node roots included"); - }); - - it("merges user cert with full base bundle (Safe Chain CA + Mozilla + Node roots)", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - - // Create Safe Chain CA - const safeChainPath = path.join(tmpDir, "safechain.pem"); - const safeChainCert = getValidCert(); - fs.writeFileSync(safeChainPath, safeChainCert, "utf8"); - - // Create user cert file - const userCertPath = path.join(tmpDir, "user-cert.pem"); - const userCert = getValidCert(); - fs.writeFileSync(userCertPath, userCert, "utf8"); - process.env.NODE_EXTRA_CA_CERTS = userCertPath; - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); - - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - - // Both certs should be in the bundle - const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length; - assert.ok(certCount >= 2, "Bundle should contain both Safe Chain and user certificates"); - }); - - it("ignores non-existent user cert path", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - process.env.NODE_EXTRA_CA_CERTS = "/nonexistent/path.pem"; - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); - - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - // Should still have Safe Chain CA - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); - }); - - it("ignores invalid PEM user cert", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - const userCertPath = path.join(tmpDir, "invalid.pem"); - fs.writeFileSync(userCertPath, "NOT A VALID PEM", "utf8"); - process.env.NODE_EXTRA_CA_CERTS = userCertPath; - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); - - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - // Should still have Safe Chain CA only - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); - assert.ok(!contents.includes("NOT A VALID"), "Should not include invalid cert"); - }); - - it("rejects user cert with path traversal attempts", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - process.env.NODE_EXTRA_CA_CERTS = "../../../etc/passwd"; - const bundlePath = getCombinedCaBundlePath(); - - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - // Should only have Safe Chain CA, rejected the traversal path - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); - }); - - it("rejects user cert with symlink", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - // Create a target file and a symlink to it - const targetCert = path.join(tmpDir, "target.pem"); - fs.writeFileSync(targetCert, getValidCert(), "utf8"); - - const symlinkPath = path.join(tmpDir, "symlink.pem"); - try { - fs.symlinkSync(targetCert, symlinkPath); - } catch { - // Skip test if symlinks are not supported (e.g., on Windows without admin) - return; - } - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - process.env.NODE_EXTRA_CA_CERTS = symlinkPath; - const bundlePath = getCombinedCaBundlePath(); - - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - // Should only have Safe Chain CA, symlinks are rejected - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); - }); - - it("rejects user cert that is a directory", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - const certDir = path.join(tmpDir, "certs"); - fs.mkdirSync(certDir); - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - process.env.NODE_EXTRA_CA_CERTS = certDir; - const bundlePath = getCombinedCaBundlePath(); - - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - // Should only have Safe Chain CA - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); - }); - - it("handles empty string user cert path", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - process.env.NODE_EXTRA_CA_CERTS = " "; - const bundlePath = getCombinedCaBundlePath(); - - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); - }); - - it("accepts files with CRLF line endings (Windows-style)", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - // Create a real file with CRLF content to test Windows line ending support - const userCertPath = path.join(tmpDir, "user-cert-crlf.pem"); - const userCert = getValidCert(); - const certWithCRLF = userCert.replace(/\n/g, "\r\n"); - fs.writeFileSync(userCertPath, certWithCRLF, "utf8"); - process.env.NODE_EXTRA_CA_CERTS = userCertPath; - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length; - assert.ok(certCount >= 2, "Bundle should contain Safe Chain and user certificates with CRLF"); - }); - - it("detects and handles Windows-style path syntax (drive letters and UNC)", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - - // Test that Windows path syntax is recognized (even if files don't exist on macOS/Linux) - // These should gracefully fail (return Safe Chain CA only) rather than crash - const winPaths = [ - "C:\\temp\\cert.pem", - "D:\\Users\\name\\certs\\ca.pem", - "\\\\server\\share\\cert.pem" - ]; - - for (const winPath of winPaths) { - process.env.NODE_EXTRA_CA_CERTS = winPath; - const bundlePath = getCombinedCaBundlePath(); - assert.ok(fs.existsSync(bundlePath), `Bundle should exist for ${winPath}`); - const contents = fs.readFileSync(bundlePath, "utf8"); - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); - } - }); - - it("rejects path traversal with Windows-style paths (C:\\temp\\..\\etc\\passwd)", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - - // Test various Windows-style traversal attempts - const traversalPaths = [ - "C:\\temp\\..\\etc\\passwd", - "D:\\Users\\..\\..\\Windows\\System32", - "\\\\server\\share\\..\\admin", - "../../../etc/passwd", // Unix-style for comparison - ]; - - // First, get baseline bundle without user certs to know expected cert count - delete process.env.NODE_EXTRA_CA_CERTS; - const baselineBundlePath = getCombinedCaBundlePath(); - const baselineContents = fs.readFileSync(baselineBundlePath, "utf8"); - const baselineCertCount = (baselineContents.match(/-----BEGIN CERTIFICATE-----/g) || []).length; - - for (const badPath of traversalPaths) { - process.env.NODE_EXTRA_CA_CERTS = badPath; - const bundlePath = getCombinedCaBundlePath(); - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - // Should contain base bundle (Safe Chain + Mozilla + Node roots) but NOT user cert - const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length; - assert.strictEqual(certCount, baselineCertCount, `Traversal path ${badPath} should be rejected; base bundle only (no user cert added)`); - } - }); -}); diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js index 3918177..d5d414c 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.js +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -1,31 +1,17 @@ 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(); -/** - * @param {forge.pki.PublicKey} publicKey - * @returns {string} - */ -function createKeyIdentifier(publicKey) { - return forge.pki.getPublicKeyFingerprint(publicKey, { - encoding: "binary", - md: forge.md.sha1.create(), - }); -} - export function getCaCertPath() { - return path.join(getCertsDir(), "ca-cert.pem"); + return path.join(certFolder, "ca-cert.pem"); } -/** - * @param {string} hostname - * @returns {{privateKey: string, certificate: string}} - */ export function generateCertForHost(hostname) { let existingCert = certCache.get(hostname); if (existingCert) { @@ -43,7 +29,6 @@ export function generateCertForHost(hostname) { const attrs = [{ name: "commonName", value: hostname }]; cert.setSubject(attrs); cert.setIssuer(ca.certificate.subject.attributes); - const authorityKeyIdentifier = createKeyIdentifier(ca.certificate.publicKey); cert.setExtensions([ { name: "subjectAltName", @@ -59,44 +44,6 @@ export function generateCertForHost(hostname) { digitalSignature: true, keyEncipherment: true, }, - { - /* - Extended Key Usage (EKU) serverAuth extension - - Needed for TLS server authentication. This extension indicates the certificate's - public key may be used for TLS WWW server authentication. - Python virtualenv environments (like pipx-installed Poetry) enforce this strictly - https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.12 - */ - name: "extKeyUsage", - serverAuth: true, - }, - { - /* - Subject Key Identifier (SKI) - - Needed for Python virtualenv SSL validation and certificate chain building. - This extension provides a means of identifying certificates containing a particular public key. - Python virtualenv environments require this for proper certificate chain validation. - System Python installations may be more lenient. - https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.2 - */ - name: "subjectKeyIdentifier", - subjectKeyIdentifier: createKeyIdentifier(cert.publicKey), - }, - { - /* - Authority Key Identifier (AKI) - - Needed for Python virtualenv SSL validation and certificate path validation. - This extension identifies the public key corresponding to the private key used to sign - this certificate. It links this certificate to its issuing CA certificate. - Without this, Python virtualenv certificate validation might fail (for instance for Poetry) - https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.1 - */ - name: "authorityKeyIdentifier", - keyIdentifier: authorityKeyIdentifier, - }, ]); cert.sign(ca.privateKey, forge.md.sha256.create()); @@ -111,7 +58,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"); @@ -146,13 +92,11 @@ function generateCa() { const attrs = [{ name: "commonName", value: "safe-chain proxy" }]; cert.setSubject(attrs); - cert.setIssuer(attrs); // Self-signed: issuer === subject - const keyIdentifier = createKeyIdentifier(cert.publicKey); + cert.setIssuer(attrs); cert.setExtensions([ { name: "basicConstraints", cA: true, - critical: true, // Marking basicConstraints as critical is required for CA certificates so clients must process it to trust the cert as a CA }, { name: "keyUsage", @@ -160,14 +104,6 @@ function generateCa() { digitalSignature: true, keyEncipherment: true, }, - { - name: "subjectKeyIdentifier", - subjectKeyIdentifier: keyIdentifier, - }, - { - name: "authorityKeyIdentifier", - keyIdentifier, - }, ]); cert.sign(keys.privateKey, forge.md.sha256.create()); diff --git a/packages/safe-chain/src/registryProxy/certUtils.spec.js b/packages/safe-chain/src/registryProxy/certUtils.spec.js deleted file mode 100644 index 4bf8c95..0000000 --- a/packages/safe-chain/src/registryProxy/certUtils.spec.js +++ /dev/null @@ -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", - ); - }); -}); diff --git a/packages/safe-chain/src/registryProxy/getConnectTimeout.js b/packages/safe-chain/src/registryProxy/getConnectTimeout.js deleted file mode 100644 index 2945be4..0000000 --- a/packages/safe-chain/src/registryProxy/getConnectTimeout.js +++ /dev/null @@ -1,13 +0,0 @@ -import { isImdsEndpoint } from "./isImdsEndpoint.js"; - -/** - * Returns appropriate connection timeout for a host. - * - IMDS endpoints: 3s (fail fast when outside cloud, reduce 5min delay to ~20s) - * - Other endpoints: 30s (allow for slow networks while preventing indefinite hangs) - */ -export function getConnectTimeout(/** @type {string} */ host) { - if (isImdsEndpoint(host)) { - return 3000; - } - return 30000; -} diff --git a/packages/safe-chain/src/registryProxy/http-utils.js b/packages/safe-chain/src/registryProxy/http-utils.js deleted file mode 100644 index 8e2f8e2..0000000 --- a/packages/safe-chain/src/registryProxy/http-utils.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @param {NodeJS.Dict | undefined} headers - * @param {string} headerName - */ -export function getHeaderValueAsString(headers, headerName) { - if (!headers) { - return undefined; - } - - let header = headers[headerName]; - - if (Array.isArray(header)) { - return header.join(", "); - } - - return header; -} - -/** - * Returns a copy of headers without the provided header names, matched - * either exactly or case-insensitively. - * - * @param {NodeJS.Dict | undefined} headers - * @param {string[]} headerNames - * @param {{ caseInsensitive?: boolean }} [options] - * @returns {NodeJS.Dict | 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} */ - 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 | 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); -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js b/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js deleted file mode 100644 index 869af81..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +++ /dev/null @@ -1,25 +0,0 @@ -import { - ECOSYSTEM_JS, - ECOSYSTEM_PY, - getEcoSystem, -} from "../../config/settings.js"; -import { npmInterceptorForUrl } from "./npm/npmInterceptor.js"; -import { pipInterceptorForUrl } from "./pip/pipInterceptor.js"; - -/** - * @param {string} url - * @returns {import("./interceptorBuilder.js").Interceptor | undefined} - */ -export function createInterceptorForUrl(url) { - const ecosystem = getEcoSystem(); - - if (ecosystem === ECOSYSTEM_JS) { - return npmInterceptorForUrl(url); - } - - if (ecosystem === ECOSYSTEM_PY) { - return pipInterceptorForUrl(url); - } - - return undefined; -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js deleted file mode 100644 index fbfc131..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js +++ /dev/null @@ -1,179 +0,0 @@ -import { EventEmitter } from "events"; - -/** - * @typedef {Object} Interceptor - * @property {(targetUrl: string) => Promise} handleRequest - * @property {(event: string, listener: (...args: any[]) => void) => Interceptor} on - * @property {(event: string, ...args: any[]) => boolean} emit - * - * - * @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) => NodeJS.Dict) => void} modifyRequestHeaders - * @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict | undefined) => Buffer) => void} modifyBody - * @property {() => RequestInterceptionHandler} build - * - * - * @typedef {Object} RequestInterceptionHandler - * @property {{statusCode: number, message: string} | undefined} blockResponse - * @property {(headers: NodeJS.Dict | undefined) => NodeJS.Dict | undefined} modifyRequestHeaders - * @property {() => boolean} modifiesResponse - * @property {(body: Buffer, headers: NodeJS.Dict | undefined) => Buffer} modifyBody - * - * @typedef {Object} MalwareBlockedEvent - * @property {string} packageName - * @property {string} version - * @property {string} targetUrl - * @property {number} timestamp - * - * @typedef {Object} MinimumAgeRequestBlockedEvent - * @property {string} packageName - * @property {string} version - * @property {string} targetUrl - * @property {number} timestamp - */ - -/** - * @param {(requestHandlerBuilder: RequestInterceptionContext) => Promise} requestInterceptionFunc - * @returns {Interceptor} - */ -export function interceptRequests(requestInterceptionFunc) { - return buildInterceptor([requestInterceptionFunc]); -} - -/** - * @param {Array<(requestHandlerBuilder: RequestInterceptionContext) => Promise>} requestHandlers - * @returns {Interceptor} - */ -function buildInterceptor(requestHandlers) { - const eventEmitter = new EventEmitter(); - - return { - async handleRequest(targetUrl) { - const requestContext = createRequestContext(targetUrl, eventEmitter); - - for (const handler of requestHandlers) { - await handler(requestContext); - } - - return requestContext.build(); - }, - on(event, listener) { - eventEmitter.on(event, listener); - return this; - }, - emit(event, ...args) { - return eventEmitter.emit(event, ...args); - }, - }; -} - -/** - * @param {string} targetUrl - * @param {import('events').EventEmitter} eventEmitter - * @returns {RequestInterceptionContext} - */ -function createRequestContext(targetUrl, eventEmitter) { - /** @type {{statusCode: number, message: string} | undefined} */ - let blockResponse = undefined; - /** @type {Array<(headers: NodeJS.Dict) => NodeJS.Dict>} */ - let reqheaderModificationFuncs = []; - /** @type {Array<(body: Buffer, headers: NodeJS.Dict | undefined) => Buffer>} */ - let modifyBodyFuncs = []; - - /** - * @param {string | undefined} packageName - * @param {string | undefined} version - */ - function blockMalwareSetup(packageName, version) { - blockResponse = createBlockResponse("Forbidden - blocked by safe-chain"); - - // Emit the malwareBlocked event - eventEmitter.emit("malwareBlocked", { - packageName, - version, - targetUrl, - timestamp: Date.now(), - }); - } - - /** - * @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() { - /** - * @param {NodeJS.Dict | undefined} headers - * @returns {NodeJS.Dict | undefined} - */ - function modifyRequestHeaders(headers) { - if (headers) { - for (const func of reqheaderModificationFuncs) { - func(headers); - } - } - - return headers; - } - - /** - * @param {Buffer} body - * @param {NodeJS.Dict | undefined} headers - * @returns {Buffer} - */ - function modifyBody(body, headers) { - let modifiedBody = body; - - for (var func of modifyBodyFuncs) { - modifiedBody = func(body, headers); - } - - return modifiedBody; - } - - // These functions are invoked in the proxy, allowing to apply the configured modifications - return { - blockResponse, - modifyRequestHeaders: modifyRequestHeaders, - modifiesResponse: () => modifyBodyFuncs.length > 0, - modifyBody, - }; - } - - // These functions are used to setup the modifications - return { - targetUrl, - blockMalware: blockMalwareSetup, - blockMinimumAgeRequest: blockMinimumAgeRequestSetup, - modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func), - modifyBody: (func) => modifyBodyFuncs.push(func), - build, - }; -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/minimumPackageAgeExclusions.js b/packages/safe-chain/src/registryProxy/interceptors/minimumPackageAgeExclusions.js deleted file mode 100644 index 05a86ea..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/minimumPackageAgeExclusions.js +++ /dev/null @@ -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)) - ); -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js deleted file mode 100644 index 26b3b70..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ /dev/null @@ -1,180 +0,0 @@ -import { getMinimumPackageAgeHours } from "../../../config/settings.js"; -import { ui } from "../../../environment/userInteraction.js"; -import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js"; -import { recordSuppressedVersion } from "../suppressedVersionsState.js"; - -/** - * @param {NodeJS.Dict} headers - * @returns {NodeJS.Dict} - */ -export function modifyNpmInfoRequestHeaders(headers) { - const accept = getHeaderValueAsString(headers, "accept"); - if (accept?.includes("application/vnd.npm.install-v1+json")) { - // The npm registry sometimes serves a more compact format that lacks - // the time metadata we need to filter out too new packages. - // Force the registry to return the full metadata by changing the Accept header. - headers["accept"] = "application/json"; - } - return headers; -} - -/** - * @param {string} url - * @returns {boolean} - */ -export function isPackageInfoUrl(url) { - // Remove query string and fragment to get the actual path - const urlWithoutParams = url.split("?")[0].split("#")[0]; - - // Tarball downloads end with .tgz - if (urlWithoutParams.endsWith(".tgz")) return false; - - // Special endpoints start with /-/ and should not be modified - // Examples: /-/npm/v1/security/advisories/bulk, /-/v1/search, /-/package/foo/access - if (urlWithoutParams.includes("/-/")) return false; - - // Everything else is package metadata that can be modified - return true; -} -/** - * - * @param {Buffer} body - * @param {NodeJS.Dict | undefined} headers - * @returns Buffer - */ -export function modifyNpmInfoResponse(body, headers) { - try { - const contentType = getHeaderValueAsString(headers, "content-type"); - if (!contentType?.toLowerCase().includes("application/json")) { - return body; - } - - if (body.byteLength === 0) { - return body; - } - - // utf-8 is default encoding for JSON, so we don't check if charset is defined in content-type header - const bodyContent = body.toString("utf8"); - const bodyJson = JSON.parse(bodyContent); - - if (!bodyJson.time || !bodyJson["dist-tags"] || !bodyJson.versions) { - // Just return the current body if the format is not - return body; - } - - const cutOff = new Date( - new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 - ); - - const hasLatestTag = !!bodyJson["dist-tags"]["latest"]; - - const versions = Object.entries(bodyJson.time) - .map(([version, timestamp]) => ({ - version, - timestamp, - })) - .filter((x) => x.version !== "created" && x.version !== "modified"); - - for (const { version, timestamp } of versions) { - const timestampValue = new Date(timestamp); - if (timestampValue > cutOff) { - deleteVersionFromJson(bodyJson, version); - clearCachingHeaders(headers); - } - } - - if (hasLatestTag && !bodyJson["dist-tags"]["latest"]) { - // The latest tag was removed because it contained a package younger than the treshold. - // A new latest tag needs to be calculated - bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson.time); - } - - return Buffer.from(JSON.stringify(bodyJson)); - } catch (/** @type {any} */ err) { - ui.writeVerbose( - `Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}` - ); - return body; - } -} - -/** - * @param {any} json - * @param {string} version - */ -function deleteVersionFromJson(json, version) { - recordSuppressedVersion(); - - const packageName = typeof json?.name === "string" ? json.name : "(unknown)"; - - ui.writeVerbose( - `Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).` - ); - - delete json.time[version]; - delete json.versions[version]; - - for (const [tag, distVersion] of Object.entries(json["dist-tags"])) { - if (version == distVersion) { - delete json["dist-tags"][tag]; - } - } -} - -/** - * @param {Record} tagList - * @returns {string | undefined} - */ -function calculateLatestTag(tagList) { - const entries = Object.entries(tagList).filter( - ([version, _]) => version !== "created" && version !== "modified" - ); - - const latestFullRelease = getMostRecentTag( - Object.fromEntries(entries.filter(([version, _]) => !version.includes("-"))) - ); - if (latestFullRelease) { - return latestFullRelease; - } - - const latestPrerelease = getMostRecentTag( - Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))) - ); - return latestPrerelease; -} - -/** - * @param {Record} tagList - * @returns {string | undefined} - */ -function getMostRecentTag(tagList) { - let current, currentDate; - - for (const [version, timestamp] of Object.entries(tagList)) { - if (!currentDate || currentDate < timestamp) { - current = version; - currentDate = timestamp; - } - } - - return current; -} - -/** - * @param {Buffer} body - * @param {NodeJS.Dict | undefined} headers - * @returns {string | undefined} - */ -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; - } -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js deleted file mode 100644 index 8caae84..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ /dev/null @@ -1,101 +0,0 @@ -import { - getNpmCustomRegistries, - 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", -]; - -/** - * @param {string} url - * @returns {import("../interceptorBuilder.js").Interceptor | undefined} - */ -export function npmInterceptorForUrl(url) { - const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find( - (reg) => url.includes(reg) - ); - - if (registry) { - return buildNpmInterceptor(registry); - } - - return undefined; -} - -/** - * @param {string} registry - * @returns {import("../interceptorBuilder.js").Interceptor} - */ -function buildNpmInterceptor(registry) { - return interceptRequests(async (reqContext) => { - const { packageName, version } = parseNpmPackageUrl( - reqContext.targetUrl, - registry - ); - const minimumAgeChecksEnabled = !skipMinimumPackageAge(); - - if (await isMalwarePackage(packageName, version)) { - reqContext.blockMalware(packageName, version); - return; - } - - if (minimumAgeChecksEnabled && 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})` - ); - } - } - }); -} - -/** - * @param {Buffer} body - * @param {NodeJS.Dict | undefined} headers - * @returns {Buffer} - */ -function modifyNpmInfoResponseUnlessExcluded(body, headers) { - const metadataPackageName = getPackageNameFromMetadataResponse(body, headers); - - if ( - metadataPackageName && - isExcludedFromMinimumPackageAge(metadataPackageName) - ) { - return body; - } - - return modifyNpmInfoResponse(body, headers); -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js deleted file mode 100644 index cdd38ef..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ /dev/null @@ -1,667 +0,0 @@ -import { describe, it, mock } from "node:test"; -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}`), - }), - }, - }); - - mock.module("../../../scanning/audit/index.js", { - namedExports: { - isMalwarePackage: async () => { - return false; - }, - }, - }); - mock.module("../../../environment/userInteraction.js", { - namedExports: { - ui: { - startProcess: () => {}, - writeError: () => {}, - writeInformation: () => {}, - writeWarning: () => {}, - writeVerbose: () => {}, - writeExitWithoutInstallingMaliciousPackages: () => {}, - emptyLine: () => {}, - }, - }, - }); - const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); - - for (const packageInfoUrl of [ - // Basic package metadata - "https://registry.npmjs.org/lodash", - "https://registry.npmjs.org/express", - // Scoped packages - "https://registry.npmjs.org/@vercel/functions", - "https://registry.npmjs.org/@babel/core", - "https://registry.npmjs.org/@types/node", - // With query parameters - "https://registry.npmjs.org/lodash?write=true", - "https://registry.npmjs.org/@babel/core?param=value&other=test", - // With fragments - "https://registry.npmjs.org/lodash#readme", - "https://registry.npmjs.org/@babel/core#installation", - // Version-specific metadata - "https://registry.npmjs.org/lodash/4.17.21", - "https://registry.npmjs.org/lodash/latest", - "https://registry.npmjs.org/@babel/core/7.21.4", - // URL-encoded scoped packages - "https://registry.npmjs.org/@types%2Fnode", - "https://registry.npmjs.org/@babel%2Fcore", - // With trailing slashes - "https://registry.npmjs.org/lodash/", - "https://registry.npmjs.org/@babel/core/", - ]) { - it(`modifyResponse should be true for package info requests: ${packageInfoUrl}`, async () => { - const interceptor = npmInterceptorForUrl(packageInfoUrl); - const requestInterceptor = await interceptor.handleRequest( - packageInfoUrl - ); - - assert.equal(requestInterceptor.modifiesResponse(), true); - }); - } - - for (const packageUrl of [ - // Regular package tarballs - "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - // Scoped package tarballs - "https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz", - "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", - // Tarballs with query parameters (integrity checks) - "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123", - "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz?integrity=sha512-def456&cache=false", - // Tarballs with fragments - "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#sha512-abc123", - "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz#hash", - // Prerelease versions - "https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz", - "https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz", - ]) { - it(`modifyResponse should be false for package downloads: ${packageUrl}`, async () => { - const interceptor = npmInterceptorForUrl(packageUrl); - const requestInterceptor = await interceptor.handleRequest(packageUrl); - - assert.equal(requestInterceptor.modifiesResponse(), false); - }); - } - - for (const specialEndpoint of [ - // Security advisory endpoints - "https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", - "https://registry.npmjs.org/-/npm/v1/security/audits", - "https://registry.npmjs.org/-/npm/v1/security/audits/quick", - // Search endpoints - "https://registry.npmjs.org/-/v1/search?text=lodash&size=20", - "https://registry.npmjs.org/-/v1/search?text=react&from=0", - // Package access/collaboration endpoints - "https://registry.npmjs.org/-/package/lodash/access", - "https://registry.npmjs.org/-/package/@babel/core/collaborators", - "https://registry.npmjs.org/-/package/lodash/dist-tags", - "https://registry.npmjs.org/-/package/@babel/core/dist-tags/latest", - // User/organization endpoints - "https://registry.npmjs.org/-/user/org.couchdb.user:username", - "https://registry.npmjs.org/-/org/myorg/package", - // Anonymous metrics - "https://registry.npmjs.org/-/npm/anon-metrics/v1/", - // Ping/health check - "https://registry.npmjs.org/-/ping", - ]) { - it(`modifyResponse should be false for special endpoints: ${specialEndpoint}`, async () => { - const interceptor = npmInterceptorForUrl(specialEndpoint); - const requestInterceptor = await interceptor.handleRequest( - specialEndpoint - ); - - assert.equal(requestInterceptor.modifiesResponse(), false); - }); - } - - it("Should remove packages older than the treshold", async () => { - minimumPackageAgeSettings = 5; - 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"]: {}, - ["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), - }, - }) - ); - - const modifiedJson = JSON.parse(modifiedBody); - - assert.equal(Object.keys(modifiedJson.time).length, 3); - assert.equal(Object.keys(modifiedJson.versions).length, 1); - assert.ok(Object.keys(modifiedJson.time).includes("1.0.0")); - assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); - assert.ok(!Object.keys(modifiedJson.time).includes("2.0.0")); - assert.ok(!Object.keys(modifiedJson.versions).includes("2.0.0")); - assert.ok(!Object.keys(modifiedJson.time).includes("3.0.0")); - assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0")); - }); - - it("Should set the package to the new latest non-preview release", async () => { - minimumPackageAgeSettings = 5; - 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"]: {}, - ["2.0.0"]: {}, - ["3.0.0"]: {}, - }, - time: { - created: getDate(-365 * 24), - modified: getDate(-3), - ["1.0.0"]: getDate(-7), - ["0.0.1"]: getDate(-8), // package order: this package is older than 1.0.0, it should not be considered latest - ["2.0.0-alpha"]: getDate(-6), //package is a pre-release, it should not be latest - // cutoff-date here - ["2.0.0"]: getDate(-4), - ["3.0.0"]: getDate(-3), - }, - }) - ); - - const modifiedJson = JSON.parse(modifiedBody); - - assert.equal(modifiedJson["dist-tags"]["latest"], "1.0.0"); - }); - - it("Should remove dist-tags if version was removed", async () => { - minimumPackageAgeSettings = 5; - const packageUrl = "https://registry.npmjs.org/lodash"; - - const modifiedBody = await runModifyNpmInfoRequest( - packageUrl, - JSON.stringify({ - name: "lodash", - ["dist-tags"]: { - latest: "3.0.0", - alpha: "2.0.0-alpha", - }, - versions: { - ["1.0.0"]: {}, - ["2.0.0"]: {}, - ["3.0.0"]: {}, - }, - time: { - created: getDate(-365 * 24), - modified: getDate(-4), - ["1.0.0"]: getDate(-7), - // cutoff-date here - ["2.0.0-alpha"]: getDate(-4), - }, - }) - ); - - const modifiedJson = JSON.parse(modifiedBody); - console.log(modifiedJson); - - assert.equal(modifiedJson["dist-tags"]["alpha"], undefined); - }); - - it("Should not filter packages when skipMinimumPackageAge is enabled", async () => { - minimumPackageAgeSettings = 5; - skipMinimumPackageAgeSetting = true; - 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), - }, - }); - - const modifiedBody = await runModifyNpmInfoRequest( - packageUrl, - originalBody - ); - - const modifiedJson = JSON.parse(modifiedBody); - - // All versions should remain unchanged - 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")); - - // Latest should remain unchanged - assert.equal(modifiedJson["dist-tags"]["latest"], "3.0.0"); - }); - - it("Should use custom minimum package age of 48 hours", async () => { - minimumPackageAgeSettings = 48; - skipMinimumPackageAgeSetting = false; - const packageUrl = "https://registry.npmjs.org/lodash"; - - const modifiedBody = await runModifyNpmInfoRequest( - packageUrl, - JSON.stringify({ - name: "lodash", - ["dist-tags"]: { - latest: "4.0.0", - }, - versions: { - ["1.0.0"]: {}, - ["2.0.0"]: {}, - ["3.0.0"]: {}, - ["4.0.0"]: {}, - }, - time: { - created: getDate(-365 * 24), - modified: getDate(-24), - ["1.0.0"]: getDate(-72), // 3 days old - should remain - ["2.0.0"]: getDate(-50), // ~2 days old - should remain - // 48-hour cutoff here - ["3.0.0"]: getDate(-40), // ~1.7 days old - should be removed - ["4.0.0"]: getDate(-24), // 1 day old - should be removed - }, - }) - ); - - const modifiedJson = JSON.parse(modifiedBody); - - // Versions older than 48 hours should remain - assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); - assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0")); - - // Versions newer than 48 hours should be removed - assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0")); - assert.ok(!Object.keys(modifiedJson.versions).includes("4.0.0")); - - // Latest should be recalculated to 2.0.0 - assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0"); - - assert.equal(Object.keys(modifiedJson.versions).length, 2); - }); - - it("Should use very small minimum package age of 1 hour", async () => { - minimumPackageAgeSettings = 1; - skipMinimumPackageAgeSetting = false; - 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"]: {}, - ["2.0.0"]: {}, - ["3.0.0"]: {}, - }, - time: { - created: getDate(-48), - modified: getDate(0), - ["1.0.0"]: getDate(-3), // 3 hours old - should remain - ["2.0.0"]: getDate(-2), // 2 hours old - should remain - // 1-hour cutoff here - ["3.0.0"]: getDate(0), // just published - should be removed - }, - }) - ); - - const modifiedJson = JSON.parse(modifiedBody); - - 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")); - assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0")); - 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); - - return date; - } - - /** - * @param {import("../interceptorBuilder.js").Interceptor} interceptor - * @param {string} body - * @returns {Promise} - */ - async function runModifyNpmInfoRequest(url, body) { - const interceptor = npmInterceptorForUrl(url); - const requestHandler = await interceptor.handleRequest(url); - - if (requestHandler.modifiesResponse()) { - const modifiedBuffer = requestHandler.modifyBody(Buffer.from(body), { - ["content-type"]: "application/json", - }); - return modifiedBuffer.toString("utf8"); - } - - return body; - } -}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js deleted file mode 100644 index 769b6e1..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ /dev/null @@ -1,320 +0,0 @@ -import { describe, it, mock, beforeEach } from "node:test"; -import assert from "node:assert"; - -let lastPackage; -let malwareResponse = false; -let customRegistries = []; -let newlyReleasedPackages = new Set(); -let skipMinimumPackageAgeSetting = false; - -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 parserCases = [ - // Regular packages - { - url: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - expected: { packageName: "lodash", version: "4.17.21" }, - }, - { - url: "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - expected: { packageName: "express", version: "4.18.2" }, - }, - // Packages with hyphens in name - { - url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-1.0.0.tgz", - expected: { packageName: "safe-chain-test", version: "1.0.0" }, - }, - { - url: "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.0.tgz", - expected: { packageName: "web-vitals", version: "3.5.0" }, - }, - // Preview/prerelease versions - { - url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-0.0.1-security.tgz", - expected: { packageName: "safe-chain-test", version: "0.0.1-security" }, - }, - { - url: "https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz", - expected: { packageName: "lodash", version: "5.0.0-beta.1" }, - }, - { - url: "https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz", - expected: { packageName: "react", version: "18.3.0-canary-abc123" }, - }, - // Scoped packages - { - url: "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz", - expected: { packageName: "@babel/core", version: "7.21.4" }, - }, - { - url: "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", - expected: { packageName: "@types/node", version: "20.10.5" }, - }, - { - url: "https://registry.npmjs.org/@angular/common/-/common-17.0.8.tgz", - expected: { packageName: "@angular/common", version: "17.0.8" }, - }, - // Scoped packages with hyphens - { - url: "https://registry.npmjs.org/@safe-chain/test-package/-/test-package-2.1.0.tgz", - expected: { packageName: "@safe-chain/test-package", version: "2.1.0" }, - }, - { - url: "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.465.0.tgz", - expected: { packageName: "@aws-sdk/client-s3", version: "3.465.0" }, - }, - // Scoped packages with preview versions - { - url: "https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz", - expected: { packageName: "@babel/core", version: "8.0.0-alpha.1" }, - }, - { - url: "https://registry.npmjs.org/@safe-chain/security-test/-/security-test-1.0.0-security.tgz", - expected: { - packageName: "@safe-chain/security-test", - version: "1.0.0-security", - }, - }, - // Yarn registry - { - url: "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz", - expected: { packageName: "lodash", version: "4.17.21" }, - }, - { - 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", - expected: { packageName: undefined, version: undefined }, - }, - // Complex version patterns - { - url: "https://registry.npmjs.org/package-with-many-hyphens/-/package-with-many-hyphens-1.0.0-rc.1+build.123.tgz", - expected: { - packageName: "package-with-many-hyphens", - version: "1.0.0-rc.1+build.123", - }, - }, - { - url: "https://registry.npmjs.org/@scope/package-name-with-hyphens/-/package-name-with-hyphens-2.0.0-beta.2.tgz", - expected: { - packageName: "@scope/package-name-with-hyphens", - version: "2.0.0-beta.2", - }, - }, - ]; - - parserCases.forEach(({ url, expected }, index) => { - it(`should parse URL ${index + 1}: ${url}`, async () => { - const interceptor = npmInterceptorForUrl(url); - assert.ok( - interceptor, - "Interceptor should be created for known npm registry" - ); - - await interceptor.handleRequest(url); - - assert.deepEqual(lastPackage, expected); - }); - }); - - it("should not create interceptor for unknown registry", () => { - const url = "https://example.com/some-package/-/some-package-1.0.0.tgz"; - - const interceptor = npmInterceptorForUrl(url); - - assert.equal( - interceptor, - undefined, - "Interceptor should be undefined for unknown registry" - ); - }); - - it("should block malicious package", async () => { - const url = - "https://registry.npmjs.org/malicious-package/-/malicious-package-1.0.0.tgz"; - malwareResponse = true; - - const interceptor = npmInterceptorForUrl(url); - - const result = await interceptor.handleRequest(url); - - 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", - "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" - ); - }); -}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js deleted file mode 100644 index ef0ab18..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js +++ /dev/null @@ -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} headers - * @returns {NodeJS.Dict} - */ -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 = - /]*href\s*=\s*(["'])([^"']+)\1[^>]*>[\s\S]*?<\/a>/gi; - -/** - * @param {Buffer} body - * @param {NodeJS.Dict | 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 | 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 | 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; -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js deleted file mode 100644 index 900941d..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js +++ /dev/null @@ -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(` - - - - requests-1.0.0.tar.gz - requests-2.0.0.tar.gz - - - `); - - 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( - `requests-1.0.0.tar.gz` - ); - - 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( - `foo_bar-2.0.0.tar.gz` + - `foo_bar-1.0.0.tar.gz` - ); - - 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(` - - foo_bar-2.0.0.tar.gz - - foo_bar-1.0.0.tar.gz - `); - - 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"); - }); -}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js deleted file mode 100644 index e005237..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js +++ /dev/null @@ -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); -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js deleted file mode 100644 index da3d29f..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js +++ /dev/null @@ -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 }; -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.spec.js deleted file mode 100644 index 1345dd4..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.spec.js +++ /dev/null @@ -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 } - ); - }); -}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js deleted file mode 100644 index 5904f05..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js +++ /dev/null @@ -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" - ) - ); - }); -}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js deleted file mode 100644 index 86d84eb..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js +++ /dev/null @@ -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} - */ -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})` - ); - } - } - }; -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js deleted file mode 100644 index f311df7..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js +++ /dev/null @@ -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(` - foo_bar-1.0.0.tar.gz - foo_bar-2.0.0.tar.gz - `), - { - "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; - }); -}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js deleted file mode 100644 index f4a54a4..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, it, mock } from "node:test"; -import assert from "node:assert"; - -describe("pipInterceptor", async () => { - let scannedPackages; - let malwareResponse = false; - - mock.module("../../../scanning/audit/index.js", { - namedExports: { - isMalwarePackage: async (packageName, version) => { - scannedPackages.push({ 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 = [ - { - url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz", - expected: { packageName: "foobar", version: "1.2.3" }, - }, - { - url: "https://pypi.org/packages/source/f/foobar/foobar-1.2.3.tar.gz", - expected: { packageName: "foobar", version: "1.2.3" }, - }, - { - url: "https://pypi.org/packages/source/f/foo-bar/foo-bar-0.9.0.tar.gz", - expected: { packageName: "foo-bar", version: "0.9.0" }, - }, - { - url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-py3-none-any.whl", - expected: { packageName: "foo-bar", version: "2.0.0" }, - }, - { - 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" }, - }, - { - url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl", - expected: { packageName: "foo-bar", version: "2.0.0" }, - }, - { - url: "https://pypi.org/packages/source/f/foo.bar/foo.bar-1.0.0.tar.gz", - expected: { packageName: "foo.bar", version: "1.0.0" }, - }, - { - url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0b1.tar.gz", - expected: { packageName: "foo-bar", version: "2.0.0b1" }, - }, - { - url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz.metadata", - expected: { packageName: "foo-bar", version: "2.0.0" }, - }, - { - url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0rc1.tar.gz", - expected: { packageName: "foo-bar", version: "2.0.0rc1" }, - }, - { - url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.post1.tar.gz", - expected: { packageName: "foo-bar", version: "2.0.0.post1" }, - }, - { - url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.dev1.tar.gz", - expected: { packageName: "foo-bar", version: "2.0.0.dev1" }, - }, - { - url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz", - expected: { packageName: "foo-bar", version: "2.0.0a1" }, - }, - { - 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" }, - }, - { - url: "https://pypi.org/simple/", - expected: { packageName: undefined, version: undefined }, - }, - { - url: "https://pypi.org/project/foobar/", - expected: { packageName: undefined, version: undefined }, - }, - { - url: "https://files.pythonhosted.org/packages/xx/yy/foobar-latest.tar.gz", - expected: { packageName: undefined, version: undefined }, - }, - { - url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-latest.tar.gz", - expected: { packageName: undefined, version: undefined }, - }, - ]; - - 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"); - - await interceptor.handleRequest(url); - - if (expected.packageName === undefined) { - assert.deepEqual(scannedPackages, []); - return; - } - - assert.ok( - scannedPackages.some( - ({ packageName, version }) => - packageName === expected.packageName && - version === expected.version - ) - ); - }); - }); - - 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); - }); - - 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.equal( - result.blockResponse.message, - "Forbidden - blocked by safe-chain" - ); - - malwareResponse = false; - }); -}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js deleted file mode 100644 index e394810..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js +++ /dev/null @@ -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 | 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).` - ); -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js deleted file mode 100644 index 4ccb953..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js +++ /dev/null @@ -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); -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/suppressedVersionsState.js b/packages/safe-chain/src/registryProxy/interceptors/suppressedVersionsState.js deleted file mode 100644 index 26c0559..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/suppressedVersionsState.js +++ /dev/null @@ -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; -} diff --git a/packages/safe-chain/src/registryProxy/isImdsEndpoint.js b/packages/safe-chain/src/registryProxy/isImdsEndpoint.js deleted file mode 100644 index deccf10..0000000 --- a/packages/safe-chain/src/registryProxy/isImdsEndpoint.js +++ /dev/null @@ -1,13 +0,0 @@ -// Instance Metadata Service (IMDS) endpoints used by cloud providers. -// Cloud SDK tools probe these to detect environment and retrieve credentials. -// When outside cloud environments, connections timeout - we reduce timeout (3s vs 30s) -// and suppress error logging since this is expected behavior. -const imdsEndpoints = [ - "metadata.google.internal", - "metadata.goog", - "169.254.169.254", // AWS, Azure, Oracle Cloud, GCP -]; - -export function isImdsEndpoint(/** @type {string} */ host) { - return imdsEndpoints.includes(host); -} diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 4c4e9ec..4be9987 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -1,42 +1,11 @@ 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"; -/** - * @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor - */ +export function mitmConnect(req, clientSocket, isAllowed) { + const { hostname } = new URL(`http://${req.url}`); -/** - * @param {import("http").IncomingMessage} req - * @param {import("http").ServerResponse} clientSocket - * @param {Interceptor} interceptor - */ -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}`); - - clientSocket.on("error", (err) => { - ui.writeVerbose( - `Safe-chain: Client socket error for ${req.url}: ${err.message}` - ); - // NO-OP - // This can happen if the client TCP socket sends RST instead of FIN. - // Not subscribing to 'close' event will cause node to throw and crash. - }); - - const server = createHttpsServer(hostname, port, interceptor); - - server.on("error", (err) => { - ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`); - if (!clientSocket.headersSent) { - clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); - } else if (clientSocket.writable) { - clientSocket.end(); - } - }); + const server = createHttpsServer(hostname, isAllowed); // Establish the connection clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); @@ -45,61 +14,32 @@ export function mitmConnect(req, clientSocket, interceptor) { server.emit("connection", clientSocket); } -/** - * @param {string} hostname - * @param {string} port - * @param {Interceptor} interceptor - * @returns {import("https").Server} - */ -function createHttpsServer(hostname, port, interceptor) { +function createHttpsServer(hostname, isAllowed) { const cert = generateCertForHost(hostname); - /** - * @param {import("http").IncomingMessage} req - * @param {import("http").ServerResponse} res - * - * @returns {Promise} - */ async function handleRequest(req, res) { - if (!req.url) { - ui.writeError("Safe-chain: Request missing URL"); - res.writeHead(400, "Bad Request"); - res.end("Bad Request: Missing URL"); - return; - } - const pathAndQuery = getRequestPathAndQuery(req.url); const targetUrl = `https://${hostname}${pathAndQuery}`; - const requestInterceptor = await interceptor.handleRequest(targetUrl); - const blockResponse = requestInterceptor.blockResponse; - - if (blockResponse) { - ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`); - res.writeHead(blockResponse.statusCode, blockResponse.message); - res.end(blockResponse.message); + if (!(await isAllowed(targetUrl))) { + res.writeHead(403, "Forbidden - blocked by safe-chain"); + res.end("Blocked by safe-chain"); return; } // Collect request body - forwardRequest(req, hostname, port, res, requestInterceptor); + forwardRequest(req, hostname, res); } - const server = https.createServer( + return https.createServer( { key: cert.privateKey, cert: cert.certificate, }, handleRequest ); - - return server; } -/** - * @param {string} url - * @returns {string} - */ function getRequestPathAndQuery(url) { if (url.startsWith("http://") || url.startsWith("https://")) { const parsedUrl = new URL(url); @@ -108,132 +48,42 @@ function getRequestPathAndQuery(url) { return 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) { + const proxyReq = createProxyRequest(hostname, req, res); - proxyReq.on("error", (err) => { - ui.writeVerbose( - `Safe-chain: Error occurred while proxying request to ${req.url} for ${hostname}: ${err.message}` - ); + proxyReq.on("error", () => { res.writeHead(502); res.end("Bad Gateway"); }); - req.on("error", (err) => { - ui.writeError( - `Safe-chain: Error reading client request to ${req.url} for ${hostname}: ${err.message}` - ); - proxyReq.destroy(); - }); - req.on("data", (chunk) => { proxyReq.write(chunk); }); req.on("end", () => { - ui.writeVerbose( - `Safe-chain: Finished proxying request to ${req.url} for ${hostname}` - ); proxyReq.end(); }); } -/** - * @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) { - /** @type {NodeJS.Dict | undefined} */ - let headers = { ...req.headers }; - // Remove the host header from the incoming request before forwarding. - // Node's http module sets the correct host header for the target hostname automatically. - if (headers.host) { - delete headers.host; - } - headers = requestHandler.modifyRequestHeaders(headers); - - /** @type {import("http").RequestOptions} */ +function createProxyRequest(hostname, req, res) { const options = { hostname: hostname, - port: port || 443, + port: 443, path: req.url, method: req.method, - headers: { ...headers }, + headers: { ...req.headers }, }; + delete options.headers.host; + const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; if (httpsProxy) { options.agent = new HttpsProxyAgent(httpsProxy); } const proxyReq = https.request(options, (proxyRes) => { - proxyRes.on("error", (err) => { - ui.writeError( - `Safe-chain: Error reading upstream response to ${req.url} for ${hostname}: ${err.message}` - ); - if (!res.headersSent) { - res.writeHead(502); - res.end("Bad Gateway"); - } - }); - - if (!proxyRes.statusCode) { - ui.writeError( - `Safe-chain: Proxy response missing status code to ${req.url} for ${hostname}` - ); - res.writeHead(500); - res.end("Internal Server Error"); - return; - } - - const { statusCode, headers } = proxyRes; - - if (requestHandler.modifiesResponse()) { - /** @type {Array} */ - let chunks = []; - - proxyRes.on("data", (chunk) => chunks.push(chunk)); - - proxyRes.on("end", () => { - /** @type {Buffer} */ - let buffer = Buffer.concat(chunks); - - if (proxyRes.headers["content-encoding"] === "gzip") { - buffer = gunzipSync(buffer); - } - - 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); - res.end(buffer); - }); - } else { - // If the response is not being modified, we can - // just pipe without the need for buffering the output - res.writeHead(statusCode, headers); - proxyRes.pipe(res); - } + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); }); return proxyReq; diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.spec.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.spec.js deleted file mode 100644 index de01e2c..0000000 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.spec.js +++ /dev/null @@ -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) - ); - }); -}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js similarity index 58% rename from packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js rename to packages/safe-chain/src/registryProxy/parsePackageFromUrl.js index 13cb99a..7368b35 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js @@ -1,33 +1,21 @@ -/** - * @param {string} url - * @param {string} registry - * @returns {{packageName: string | undefined, version: string | undefined}} - */ -export function parseNpmPackageUrl(url, registry) { - let packageName, version; - let parsedUrl; +export const knownRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; - try { - parsedUrl = new URL(url); - } catch { +export function parsePackageFromUrl(url) { + let packageName, version, registry; + + for (const knownRegistry of knownRegistries) { + if (url.includes(knownRegistry)) { + registry = knownRegistry; + break; + } + } + + 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) { diff --git a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js new file mode 100644 index 0000000..0b8f700 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js @@ -0,0 +1,114 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { parsePackageFromUrl } from "./parsePackageFromUrl.js"; + +describe("parsePackageFromUrl", () => { + const testCases = [ + // Regular packages + { + url: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + expected: { packageName: "lodash", version: "4.17.21" }, + }, + { + url: "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + expected: { packageName: "express", version: "4.18.2" }, + }, + // Packages with hyphens in name + { + url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-1.0.0.tgz", + expected: { packageName: "safe-chain-test", version: "1.0.0" }, + }, + { + url: "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.0.tgz", + expected: { packageName: "web-vitals", version: "3.5.0" }, + }, + // Preview/prerelease versions + { + url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-0.0.1-security.tgz", + expected: { packageName: "safe-chain-test", version: "0.0.1-security" }, + }, + { + url: "https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz", + expected: { packageName: "lodash", version: "5.0.0-beta.1" }, + }, + { + url: "https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz", + expected: { packageName: "react", version: "18.3.0-canary-abc123" }, + }, + // Scoped packages + { + url: "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz", + expected: { packageName: "@babel/core", version: "7.21.4" }, + }, + { + url: "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", + expected: { packageName: "@types/node", version: "20.10.5" }, + }, + { + url: "https://registry.npmjs.org/@angular/common/-/common-17.0.8.tgz", + expected: { packageName: "@angular/common", version: "17.0.8" }, + }, + // Scoped packages with hyphens + { + url: "https://registry.npmjs.org/@safe-chain/test-package/-/test-package-2.1.0.tgz", + expected: { packageName: "@safe-chain/test-package", version: "2.1.0" }, + }, + { + url: "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.465.0.tgz", + expected: { packageName: "@aws-sdk/client-s3", version: "3.465.0" }, + }, + // Scoped packages with preview versions + { + url: "https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz", + expected: { packageName: "@babel/core", version: "8.0.0-alpha.1" }, + }, + { + url: "https://registry.npmjs.org/@safe-chain/security-test/-/security-test-1.0.0-security.tgz", + expected: { + packageName: "@safe-chain/security-test", + version: "1.0.0-security", + }, + }, + // Yarn registry + { + url: "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz", + expected: { packageName: "lodash", version: "4.17.21" }, + }, + { + url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz", + expected: { packageName: "@babel/core", version: "7.21.4" }, + }, + // Invalid URLs should return undefined values + { + url: "https://example.com/package.tgz", + expected: { packageName: undefined, version: undefined }, + }, + // URL to get package info, not tarball + { + url: "https://registry.npmjs.org/lodash", + expected: { packageName: undefined, version: undefined }, + }, + // Complex version patterns + { + url: "https://registry.npmjs.org/package-with-many-hyphens/-/package-with-many-hyphens-1.0.0-rc.1+build.123.tgz", + expected: { + packageName: "package-with-many-hyphens", + version: "1.0.0-rc.1+build.123", + }, + }, + { + url: "https://registry.npmjs.org/@scope/package-name-with-hyphens/-/package-name-with-hyphens-2.0.0-beta.2.tgz", + expected: { + packageName: "@scope/package-name-with-hyphens", + version: "2.0.0-beta.2", + }, + }, + ]; + + testCases.forEach(({ url, expected }, index) => { + it(`should parse URL ${index + 1}: ${url}`, () => { + const result = parsePackageFromUrl(url); + assert.deepEqual(result, expected); + }); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/plainHttpProxy.js b/packages/safe-chain/src/registryProxy/plainHttpProxy.js deleted file mode 100644 index 75b9d77..0000000 --- a/packages/safe-chain/src/registryProxy/plainHttpProxy.js +++ /dev/null @@ -1,95 +0,0 @@ -import * as http from "http"; -import * as https from "https"; -import { ui } from "../environment/userInteraction.js"; - -/** - * @param {import("http").IncomingMessage} req - * @param {import("http").ServerResponse} res - * - * @returns {void} - */ -export function handleHttpProxyRequest(req, res) { - if (!req.url) { - ui.writeError("Safe-chain: Request missing URL"); - res.writeHead(400, "Bad Request"); - res.end("Bad Request: Missing URL"); - return; - } - - const url = new URL(req.url); - - // The protocol for the plainHttpProxy should usually only be http: - // but when the client for some reason sends an https: request directly - // instead of using the CONNECT method, we should handle it gracefully. - let protocol; - if (url.protocol === "http:") { - protocol = http; - } else if (url.protocol === "https:") { - protocol = https; - } else { - res.writeHead(502); - res.end(`Bad Gateway: Unsupported protocol ${url.protocol}`); - return; - } - - const proxyRequest = protocol - .request( - req.url, - { method: req.method, headers: req.headers }, - (proxyRes) => { - if (!proxyRes.statusCode) { - ui.writeError("Safe-chain: Proxy response missing status code"); - res.writeHead(500); - res.end("Internal Server Error"); - return; - } - - res.writeHead(proxyRes.statusCode, proxyRes.headers); - proxyRes.pipe(res); - - proxyRes.on("error", () => { - // Proxy response stream error - // Clean up client response stream - if (res.writable) { - res.end(); - } - }); - - proxyRes.on("close", () => { - // Clean up if the proxy response stream closes - if (res.writable) { - res.end(); - } - }); - } - ) - .on("error", (err) => { - if (!res.headersSent) { - res.writeHead(502); - res.end(`Bad Gateway: ${err.message}`); - } else { - // Headers already sent, just destroy the response - res.destroy(); - } - }); - - req.on("error", () => { - // Client request stream error - // Abort the proxy request - proxyRequest.destroy(); - }); - - res.on("error", () => { - // Client response stream error (client disconnected) - // Clean up proxy streams - proxyRequest.destroy(); - }); - - res.on("close", () => { - // Client disconnected - // Abort the proxy request to avoid unnecessary work - proxyRequest.destroy(); - }); - - req.pipe(proxyRequest); -} diff --git a/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js deleted file mode 100644 index ace84ee..0000000 --- a/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js +++ /dev/null @@ -1,414 +0,0 @@ -import { before, after, describe, it, mock } from "node:test"; -import assert from "node:assert"; -import net from "net"; -import tls from "tls"; - -// Mock isImdsEndpoint BEFORE any other imports that might use it -// This allows us to use TEST-NET-1 (192.0.2.1) as a test IMDS endpoint -const mockIsImdsEndpoint = (host) => { - if (host === "192.0.2.1") return true; - return [ - "metadata.google.internal", - "metadata.goog", - "169.254.169.254", - ].includes(host); -}; - -mock.module("./isImdsEndpoint.js", { - namedExports: { - isImdsEndpoint: mockIsImdsEndpoint, - }, -}); - -// Mock getConnectTimeout to speed up tests -mock.module("./getConnectTimeout.js", { - namedExports: { - getConnectTimeout: (host) => { - // IMDS endpoints: 100ms (real: 3s) - // Other endpoints: 500ms (real: 30s) - return mockIsImdsEndpoint(host) ? 100 : 500; - }, - }, -}); - -// Use dynamic import AFTER mocking to ensure mock is applied -const { createSafeChainProxy, mergeSafeChainProxyEnvironmentVariables } = - await import("./registryProxy.js"); - -describe("registryProxy.connectTunnel", () => { - let proxy, proxyHost, proxyPort; - - before(async () => { - proxy = createSafeChainProxy(); - await proxy.startServer(); - const envVars = mergeSafeChainProxyEnvironmentVariables([]); - const proxyUrl = new URL(envVars.HTTPS_PROXY); - proxyHost = proxyUrl.hostname; - proxyPort = parseInt(proxyUrl.port, 10); - }); - - after(async () => { - await proxy.stopServer(); - }); - - it("should establish a tunnel for HTTP connect", async () => { - const socket = await connectToProxy(proxyHost, proxyPort); - const tunnelResponse = await establishHttpsTunnel( - socket, - "postman-echo.com", - 443 - ); - - assert.ok(tunnelResponse.includes("HTTP/1.1 200 Connection Established")); - socket.destroy(); - }); - - it("should send HTTPS request through the established tunnel", async () => { - const socket = await connectToProxy(proxyHost, proxyPort); - await establishHttpsTunnel(socket, "postman-echo.com", 443); - const httpsResponse = await sendHttpsRequestThroughTunnel( - socket, - "GET", - new URL("https://postman-echo.com/status/200") - ); - - assert.ok(httpsResponse.includes("HTTP/1.1 200 OK")); - - socket.destroy(); - }); - - it("should use destination's real certificate (not safe-chain's self-signed CA)", async () => { - const socket = await connectToProxy(proxyHost, proxyPort); - await establishHttpsTunnel(socket, "postman-echo.com", 443); - - // Verifies that tunnel requests pass through the destination's real certificate - // without interception by the safe-chain MITM proxy. - const certInfo = await getTlsCertificateInfo( - socket, - new URL("https://postman-echo.com") - ); - - // Verify the certificate is NOT issued by our safe-chain CA - // Our self-signed CA would have issuer: "Safe-Chain Proxy CA" - assert.ok( - certInfo.issuer !== undefined, - "Certificate should have an issuer" - ); - assert.ok( - !certInfo.issuer.includes("Safe-Chain"), - `Tunnel should use destination's real certificate, not safe-chain CA. Issuer: ${certInfo.issuer}` - ); - - // Verify it's a real certificate with proper hostname - assert.strictEqual( - certInfo.subject.includes("postman-echo.com"), - true, - `Certificate subject should include postman-echo.com, got: ${certInfo.subject}` - ); - - socket.destroy(); - }); - - describe("Error Handling", () => { - it("should return 502 Bad Gateway for invalid hostname", async () => { - const socket = await connectToProxy(proxyHost, proxyPort); - const connectRequest = `CONNECT invalid.hostname.that.does.not.exist:443 HTTP/1.1\r\nHost: invalid.hostname.that.does.not.exist:443\r\n\r\n`; - socket.write(connectRequest); - - let responseData = ""; - await new Promise((resolve) => { - socket.once("data", (data) => { - responseData += data.toString(); - resolve(); - }); - }); - - assert.ok(responseData.includes("HTTP/1.1 502 Bad Gateway")); - socket.destroy(); - }); - - it("should handle client disconnect during tunnel establishment", async () => { - const socket = await connectToProxy(proxyHost, proxyPort); - const connectRequest = `CONNECT postman-echo.com:443 HTTP/1.1\r\nHost: postman-echo.com:443\r\n\r\n`; - socket.write(connectRequest); - - // Immediately destroy the socket before tunnel is fully established - socket.destroy(); - - // If no crash occurs, the test passes - assert.ok(true); - }); - - it("should handle socket errors without crashing", async () => { - const socket = await connectToProxy(proxyHost, proxyPort); - - socket.on("error", () => { - // Error handler is set to prevent crashes - }); - - const connectRequest = `CONNECT postman-echo.com:443 HTTP/1.1\r\nHost: postman-echo.com:443\r\n\r\n`; - socket.write(connectRequest); - - // Force an error by destroying the socket - socket.destroy(); - - // Wait a bit to ensure error handling completes - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Test passes if no unhandled error crashes the process - assert.ok(true); - }); - }); - - describe("Connection Timeout", () => { - it("should timeout quickly when connecting to IMDS endpoint", async () => { - // We need to make sure we're not running behind an existing safe-chain installation to allow this test to work - const https_proxy = process.env.HTTPS_PROXY; - delete process.env.HTTPS_PROXY; - const socket = await connectToProxy(proxyHost, proxyPort); - const startTime = Date.now(); - - // 192.0.2.1 is TEST-NET-1 (RFC 5737), guaranteed to never route - const connectRequest = `CONNECT 192.0.2.1:443 HTTP/1.1\r\nHost: 192.0.2.1:443\r\n\r\n`; - socket.write(connectRequest); - - let responseData = ""; - await new Promise((resolve) => { - socket.once("data", (data) => { - responseData += data.toString(); - resolve(); - }); - }); - - const duration = Date.now() - startTime; - - // Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts) - assert.ok( - responseData.includes("HTTP/1.1 504 Gateway Timeout"), - "Should return 504 for timeout" - ); - - // Should timeout around 100ms for IMDS endpoints (allow some margin) - assert.ok( - duration >= 80 && duration < 200, - `IMDS timeout should be ~80-200ms, got ${duration}ms` - ); - - socket.destroy(); - if (https_proxy) { - process.env.HTTPS_PROXY = https_proxy; - } - }); - - it("should cache timed-out IMDS endpoints and fail immediately on retry", async () => { - // We need to make sure we're not running behind an existing safe-chain installation to allow this test to work - const https_proxy = process.env.HTTPS_PROXY; - delete process.env.HTTPS_PROXY; - // First connection - will timeout (192.0.2.1 is mocked as IMDS endpoint) - const socket1 = await connectToProxy(proxyHost, proxyPort); - const connectRequest = `CONNECT 192.0.2.1:80 HTTP/1.1\r\nHost: 192.0.2.1:80\r\n\r\n`; - socket1.write(connectRequest); - - await new Promise((resolve) => { - socket1.once("data", () => resolve()); - }); - socket1.destroy(); - - // Second connection - should fail immediately (cached) - const socket2 = await connectToProxy(proxyHost, proxyPort); - const startTime = Date.now(); - socket2.write(connectRequest); - - let responseData = ""; - await new Promise((resolve) => { - socket2.once("data", (data) => { - responseData += data.toString(); - resolve(); - }); - }); - - const duration = Date.now() - startTime; - - // Should return 502 immediately (cached timeout) - assert.ok( - responseData.includes("HTTP/1.1 502 Bad Gateway"), - "Should return 502 for cached timeout" - ); - - // Should be nearly instant (< 50ms) since it's cached - assert.ok( - duration < 50, - `Cached IMDS timeout should be instant, got ${duration}ms` - ); - - socket2.destroy(); - if (https_proxy) { - process.env.HTTPS_PROXY = https_proxy; - } - }); - - it("should NOT cache timed-out non-IMDS endpoints", async () => { - // We need to make sure we're not running behind an existing safe-chain installation to allow this test to work - const https_proxy = process.env.HTTPS_PROXY; - delete process.env.HTTPS_PROXY; - - // 192.0.2.2 is in TEST-NET-1 (RFC 5737) but NOT mocked as IMDS - // It will timeout but should NOT be cached - const connectRequest = `CONNECT 192.0.2.2:443 HTTP/1.1\r\nHost: 192.0.2.2:443\r\n\r\n`; - - // First connection - will timeout - const socket1 = await connectToProxy(proxyHost, proxyPort); - socket1.write(connectRequest); - - await new Promise((resolve) => { - socket1.once("data", () => resolve()); - }); - socket1.destroy(); - - // Second connection - should NOT fail immediately because non-IMDS endpoints are not cached - const socket2 = await connectToProxy(proxyHost, proxyPort); - const startTime = Date.now(); - socket2.write(connectRequest); - - let responseData = ""; - await new Promise((resolve) => { - socket2.once("data", (data) => { - responseData += data.toString(); - resolve(); - }); - }); - - const duration = Date.now() - startTime; - - // Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts) - assert.ok( - responseData.includes("HTTP/1.1 504 Gateway Timeout"), - "Should return 504 for timeout" - ); - - // Should NOT be instant - it should retry the connection (taking ~500ms due to mock timeout) - // If it was cached, it would return in < 50ms - assert.ok( - duration >= 400, - `Non-IMDS timeout should NOT be cached, but got instant response in ${duration}ms` - ); - - socket2.destroy(); - if (https_proxy) { - process.env.HTTPS_PROXY = https_proxy; - } - }); - }); -}); - -function connectToProxy(host, port) { - return new Promise((resolve, reject) => { - const socket = net.connect({ host, port }, () => { - resolve(socket); - }); - - socket.on("error", (err) => { - reject(err); - }); - }); -} - -function establishHttpsTunnel(socket, targetHost, targetPort) { - return new Promise((resolve, reject) => { - const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n\r\n`; - socket.write(connectRequest); - - let responseData = ""; - const onData = (data) => { - responseData += data.toString(); - if (responseData.includes("\r\n\r\n")) { - socket.removeListener("data", onData); - socket.removeListener("error", onError); - resolve(responseData); - } - }; - - const onError = (err) => { - socket.removeListener("data", onData); - socket.removeListener("error", onError); - reject(err); - }; - - socket.on("data", onData); - socket.on("error", onError); - }); -} - -function sendHttpsRequestThroughTunnel( - socket, - verb, - url, - rejectUnauthorized = false -) { - return new Promise((resolve, reject) => { - const tlsSocket = tls.connect( - { - socket: socket, - servername: url.hostname, - // Tests should focus on tunnel behavior, not system CA state; - // disable CA verification to avoid flakiness on machines without full roots. - rejectUnauthorized: rejectUnauthorized, - }, - () => { - tlsSocket.write( - `${verb} ${url.pathname} HTTP/1.1\r\nHost: ${url.hostname}\r\nConnection: close\r\n\r\n` - ); - } - ); - - let tlsData = ""; - - tlsSocket.on("data", (data) => { - tlsData += data.toString(); - }); - - tlsSocket.on("end", () => { - resolve(tlsData); - }); - - tlsSocket.on("error", (err) => { - reject(err); - }); - }); -} - -function getTlsCertificateInfo(socket, url) { - return new Promise((resolve, reject) => { - const tlsSocket = tls.connect( - { - socket: socket, - servername: url.hostname, - // Don't reject unauthorized to avoid system CA issues in CI - // We just want to inspect the certificate - rejectUnauthorized: false, - }, - () => { - const cert = tlsSocket.getPeerCertificate(); - - // Extract issuer and subject information - const issuer = cert.issuer - ? Object.entries(cert.issuer) - .map(([k, v]) => `${k}=${v}`) - .join(", ") - : "unknown"; - const subject = cert.subject - ? Object.entries(cert.subject) - .map(([k, v]) => `${k}=${v}`) - .join(", ") - : "unknown"; - - tlsSocket.end(); - resolve({ issuer, subject }); - } - ); - - tlsSocket.on("error", (err) => { - reject(err); - }); - }); -} diff --git a/packages/safe-chain/src/registryProxy/registryProxy.http-proxy.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.http-proxy.spec.js deleted file mode 100644 index 970543c..0000000 --- a/packages/safe-chain/src/registryProxy/registryProxy.http-proxy.spec.js +++ /dev/null @@ -1,225 +0,0 @@ -import { before, after, describe, it } from "node:test"; -import assert from "node:assert"; -import http from "http"; -import { - createSafeChainProxy, - mergeSafeChainProxyEnvironmentVariables, -} from "./registryProxy.js"; - -describe("registryProxy.httpProxy", () => { - let proxy, proxyHost, proxyPort; - let testHttpServer, testHttpServerPort; - - before(async () => { - // Start safe-chain proxy - proxy = createSafeChainProxy(); - await proxy.startServer(); - const envVars = mergeSafeChainProxyEnvironmentVariables([]); - const proxyUrl = new URL(envVars.HTTPS_PROXY); - proxyHost = proxyUrl.hostname; - proxyPort = parseInt(proxyUrl.port, 10); - - // Start a test HTTP server to forward requests to - testHttpServer = http.createServer((req, res) => { - if (req.url === "/test") { - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end("HTTP test response"); - } else if (req.url === "/echo-headers") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(req.headers)); - } else if (req.url === "/echo-method") { - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end(req.method); - } else if (req.url === "/post-echo") { - let body = ""; - req.on("data", (chunk) => { - body += chunk.toString(); - }); - req.on("end", () => { - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end(body); - }); - } else if (req.url === "/404") { - res.writeHead(404, { "Content-Type": "text/plain" }); - res.end("Not Found"); - } else { - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end("OK"); - } - }); - - testHttpServerPort = await new Promise((resolve) => { - testHttpServer.listen(0, () => { - resolve(testHttpServer.address().port); - }); - }); - }); - - after(async () => { - await proxy.stopServer(); - await new Promise((resolve) => { - testHttpServer.close(() => resolve()); - setTimeout(resolve, 1000); - }); - }); - - it("should forward HTTP GET requests", async () => { - const response = await makeHttpProxyRequest( - proxyHost, - proxyPort, - `http://localhost:${testHttpServerPort}/test`, - "GET" - ); - - assert.strictEqual(response.statusCode, 200); - assert.strictEqual(response.body, "HTTP test response"); - }); - - it("should forward HTTP POST requests with body", async () => { - const postData = "test post data"; - const response = await makeHttpProxyRequest( - proxyHost, - proxyPort, - `http://localhost:${testHttpServerPort}/post-echo`, - "POST", - postData - ); - - assert.strictEqual(response.statusCode, 200); - assert.strictEqual(response.body, postData); - }); - - it("should preserve request headers", async () => { - const response = await makeHttpProxyRequest( - proxyHost, - proxyPort, - `http://localhost:${testHttpServerPort}/echo-headers`, - "GET", - null, - { - "X-Custom-Header": "test-value", - "User-Agent": "test-agent/1.0", - } - ); - - assert.strictEqual(response.statusCode, 200); - const headers = JSON.parse(response.body); - assert.strictEqual(headers["x-custom-header"], "test-value"); - assert.strictEqual(headers["user-agent"], "test-agent/1.0"); - }); - - it("should preserve HTTP methods", async () => { - const methods = ["GET", "POST", "PUT", "DELETE"]; - - for (const method of methods) { - const response = await makeHttpProxyRequest( - proxyHost, - proxyPort, - `http://localhost:${testHttpServerPort}/echo-method`, - method - ); - - assert.strictEqual(response.statusCode, 200); - assert.strictEqual(response.body, method); - } - }); - - it("should forward 404 responses correctly", async () => { - const response = await makeHttpProxyRequest( - proxyHost, - proxyPort, - `http://localhost:${testHttpServerPort}/404`, - "GET" - ); - - assert.strictEqual(response.statusCode, 404); - assert.strictEqual(response.body, "Not Found"); - }); - - it("should handle invalid host with 502 Bad Gateway", async () => { - const response = await makeHttpProxyRequest( - proxyHost, - proxyPort, - "http://invalid-host-that-does-not-exist.test:9999/test", - "GET" - ); - - assert.strictEqual(response.statusCode, 502); - assert.ok(response.body.includes("Bad Gateway")); - }); - - it("should handle HTTPS URLs sent to HTTP proxy", async () => { - // Some clients incorrectly send https:// URLs to the HTTP proxy handler - // instead of using CONNECT. The proxy should handle this gracefully. - const response = await makeHttpProxyRequest( - proxyHost, - proxyPort, - "https://registry.npmjs.org/lodash", - "GET" - ); - - // Should successfully forward the HTTPS request - assert.strictEqual(response.statusCode, 200); - assert.ok(response.body.includes("lodash")); - }); - - it("should handle unsupported protocols with 502", async () => { - const response = await makeHttpProxyRequest( - proxyHost, - proxyPort, - "ftp://example.com/file.txt", - "GET" - ); - - assert.strictEqual(response.statusCode, 502); - assert.ok(response.body.includes("Unsupported protocol")); - }); -}); - -function makeHttpProxyRequest( - proxyHost, - proxyPort, - targetUrl, - method = "GET", - body = null, - extraHeaders = {} -) { - return new Promise((resolve, reject) => { - const options = { - hostname: proxyHost, - port: proxyPort, - path: targetUrl, - method: method, - headers: { - Host: new URL(targetUrl).host, - ...extraHeaders, - }, - }; - - const req = http.request(options, (res) => { - let responseBody = ""; - - res.on("data", (chunk) => { - responseBody += chunk.toString(); - }); - - res.on("end", () => { - resolve({ - statusCode: res.statusCode, - headers: res.headers, - body: responseBody, - }); - }); - }); - - req.on("error", (err) => { - reject(err); - }); - - if (body) { - req.write(body); - } - - req.end(); - }); -} diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 694c72c..3558673 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -1,62 +1,41 @@ 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 { getCaCertPath } from "./certUtils.js"; +import { auditChanges } from "../scanning/audit/index.js"; +import { knownRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js"; import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; -import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; -import { getHasSuppressedVersions } from "./interceptors/suppressedVersionsState.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}[] - * }} - */ const state = { port: null, blockedRequests: [], - blockedMinimumAgeRequests: [], }; export function createSafeChainProxy() { const server = createProxyServer(); + server.on("connect", handleConnect); return { startServer: () => startServer(server), stopServer: () => stopServer(server), - hasBlockedMaliciousPackages, - hasBlockedMinimumAgeRequests, - hasSuppressedVersions: getHasSuppressedVersions, + verifyNoMaliciousPackages, }; } -/** - * @returns {Record} - */ function getSafeChainProxyEnvironmentVariables() { if (!state.port) { return {}; } - const proxyUrl = `http://127.0.0.1:${state.port}`; - const caCertPath = getCombinedCaBundlePath(); - return { - HTTPS_PROXY: proxyUrl, - GLOBAL_AGENT_HTTP_PROXY: proxyUrl, - NODE_EXTRA_CA_CERTS: caCertPath, + HTTPS_PROXY: `http://localhost:${state.port}`, + GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`, + NODE_EXTRA_CA_CERTS: getCaCertPath(), }; } -/** - * @param {Record} env - * - * @returns {Record} - */ export function mergeSafeChainProxyEnvironmentVariables(env) { const proxyEnv = getSafeChainProxyEnvironmentVariables(); @@ -66,7 +45,7 @@ export function mergeSafeChainProxyEnvironmentVariables(env) { // So we only copy the variable if it's not already set in a different case const upperKey = key.toUpperCase(); - if (!proxyEnv[upperKey] && env[key]) { + if (!proxyEnv[upperKey]) { proxyEnv[key] = env[key]; } } @@ -75,31 +54,21 @@ export function mergeSafeChainProxyEnvironmentVariables(env) { } function createProxyServer() { - const server = http.createServer( - // This handles direct HTTP requests (non-CONNECT requests) - // This is normally http-only traffic, but we also handle - // https for clients that don't properly use CONNECT - handleHttpProxyRequest - ); - - // This handles HTTPS requests via the CONNECT method - server.on("connect", handleConnect); + const server = http.createServer((_, res) => { + res.writeHead(400, "Bad Request"); + res.write( + "Safe-chain proxy: Direct http not supported. Only CONNECT requests are allowed." + ); + res.end(); + }); return server; } -/** - * @param {import("http").Server} server - * - * @returns {Promise} - */ 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; @@ -115,97 +84,60 @@ function startServer(server) { }); } -/** - * @param {import("http").Server} server - * - * @returns {Promise} - */ 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); }); } -/** - * @param {import("http").IncomingMessage} req - * @param {import("http").ServerResponse} clientSocket - * @param {Buffer} head - * - * @returns {void} - */ function handleConnect(req, clientSocket, head) { // CONNECT method is used for HTTPS requests // It establishes a tunnel to the server identified by the request URL - const interceptor = createInterceptorForUrl(req.url || ""); - - if (interceptor) { - // Subscribe to malware blocked events - interceptor.on( - "malwareBlocked", - ( - /** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event - ) => { - 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); + if (knownRegistries.some((reg) => req.url.includes(reg))) { + // For npm and yarn registries, we want to intercept and inspect the traffic + // so we can block packages with malware + mitmConnect(req, clientSocket, isAllowedUrl); } else { // For other hosts, just tunnel the request to the destination tcp socket - ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`); tunnelRequest(req, clientSocket, head); } } -/** - * - * @param {string} packageName - * @param {string} version - * @param {string} url - */ -function onMalwareBlocked(packageName, version, url) { - state.blockedRequests.push({ packageName, version, url }); -} +async function isAllowedUrl(url) { + const { packageName, version } = parsePackageFromUrl(url); -/** - * - * @param {string} packageName - * @param {string} version - * @param {string} url - */ -function onMinimumAgeRequestBlocked(packageName, version, url) { - state.blockedMinimumAgeRequests.push({ packageName, version, url }); -} + // packageName and version are undefined when the URL is not a package download + // In that case, we can allow the request to proceed + if (!packageName || !version) { + return true; + } -function hasBlockedMaliciousPackages() { - if (state.blockedRequests.length === 0) { + const auditResult = await auditChanges([ + { name: packageName, version, type: "add" }, + ]); + + if (!auditResult.isAllowed) { + state.blockedRequests.push({ packageName, version, url }); return false; } + return true; +} + +function verifyNoMaliciousPackages() { + if (state.blockedRequests.length === 0) { + // No malicious packages were blocked, so nothing to block + return true; + } + ui.emptyLine(); ui.writeInformation( @@ -219,40 +151,8 @@ function hasBlockedMaliciousPackages() { } ui.emptyLine(); - ui.writeExitWithoutInstallingMaliciousPackages(); + ui.writeError("Exiting without installing malicious packages."); 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; + return false; } diff --git a/packages/safe-chain/src/registryProxy/registryProxy.loopback.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.loopback.spec.js deleted file mode 100644 index 64bb862..0000000 --- a/packages/safe-chain/src/registryProxy/registryProxy.loopback.spec.js +++ /dev/null @@ -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(); - }); - }); - } - }); -}); diff --git a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js deleted file mode 100644 index 407aa3c..0000000 --- a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js +++ /dev/null @@ -1,402 +0,0 @@ -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 fs from "fs"; - -describe("registryProxy.mitm", () => { - let proxy, proxyHost, proxyPort; - - before(async () => { - proxy = createSafeChainProxy(); - await proxy.startServer(); - const envVars = mergeSafeChainProxyEnvironmentVariables([]); - const proxyUrl = new URL(envVars.HTTPS_PROXY); - proxyHost = proxyUrl.hostname; - proxyPort = parseInt(proxyUrl.port, 10); - // Default to JS ecosystem for JS registry tests - setEcoSystem(ECOSYSTEM_JS); - }); - - after(async () => { - await proxy.stopServer(); - }); - - it("should intercept HTTPS requests to npm registry", async () => { - const response = await makeRegistryRequest( - proxyHost, - proxyPort, - "registry.npmjs.org", - "/lodash", - ); - - assert.strictEqual(response.statusCode, 200); - assert.ok(response.body.includes("lodash")); - }); - - it("should allow non-malicious package downloads", async () => { - const response = await makeRegistryRequest( - proxyHost, - proxyPort, - "registry.npmjs.org", - "/lodash/-/lodash-4.17.21.tgz", - ); - - // Should get a response (200 or redirect, but not 403 blocked) - assert.notStrictEqual(response.statusCode, 403); - }); - - it("should handle 404 responses correctly", async () => { - const response = await makeRegistryRequest( - proxyHost, - proxyPort, - "registry.npmjs.org", - "/this-package-definitely-does-not-exist-12345", - ); - - assert.strictEqual(response.statusCode, 404); - }); - - it("should handle query parameters in URL", async () => { - const response = await makeRegistryRequest( - proxyHost, - proxyPort, - "registry.npmjs.org", - "/lodash?write=true", - ); - - assert.strictEqual(response.statusCode, 200); - }); - - it("should generate valid certificates for yarn registry", async () => { - const response = await makeRegistryRequest( - proxyHost, - proxyPort, - "registry.yarnpkg.com", - "/lodash", - ); - - assert.strictEqual(response.statusCode, 200); - }); - - it("should generate certificate with correct hostname in CN", async () => { - const { cert } = await makeRegistryRequestAndGetCert( - proxyHost, - proxyPort, - "registry.npmjs.org", - "/lodash", - ); - - // Check certificate common name matches the target hostname - assert.strictEqual(cert.subject.CN, "registry.npmjs.org"); - - // Check Subject Alternative Name includes the hostname - const san = cert.subjectaltname; - assert.ok(san.includes("registry.npmjs.org")); - - // Check certificate is issued by safe-chain CA - assert.strictEqual(cert.issuer.CN, "safe-chain proxy"); - }); - - it("should generate different certificates for different hostnames", async () => { - const { cert: cert1 } = await makeRegistryRequestAndGetCert( - proxyHost, - proxyPort, - "registry.npmjs.org", - "/lodash", - ); - - const { cert: cert2 } = await makeRegistryRequestAndGetCert( - proxyHost, - proxyPort, - "registry.yarnpkg.com", - "/lodash", - ); - - // Different hostnames should have different certificates - assert.notStrictEqual(cert1.fingerprint, cert2.fingerprint); - assert.strictEqual(cert1.subject.CN, "registry.npmjs.org"); - assert.strictEqual(cert2.subject.CN, "registry.yarnpkg.com"); - }); - - it("should cache generated certificates for same hostname", async () => { - const { cert: cert1 } = await makeRegistryRequestAndGetCert( - proxyHost, - proxyPort, - "registry.npmjs.org", - "/lodash", - ); - - const { cert: cert2 } = await makeRegistryRequestAndGetCert( - proxyHost, - proxyPort, - "registry.npmjs.org", - "/package/lodash", - ); - - // Same hostname should get the same certificate (fingerprint) - assert.strictEqual(cert1.fingerprint, cert2.fingerprint); - }); - - // --- Pip registry MITM and env var tests --- - it("should NOT set Python CA environment variables in proxy merge (handled by runPipCommand)", () => { - const envVars = mergeSafeChainProxyEnvironmentVariables([]); - assert.strictEqual(envVars.PIP_CERT, undefined); - assert.strictEqual(envVars.REQUESTS_CA_BUNDLE, undefined); - assert.strictEqual(envVars.SSL_CERT_FILE, undefined); - }); - - it("should intercept HTTPS requests to pypi.org for pip package", async () => { - // Switch to Python ecosystem for pip registry MITM tests - setEcoSystem(ECOSYSTEM_PY); - const response = await makeRegistryRequest( - proxyHost, - proxyPort, - "pypi.org", - "/packages/source/f/foo_bar/foo_bar-2.0.0.tar.gz", - ); - assert.notStrictEqual(response.statusCode, 403); - assert.ok(typeof response.body === "string"); - }); - - it("should intercept HTTPS requests to files.pythonhosted.org for pip wheel", async () => { - // Ensure Python ecosystem - setEcoSystem(ECOSYSTEM_PY); - const response = await makeRegistryRequest( - proxyHost, - proxyPort, - "files.pythonhosted.org", - "/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl", - ); - assert.notStrictEqual(response.statusCode, 403); - assert.ok(typeof response.body === "string"); - }); - - it("should handle pip package with a1 version", async () => { - // Ensure Python ecosystem - setEcoSystem(ECOSYSTEM_PY); - const response = await makeRegistryRequest( - proxyHost, - proxyPort, - "pypi.org", - "/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz", - ); - assert.notStrictEqual(response.statusCode, 403); - assert.ok(typeof response.body === "string"); - }); - - it("should handle pip package with latest version (should not block)", async () => { - // Ensure Python ecosystem - setEcoSystem(ECOSYSTEM_PY); - const response = await makeRegistryRequest( - proxyHost, - proxyPort, - "pypi.org", - "/packages/source/f/foo_bar/foo_bar-latest.tar.gz", - ); - assert.notStrictEqual(response.statusCode, 403); - assert.ok(typeof response.body === "string"); - }); -}); - -async function makeRegistryRequest(proxyHost, proxyPort, targetHost, path) { - // Step 1: Connect to proxy - const socket = await new Promise((resolve, reject) => { - const sock = net.connect({ host: proxyHost, port: proxyPort }, () => { - resolve(sock); - }); - sock.on("error", reject); - }); - - // Step 2: Send CONNECT request - await new Promise((resolve) => { - const connectRequest = `CONNECT ${targetHost}:443 HTTP/1.1\r\nHost: ${targetHost}:443\r\n\r\n`; - socket.write(connectRequest); - socket.once("data", resolve); - }); - - // Step 3: Upgrade to TLS using the proxy's CA cert - const tlsSocket = tls.connect({ - socket: socket, - servername: targetHost, - ca: fs.readFileSync(getCaCertPath()), - rejectUnauthorized: true, - }); - - await new Promise((resolve) => { - tlsSocket.on("secureConnect", resolve); - }); - - // 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`; - tlsSocket.write(httpRequest); - - // Step 5: Read response as binary chunks - return new Promise((resolve, reject) => { - const chunks = []; - - tlsSocket.on("data", (chunk) => { - chunks.push(chunk); - }); - - 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 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; - } - } - - // 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 }); - }); - - tlsSocket.on("error", reject); - }); -} - -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 }, () => { - resolve(sock); - }); - sock.on("error", reject); - }); - - // Step 2: Send CONNECT request - await new Promise((resolve) => { - const connectRequest = `CONNECT ${targetHost}:443 HTTP/1.1\r\nHost: ${targetHost}:443\r\n\r\n`; - socket.write(connectRequest); - socket.once("data", resolve); - }); - - // Step 3: Upgrade to TLS and capture certificate - const tlsSocket = tls.connect({ - socket: socket, - servername: targetHost, - ca: fs.readFileSync(getCaCertPath()), - rejectUnauthorized: true, - }); - - let peerCert; - await new Promise((resolve) => { - tlsSocket.on("secureConnect", () => { - peerCert = tlsSocket.getPeerCertificate(); - resolve(); - }); - }); - - // Step 4: Send HTTP request over TLS - const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\n\r\n`; - tlsSocket.write(httpRequest); - - // Step 5: Read response - const response = await new Promise((resolve, reject) => { - let data = ""; - - tlsSocket.on("data", (chunk) => { - data += chunk.toString(); - }); - - tlsSocket.on("end", () => { - const lines = data.split("\r\n"); - const statusLine = lines[0]; - const statusCode = parseInt(statusLine.split(" ")[1]); - - // Find body after empty line - const emptyLineIndex = lines.findIndex((line) => line === ""); - const body = lines.slice(emptyLineIndex + 1).join("\r\n"); - - resolve({ statusCode, body }); - }); - - tlsSocket.on("error", reject); - }); - - return { cert: peerCert, response }; -} - -/** - * Decode HTTP chunked transfer encoding - * Format: \r\n\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); -} diff --git a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js index 5eac381..95e2beb 100644 --- a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js @@ -1,18 +1,6 @@ import * as net from "net"; import { ui } from "../environment/userInteraction.js"; -import { isImdsEndpoint } from "./isImdsEndpoint.js"; -import { getConnectTimeout } from "./getConnectTimeout.js"; -/** @type {string[]} */ -let timedoutImdsEndpoints = []; - -/** - * @param {import("http").IncomingMessage} req - * @param {import("http").ServerResponse} clientSocket - * @param {Buffer} head - * - * @returns {void} - */ export function tunnelRequest(req, clientSocket, head) { const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; @@ -33,111 +21,24 @@ export function tunnelRequest(req, clientSocket, head) { } } -/** - * @param {import("http").IncomingMessage} req - * @param {import("http").ServerResponse} clientSocket - * @param {Buffer} head - * - * @returns {void} - */ 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"); - if (isImds) { - ui.writeVerbose( - `Safe-chain: Closing connection because previously timedout connect to ${hostname}` - ); - } else { - ui.writeError( - `Safe-chain: Closing connection because previously timedout connect to ${hostname}` - ); - } - 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(port || 443, hostname, () => { clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); serverSocket.write(head); serverSocket.pipe(clientSocket); clientSocket.pipe(serverSocket); }); - clientSocket.on("error", () => { - // This can happen if the client TCP socket sends RST instead of FIN. - // Not subscribing to 'error' event will cause node to throw and crash. - clearTimeout(connectTimer); - if (serverSocket.writable) { - serverSocket.end(); - } - }); - - clientSocket.on("close", () => { - // Client closed connection - clean up server socket - clearTimeout(connectTimer); - if (serverSocket.writable) { - serverSocket.end(); - } - }); - serverSocket.on("error", (err) => { - clearTimeout(connectTimer); - if (isImds) { - ui.writeVerbose( - `Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}` - ); - } else { - ui.writeError( - `Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}` - ); - } - if (clientSocket.writable) { - clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); - } - }); - - serverSocket.on("close", () => { - // Server closed connection - clean up client socket - clearTimeout(connectTimer); - if (clientSocket.writable) { - clientSocket.end(); - } + ui.writeError( + `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}` + ); + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); }); } -/** - * @param {import("http").IncomingMessage} req - * @param {import("http").ServerResponse} clientSocket - * @param {Buffer} head - * @param {string} proxyUrl - */ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { const { port, hostname } = new URL(`http://${req.url}`); const proxy = new URL(proxyUrl); @@ -145,7 +46,7 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { // Connect to proxy server const proxySocket = net.connect({ host: proxy.hostname, - port: Number.parseInt(proxy.port) || 80, + port: proxy.port, }); proxySocket.on("connect", () => { @@ -175,12 +76,8 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { ui.writeError( `Safe-chain: proxy CONNECT failed: ${response.split("\r\n")[0]}` ); - if (clientSocket.writable) { - clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); - } - if (proxySocket.writable) { - proxySocket.end(); - } + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + proxySocket.end(); } }); @@ -191,23 +88,11 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { proxy.port || 8080 } - ${err.message}` ); - if (clientSocket.writable) { - clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); - } - } else { - ui.writeError( - `Safe-chain: proxy socket error after connection - ${err.message}` - ); - if (clientSocket.writable) { - clientSocket.end(); - } + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); } }); clientSocket.on("error", () => { - if (proxySocket.writable) { - proxySocket.end(); - } + proxySocket.end(); }); } - diff --git a/packages/safe-chain/src/scanning/audit/index.js b/packages/safe-chain/src/scanning/audit/index.js index 771401e..215bfa0 100644 --- a/packages/safe-chain/src/scanning/audit/index.js +++ b/packages/safe-chain/src/scanning/audit/index.js @@ -1,67 +1,8 @@ -import { ui } from "../../environment/userInteraction.js"; import { MALWARE_STATUS_MALWARE, openMalwareDatabase, } from "../malwareDatabase.js"; -/** - * @typedef {Object} PackageChange - * @property {string} name - * @property {string} version - * @property {string} type - */ - -/** - * @typedef {Object} AuditResult - * @property {PackageChange[]} allowedChanges - * @property {(PackageChange & {reason: string})[]} disallowedChanges - * @property {boolean} isAllowed - */ - -/** - * @typedef {Object} AuditStats - * @property {number} totalPackages - * @property {number} safePackages - * @property {number} malwarePackages - */ - -/** - * @type AuditStats - */ -const auditStats = { - totalPackages: 0, - safePackages: 0, - malwarePackages: 0, -}; - -/** - * @returns {AuditStats} - */ -export function getAuditStats() { - return auditStats; -} - -/** - * - * @param {string | undefined} name - * @param {string | undefined} version - * @returns {Promise} - */ -export async function isMalwarePackage(name, version) { - if (!name || !version) { - return false; - } - - const auditResult = await auditChanges([{ name, version, type: "add" }]); - - return !auditResult.isAllowed; -} - -/** - * @param {PackageChange[]} changes - * - * @returns {Promise} - */ export async function auditChanges(changes) { const allowedChanges = []; const disallowedChanges = []; @@ -78,20 +19,10 @@ export async function auditChanges(changes) { ); if (malwarePackage) { - auditStats.malwarePackages += 1; - ui.writeVerbose( - `Safe-chain: Package ${change.name}@${change.version} is marked as malware: ${malwarePackage.status}` - ); disallowedChanges.push({ ...change, reason: malwarePackage.status }); } else { - auditStats.safePackages += 1; - ui.writeVerbose( - `Safe-chain: Package ${change.name}@${change.version} is clean` - ); allowedChanges.push(change); } - - auditStats.totalPackages += 1; } const auditResults = { @@ -103,10 +34,6 @@ export async function auditChanges(changes) { return auditResults; } -/** - * @param {{name: string, version: string, type: string}[]} changes - * @returns {Promise<{name: string, version: string, status: string}[]>} - */ async function getPackagesWithMalware(changes) { if (changes.length === 0) { return []; diff --git a/packages/safe-chain/src/scanning/audit/index.spec.js b/packages/safe-chain/src/scanning/audit/index.spec.js deleted file mode 100644 index 33ca9e3..0000000 --- a/packages/safe-chain/src/scanning/audit/index.spec.js +++ /dev/null @@ -1,188 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it, mock, beforeEach } from "node:test"; - -describe("audit/index", async () => { - const mockWriteVerbose = mock.fn(); - - // Mock UI module - mock.module("../../environment/userInteraction.js", { - namedExports: { - ui: { - writeVerbose: mockWriteVerbose, - }, - }, - }); - - // Mock malware database - const mockIsMalware = mock.fn(); - mock.module("../malwareDatabase.js", { - namedExports: { - MALWARE_STATUS_MALWARE: "malware", - openMalwareDatabase: async () => ({ - isMalware: mockIsMalware, - }), - }, - }); - - const { auditChanges, getAuditStats } = await import("./index.js"); - - beforeEach(() => { - mockWriteVerbose.mock.resetCalls(); - mockIsMalware.mock.resetCalls(); - }); - - describe("getAuditStats", () => { - it("should return audit stats object with correct structure", () => { - const stats = getAuditStats(); - - assert.ok(stats.hasOwnProperty("totalPackages")); - assert.ok(stats.hasOwnProperty("safePackages")); - assert.ok(stats.hasOwnProperty("malwarePackages")); - assert.equal(typeof stats.totalPackages, "number"); - assert.equal(typeof stats.safePackages, "number"); - assert.equal(typeof stats.malwarePackages, "number"); - }); - - it("should return the same object reference on multiple calls", () => { - const stats1 = getAuditStats(); - const stats2 = getAuditStats(); - - assert.equal(stats1, stats2); - }); - }); - - describe("auditChanges", () => { - it("should return empty allowed and disallowed arrays when no changes provided", async () => { - const result = await auditChanges([]); - - assert.deepEqual(result.allowedChanges, []); - assert.deepEqual(result.disallowedChanges, []); - assert.equal(result.isAllowed, true); - }); - - it("should mark package as allowed when not malware", async () => { - mockIsMalware.mock.mockImplementation(() => false); - - const changes = [{ name: "lodash", version: "4.17.21", type: "add" }]; - const result = await auditChanges(changes); - - assert.equal(result.allowedChanges.length, 1); - assert.equal(result.disallowedChanges.length, 0); - assert.equal(result.isAllowed, true); - assert.deepEqual(result.allowedChanges[0], changes[0]); - }); - - it("should mark package as disallowed when malware detected", async () => { - mockIsMalware.mock.mockImplementation(() => true); - - const changes = [ - { name: "malicious-pkg", version: "1.0.0", type: "add" }, - ]; - const result = await auditChanges(changes); - - assert.equal(result.allowedChanges.length, 0); - assert.equal(result.disallowedChanges.length, 1); - assert.equal(result.isAllowed, false); - assert.equal(result.disallowedChanges[0].name, "malicious-pkg"); - assert.equal(result.disallowedChanges[0].version, "1.0.0"); - assert.equal(result.disallowedChanges[0].reason, "malware"); - }); - - it("should handle mixed safe and malware packages", async () => { - mockIsMalware.mock.mockImplementation((name) => { - return name === "malicious-pkg"; - }); - - const changes = [ - { name: "lodash", version: "4.17.21", type: "add" }, - { name: "malicious-pkg", version: "1.0.0", type: "add" }, - { name: "express", version: "4.18.0", type: "add" }, - ]; - const result = await auditChanges(changes); - - assert.equal(result.allowedChanges.length, 2); - assert.equal(result.disallowedChanges.length, 1); - assert.equal(result.isAllowed, false); - assert.equal(result.disallowedChanges[0].name, "malicious-pkg"); - }); - - it("should only check malware for add and change types", async () => { - mockIsMalware.mock.mockImplementation(() => false); - - const changes = [ - { name: "pkg1", version: "1.0.0", type: "add" }, - { name: "pkg2", version: "2.0.0", type: "change" }, - { name: "pkg3", version: "3.0.0", type: "remove" }, - ]; - await auditChanges(changes); - - // Should only check pkg1 and pkg2, not pkg3 (remove type) - assert.equal(mockIsMalware.mock.calls.length, 2); - }); - - it("should increment totalPackages counter for each package", async () => { - mockIsMalware.mock.mockImplementation(() => false); - - const statsBefore = getAuditStats(); - const initialCount = statsBefore.totalPackages; - - const changes = [ - { name: "pkg1", version: "1.0.0", type: "add" }, - { name: "pkg2", version: "2.0.0", type: "add" }, - { name: "pkg3", version: "3.0.0", type: "add" }, - ]; - await auditChanges(changes); - - const statsAfter = getAuditStats(); - assert.equal(statsAfter.totalPackages, initialCount + 3); - }); - - it("should increment safePackages counter for safe packages", async () => { - mockIsMalware.mock.mockImplementation(() => false); - - const statsBefore = getAuditStats(); - const initialCount = statsBefore.safePackages; - - const changes = [ - { name: "lodash", version: "4.17.21", type: "add" }, - { name: "express", version: "4.18.0", type: "add" }, - ]; - await auditChanges(changes); - - const statsAfter = getAuditStats(); - assert.equal(statsAfter.safePackages, initialCount + 2); - }); - - it("should increment malwarePackages counter for malware packages", async () => { - mockIsMalware.mock.mockImplementation(() => true); - - const statsBefore = getAuditStats(); - const initialCount = statsBefore.malwarePackages; - - const changes = [ - { name: "malicious-1", version: "1.0.0", type: "add" }, - { name: "malicious-2", version: "2.0.0", type: "add" }, - ]; - await auditChanges(changes); - - const statsAfter = getAuditStats(); - assert.equal(statsAfter.malwarePackages, initialCount + 2); - }); - - it("should accumulate stats across multiple auditChanges calls", async () => { - mockIsMalware.mock.mockImplementation(() => false); - - const statsBefore = getAuditStats(); - const initialCount = statsBefore.totalPackages; - - // First call - await auditChanges([{ name: "pkg1", version: "1.0.0", type: "add" }]); - - // Second call - await auditChanges([{ name: "pkg2", version: "2.0.0", type: "add" }]); - - const statsAfter = getAuditStats(); - assert.equal(statsAfter.totalPackages, initialCount + 2); - }); - }); -}); diff --git a/packages/safe-chain/src/scanning/index.js b/packages/safe-chain/src/scanning/index.js index abfc420..36f62ca 100644 --- a/packages/safe-chain/src/scanning/index.js +++ b/packages/safe-chain/src/scanning/index.js @@ -4,12 +4,8 @@ import { setTimeout } from "timers/promises"; import chalk from "chalk"; import { getPackageManager } from "../packagemanager/currentPackageManager.js"; import { ui } from "../environment/userInteraction.js"; +import { getMalwareAction, MALWARE_ACTION_PROMPT } from "../config/settings.js"; -/** - * @param {string[]} args - * - * @returns {boolean} - */ export function shouldScanCommand(args) { if (!args || args.length === 0) { return false; @@ -18,30 +14,41 @@ export function shouldScanCommand(args) { return getPackageManager().isSupportedCommand(args); } -/** - * @param {string[]} args - * - * @returns {Promise} - */ export async function scanCommand(args) { if (!shouldScanCommand(args)) { - return 0; + return []; } let timedOut = false; - /** @type {import("./audit/index.js").AuditResult | undefined} */ + + const spinner = ui.startProcess( + "Safe-chain: Scanning for malicious packages..." + ); let audit; await Promise.race([ (async () => { - const packageManager = getPackageManager(); - const changes = await packageManager.getDependencyUpdatesForCommand(args); + try { + const packageManager = getPackageManager(); + const changes = await packageManager.getDependencyUpdatesForCommand( + args + ); - if (timedOut) { - return; + if (timedOut) { + return; + } + + if (changes.length > 0) { + spinner.setText( + `Safe-chain: Scanning ${changes.length} package(s)...` + ); + } + + audit = await auditChanges(changes); + } catch (error) { + spinner.fail(`Safe-chain: Error while scanning.`); + throw error; } - - audit = await auditChanges(changes); })(), setTimeout(getScanTimeout()).then(() => { timedOut = true; @@ -49,34 +56,44 @@ export async function scanCommand(args) { ]); if (timedOut) { + spinner.fail("Safe-chain: Timeout exceeded while scanning."); throw new Error("Timeout exceeded while scanning npm install command."); } if (!audit || audit.isAllowed) { + spinner.stop(); return 0; } else { - printMaliciousChanges(audit.disallowedChanges); - onMalwareFound(); - return 1; + printMaliciousChanges(audit.disallowedChanges, spinner); + return await onMalwareFound(); } } -/** - * @param {import("./audit/index.js").PackageChange[]} changes - * @return {void} - */ -function printMaliciousChanges(changes) { - ui.writeInformation( - chalk.red("✖") + " Safe-chain: " + chalk.bold("Malicious changes detected:") - ); +function printMaliciousChanges(changes, spinner) { + spinner.fail("Safe-chain: " + chalk.bold("Malicious changes detected:")); for (const change of changes) { ui.writeInformation(` - ${change.name}@${change.version}`); } } -function onMalwareFound() { +async function onMalwareFound() { ui.emptyLine(); - ui.writeExitWithoutInstallingMaliciousPackages(); + + if (getMalwareAction() === MALWARE_ACTION_PROMPT) { + const continueInstall = await ui.confirm({ + message: + "Malicious packages were found. Do you want to continue with the installation?", + default: false, + }); + + if (continueInstall) { + ui.writeWarning("Continuing with the installation despite the risks..."); + return 0; + } + } + + ui.writeError("Exiting without installing malicious packages."); ui.emptyLine(); + return 1; } diff --git a/packages/safe-chain/src/scanning/index.scanCommand.spec.js b/packages/safe-chain/src/scanning/index.scanCommand.spec.js index 944cf11..1858d10 100644 --- a/packages/safe-chain/src/scanning/index.scanCommand.spec.js +++ b/packages/safe-chain/src/scanning/index.scanCommand.spec.js @@ -1,10 +1,22 @@ import assert from "node:assert/strict"; -import { describe, it, mock } from "node:test"; +import { beforeEach, describe, it, mock } from "node:test"; import { setTimeout } from "node:timers/promises"; +import { + MALWARE_ACTION_PROMPT, + MALWARE_ACTION_BLOCK, +} from "../config/settings.js"; describe("scanCommand", async () => { const getScanTimeoutMock = mock.fn(() => 1000); const mockGetDependencyUpdatesForCommand = mock.fn(); + const mockStartProcess = mock.fn(() => ({ + setText: () => {}, + succeed: () => {}, + fail: () => {}, + stop: () => {}, + })); + const mockConfirm = mock.fn(() => true); + let malwareAction = MALWARE_ACTION_PROMPT; // import { getPackageManager } from "../packagemanager/currentPackageManager.js"; mock.module("../packagemanager/currentPackageManager.js", { @@ -30,15 +42,24 @@ describe("scanCommand", async () => { mock.module("../environment/userInteraction.js", { namedExports: { ui: { + startProcess: mockStartProcess, writeError: () => {}, writeInformation: () => {}, writeWarning: () => {}, - writeExitWithoutInstallingMaliciousPackages: () => {}, emptyLine: () => {}, + confirm: mockConfirm, }, }, }); + mock.module("../config/settings.js", { + namedExports: { + getMalwareAction: () => malwareAction, + MALWARE_ACTION_PROMPT, + MALWARE_ACTION_BLOCK, + }, + }); + // import { auditChanges, MAX_LENGTH_EXCEEDED } from "./audit/index.js"; mock.module("./audit/index.js", { namedExports: { @@ -67,21 +88,57 @@ describe("scanCommand", async () => { const { scanCommand } = await import("./index.js"); + beforeEach(() => { + // Reset malware action back to prompt mode for other tests + malwareAction = MALWARE_ACTION_PROMPT; + }); + it("should succeed when there are no changes", async () => { + let progressWasStopped = false; + mockStartProcess.mock.mockImplementationOnce(() => ({ + setText: () => {}, + succeed: () => {}, + fail: () => {}, + stop: () => { + progressWasStopped = true; + }, + })); mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => []); await scanCommand(["install", "lodash"]); + + assert.equal(progressWasStopped, true); }); it("should succeed when changes are not malicious", async () => { + let progressWasStopped = false; + mockStartProcess.mock.mockImplementationOnce(() => ({ + setText: () => {}, + succeed: () => {}, + fail: () => {}, + stop: () => { + progressWasStopped = true; + }, + })); mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ { name: "lodash", version: "4.17.21" }, ]); await scanCommand(["install", "lodash"]); + + assert.equal(progressWasStopped, true); }); it("should throw an error when timing out", async () => { + let failureMessageWasSet = false; + mockStartProcess.mock.mockImplementationOnce(() => ({ + setText: () => {}, + succeed: () => {}, + fail: () => { + failureMessageWasSet = true; + }, + stop: () => {}, + })); getScanTimeoutMock.mock.mockImplementationOnce(() => 100); mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => { await setTimeout(150); @@ -89,15 +146,120 @@ describe("scanCommand", async () => { }); await assert.rejects(scanCommand(["install", "lodash"])); + + assert.equal(failureMessageWasSet, true); }); - it("should fail and return 1 malicious changes are detected", async () => { + it("should fail and prompt the user when malicious changes are detected", async () => { + let failureMessageWasSet = false; + mockStartProcess.mock.mockImplementationOnce(() => ({ + setText: () => {}, + succeed: () => {}, + fail: () => { + failureMessageWasSet = true; + }, + stop: () => {}, + })); + mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ + { name: "malicious", version: "1.0.0" }, + ]); + let userWasPrompted = false; + mockConfirm.mock.mockImplementationOnce(() => { + userWasPrompted = true; + return true; // Simulate user accepting the risk, otherwise the process would exit + }); + + await scanCommand(["install", "malicious"]); + + assert.equal(failureMessageWasSet, true); + assert.equal(userWasPrompted, true); + }); + + it("should not report a timeout when the user takes a long time to respond (it should not affect the timeout)", async () => { + let failureMessages = []; + mockStartProcess.mock.mockImplementationOnce(() => ({ + setText: () => {}, + succeed: () => {}, + fail: (message) => { + failureMessages.push(message); + }, + stop: () => {}, + })); + getScanTimeoutMock.mock.mockImplementationOnce(() => 100); + mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => { + return [{ name: "malicious", version: "4.17.21" }]; + }); + mockConfirm.mock.mockImplementationOnce(async () => { + await setTimeout(200); + return true; // Simulate user accepting the risk, otherwise the process would exit + }); + + await scanCommand(["install", "malicious"]); + + assert.equal(failureMessages.length, 1); + const failureMessage = failureMessages[0]; + assert.equal(failureMessage.toLowerCase().includes("timeout"), false); + assert.equal(failureMessage.toLowerCase().includes("malicious"), true); + }); + + it("should exit immediately when malicious changes are detected in block mode", async () => { + // Set malware action to block mode for this test + malwareAction = MALWARE_ACTION_BLOCK; + + // Reset mock call count + mockConfirm.mock.resetCalls(); + + let failureMessageWasSet = false; + + mockStartProcess.mock.mockImplementationOnce(() => ({ + setText: () => {}, + succeed: () => {}, + fail: () => { + failureMessageWasSet = true; + }, + stop: () => {}, + })); + mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ { name: "malicious", version: "1.0.0" }, ]); const result = await scanCommand(["install", "malicious"]); + assert.equal(failureMessageWasSet, true); assert.equal(result, 1); + // Confirm should not have been called in block mode + assert.equal(mockConfirm.mock.callCount(), 0); + }); + + it("should exit immediately when malicious changes are detected in block mode without prompting", async () => { + // Set malware action to block mode for this test + malwareAction = MALWARE_ACTION_BLOCK; + + // Reset mock call count + mockConfirm.mock.resetCalls(); + + let userWasPrompted = false; + + mockStartProcess.mock.mockImplementationOnce(() => ({ + setText: () => {}, + succeed: () => {}, + fail: () => {}, + stop: () => {}, + })); + + mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ + { name: "malicious", version: "1.0.0" }, + ]); + + mockConfirm.mock.mockImplementationOnce(() => { + userWasPrompted = true; + return false; + }); + + const result = await scanCommand(["install", "malicious"]); + + assert.equal(result, 1); + assert.equal(userWasPrompted, false); }); }); diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index 0eccc88..1cb781b 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -7,80 +7,42 @@ import { writeDatabaseToLocalCache, } from "../config/configFile.js"; import { ui } from "../environment/userInteraction.js"; -import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js"; -/** - * @typedef {Object} MalwareDatabase - * @property {function(string, string): string} getPackageStatus - * @property {function(string, string): boolean} isMalware - */ +let cachedMalwareDatabase = null; -// 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 | null} */ -let cachedMalwareDatabasePromise = null; - -/** - * Normalize package name for comparison. - * For Python packages (PEP-503): lowercase and replace _, -, . with - - * For js packages: keep as-is (case-sensitive) - * @param {string} name - * @returns {string} - */ -function normalizePackageName(name) { - const ecosystem = getEcoSystem(); - if (ecosystem === ECOSYSTEM_PY) { - return name.toLowerCase().replace(/[-_.]+/g, "-"); +export async function openMalwareDatabase() { + if (cachedMalwareDatabase) { + return cachedMalwareDatabase; } - return name; -} + const malwareDatabase = await getMalwareDatabase(); -export function openMalwareDatabase() { - if (!cachedMalwareDatabasePromise) { - cachedMalwareDatabasePromise = getMalwareDatabase().then((malwareDatabase) => { - /** - * @param {string} name - * @param {string} version - * @returns {string} - */ - function getPackageStatus(name, version) { - const normalizedName = normalizePackageName(name); - const packageData = malwareDatabase.find( - (pkg) => { - const normalizedPkgName = normalizePackageName(pkg.package_name); - return normalizedPkgName === normalizedName && - (pkg.version === version || pkg.version === "*"); - } - ); + function getPackageStatus(name, version) { + const packageData = malwareDatabase.find( + (pkg) => + pkg.package_name === name && + (pkg.version === version || pkg.version === "*") + ); - if (!packageData) { - return MALWARE_STATUS_OK; - } + if (!packageData) { + return MALWARE_STATUS_OK; + } - return packageData.reason; - } - - return { - getPackageStatus, - isMalware: (/** @type {string} */ name, /** @type {string} */ version) => { - const status = getPackageStatus(name, version); - return isMalwareStatus(status); - }, - }; - }).catch((error) => { - cachedMalwareDatabasePromise = null; - throw error; - }); + return packageData.reason; } - return cachedMalwareDatabasePromise; + + // This implicitely caches the malware database + // that's closed over by the getPackageStatus function + cachedMalwareDatabase = { + getPackageStatus, + isMalware: (name, version) => { + const status = getPackageStatus(name, version); + return isMalwareStatus(status); + }, + }; + return cachedMalwareDatabase; } -/** - * @returns {Promise} - */ async function getMalwareDatabase() { const { malwareDatabase: cachedDatabase, version: cachedVersion } = readDatabaseFromLocalCache(); @@ -94,20 +56,10 @@ async function getMalwareDatabase() { } const { malwareDatabase, version } = await fetchMalwareDatabase(); + writeDatabaseToLocalCache(malwareDatabase, version); - if (version) { - // Only cache the malware database when we have a version. - writeDatabaseToLocalCache(malwareDatabase, version); - return malwareDatabase; - } else { - // We received a valid malware database, but the response - // did not contain an etag header with the version - ui.writeWarning( - "The malware database was downloaded, but could not be cached due to a missing version." - ); - return malwareDatabase; - } - } catch (/** @type any */ error) { + return malwareDatabase; + } catch (error) { if (cachedDatabase) { ui.writeWarning( "Failed to fetch the latest malware database. Using cached version." @@ -118,11 +70,6 @@ async function getMalwareDatabase() { } } -/** - * @param {string} status - * - * @returns {boolean} - */ function isMalwareStatus(status) { let malwareStatus = status.toUpperCase(); return malwareStatus === MALWARE_STATUS_MALWARE; diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js deleted file mode 100644 index 32de737..0000000 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ /dev/null @@ -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" - ) - ); - }); - }); -}); diff --git a/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.js b/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.js deleted file mode 100644 index d09f42c..0000000 --- a/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.js +++ /dev/null @@ -1,71 +0,0 @@ -import { - getMinimumPackageAgeHours, - getEcoSystem, - ECOSYSTEM_JS, - ECOSYSTEM_PY, -} from "../config/settings.js"; -import { getEquivalentPackageNames } from "./packageNameVariants.js"; - -/** - * @typedef {Object} NewPackagesDatabase - * @property {function(string | undefined, string | undefined): boolean} isNewlyReleasedPackage - */ - -/** - * Returns the ecosystem identifier expected in upstream/core release feeds. - * @returns {string} - */ -function getCurrentFeedSource() { - const ecosystem = getEcoSystem(); - - if (ecosystem === ECOSYSTEM_JS) { - return "npm"; - } - - if (ecosystem === ECOSYSTEM_PY) { - return "pypi"; - } - - return ecosystem; -} - -/** - * @param {import("../api/aikido.js").NewPackageEntry[]} newPackagesList - * @returns {NewPackagesDatabase} - */ -export function buildNewPackagesDatabase(newPackagesList) { - const ecosystem = getEcoSystem(); - - /** - * @param {string | undefined} name - * @param {string | undefined} version - * @returns {boolean} - */ - function isNewlyReleasedPackage(name, version) { - if (!name || !version) { - return false; - } - - const cutOff = new Date( - new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 - ); - const expectedSource = getCurrentFeedSource(); - const candidateNames = getEquivalentPackageNames(name, ecosystem); - - const entry = newPackagesList.find( - (pkg) => - (!pkg.source || pkg.source.toLowerCase() === expectedSource) && - candidateNames.includes(pkg.package_name) && - pkg.version === version - ); - - if (!entry) { - return false; - } - - const releasedOn = new Date(entry.released_on * 1000); - return releasedOn > cutOff; - } - - return { isNewlyReleasedPackage }; -} diff --git a/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js deleted file mode 100644 index 1424a20..0000000 --- a/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, it, mock } from "node:test"; -import assert from "node:assert"; - -let minimumPackageAgeHours = 24; -let ecosystem = "js"; - -mock.module("../config/settings.js", { - namedExports: { - getMinimumPackageAgeHours: () => minimumPackageAgeHours, - getEcoSystem: () => ecosystem, - getMalwareListBaseUrl: () => "https://malware-list.aikido.dev", - ECOSYSTEM_JS: "js", - ECOSYSTEM_PY: "py", - }, -}); - -const { buildNewPackagesDatabase } = await import( - "./newPackagesDatabaseBuilder.js" -); - -function hoursAgo(hours) { - return Math.floor((Date.now() - hours * 3600 * 1000) / 1000); -} - -describe("buildNewPackagesDatabase", () => { - it("returns an object with isNewlyReleasedPackage", () => { - const db = buildNewPackagesDatabase([]); - assert.strictEqual(typeof db.isNewlyReleasedPackage, "function"); - }); - - describe("isNewlyReleasedPackage", () => { - it("returns true for a package released within the age threshold", () => { - const db = buildNewPackagesDatabase([ - { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) }, - ]); - - assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); - }); - - it("returns false for a package released outside the age threshold", () => { - const db = buildNewPackagesDatabase([ - { package_name: "foo", version: "1.0.0", released_on: hoursAgo(48) }, - ]); - - assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); - }); - - it("returns false for a package not in the list", () => { - const db = buildNewPackagesDatabase([]); - - assert.strictEqual(db.isNewlyReleasedPackage("not-there", "1.0.0"), false); - }); - - it("returns false when name or version is undefined", () => { - const db = buildNewPackagesDatabase([ - { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) }, - ]); - - assert.strictEqual(db.isNewlyReleasedPackage(undefined, "1.0.0"), false); - assert.strictEqual(db.isNewlyReleasedPackage("foo", undefined), false); - }); - - it("returns false for a known package but different version", () => { - const db = buildNewPackagesDatabase([ - { package_name: "foo", version: "2.0.0", released_on: hoursAgo(1) }, - ]); - - assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); - }); - - it("filters by source when source metadata is present", () => { - const db = buildNewPackagesDatabase([ - { 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) }, - ]); - - // ecosystem is "js" → feed source is "npm" - assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); - assert.strictEqual(db.isNewlyReleasedPackage("bar", "1.0.0"), true); - }); - - it("matches regardless of source case", () => { - const db = buildNewPackagesDatabase([ - { source: "NPM", package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) }, - ]); - - assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); - }); - - it("matches entries with no source field", () => { - const db = buildNewPackagesDatabase([ - { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) }, - ]); - - assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); - }); - - it("respects a custom minimumPackageAgeHours threshold", () => { - minimumPackageAgeHours = 168; // 7 days - - const db = buildNewPackagesDatabase([ - { package_name: "foo", version: "1.0.0", released_on: hoursAgo(100) }, - ]); - - assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); - - minimumPackageAgeHours = 24; // reset - }); - - it("matches underscore request names against hyphen feed names for python", () => { - ecosystem = "py"; - - const db = buildNewPackagesDatabase([ - { source: "pypi", package_name: "foo-bar", version: "1.0.0", released_on: hoursAgo(1) }, - ]); - - assert.strictEqual(db.isNewlyReleasedPackage("foo_bar", "1.0.0"), true); - - ecosystem = "js"; - }); - - it("matches hyphen request names against underscore feed names for python", () => { - ecosystem = "py"; - - const db = buildNewPackagesDatabase([ - { source: "pypi", package_name: "foo_bar", version: "1.0.0", released_on: hoursAgo(1) }, - ]); - - assert.strictEqual(db.isNewlyReleasedPackage("foo-bar", "1.0.0"), true); - - ecosystem = "js"; - }); - - it("matches dot request names against hyphen feed names for python", () => { - ecosystem = "py"; - - const db = buildNewPackagesDatabase([ - { source: "pypi", package_name: "foo-bar", version: "1.0.0", released_on: hoursAgo(1) }, - ]); - - assert.strictEqual(db.isNewlyReleasedPackage("foo.bar", "1.0.0"), true); - - ecosystem = "js"; - }); - - it("matches underscore request names against dot feed names for python", () => { - ecosystem = "py"; - - const db = buildNewPackagesDatabase([ - { source: "pypi", package_name: "foo.bar", version: "1.0.0", released_on: hoursAgo(1) }, - ]); - - assert.strictEqual(db.isNewlyReleasedPackage("foo_bar", "1.0.0"), true); - - ecosystem = "js"; - }); - - }); -}); diff --git a/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.js b/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.js deleted file mode 100644 index fd742bb..0000000 --- a/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.js +++ /dev/null @@ -1,17 +0,0 @@ -import { ui } from "../environment/userInteraction.js"; - -let hasWarnedAboutUnavailableNewPackagesDatabase = false; - -/** @param {Error} error */ -export function warnOnceAboutUnavailableDatabase(error) { - if (!hasWarnedAboutUnavailableNewPackagesDatabase) { - ui.writeWarning( - `Failed to load the new packages list used for direct package download request blocking. Continuing with metadata-based minimum age checks only. ${error.message}` - ); - hasWarnedAboutUnavailableNewPackagesDatabase = true; - } -} - -export function resetWarningState() { - hasWarnedAboutUnavailableNewPackagesDatabase = false; -} diff --git a/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.spec.js deleted file mode 100644 index d36d5df..0000000 --- a/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, it, mock, beforeEach } from "node:test"; -import assert from "node:assert"; - -let writeWarningCalls = []; - -mock.module("../environment/userInteraction.js", { - namedExports: { - ui: { - writeWarning: (msg) => writeWarningCalls.push(msg), - }, - }, -}); - -const { warnOnceAboutUnavailableDatabase, resetWarningState } = await import( - "./newPackagesDatabaseWarnings.js" -); - -describe("newPackagesDatabaseWarnings", () => { - beforeEach(() => { - writeWarningCalls = []; - resetWarningState(); - }); - - describe("warnOnceAboutUnavailableDatabase", () => { - it("emits a warning containing the error message", () => { - warnOnceAboutUnavailableDatabase(new Error("feed unavailable")); - - assert.strictEqual(writeWarningCalls.length, 1); - assert.ok(writeWarningCalls[0].includes("feed unavailable")); - }); - - it("mentions fallback to metadata-based checks in the warning", () => { - warnOnceAboutUnavailableDatabase(new Error("timeout")); - - assert.ok( - writeWarningCalls[0].includes( - "Continuing with metadata-based minimum age checks only" - ) - ); - }); - - it("only emits once even when called multiple times", () => { - warnOnceAboutUnavailableDatabase(new Error("first")); - warnOnceAboutUnavailableDatabase(new Error("second")); - warnOnceAboutUnavailableDatabase(new Error("third")); - - assert.strictEqual(writeWarningCalls.length, 1); - }); - }); - - describe("resetWarningState", () => { - it("allows the warning to fire again after reset", () => { - warnOnceAboutUnavailableDatabase(new Error("first")); - assert.strictEqual(writeWarningCalls.length, 1); - - resetWarningState(); - writeWarningCalls = []; - - warnOnceAboutUnavailableDatabase(new Error("second")); - assert.strictEqual(writeWarningCalls.length, 1); - }); - }); -}); diff --git a/packages/safe-chain/src/scanning/newPackagesListCache.js b/packages/safe-chain/src/scanning/newPackagesListCache.js deleted file mode 100644 index 418dbdd..0000000 --- a/packages/safe-chain/src/scanning/newPackagesListCache.js +++ /dev/null @@ -1,123 +0,0 @@ -import fs from "fs"; -import { - fetchNewPackagesList, - fetchNewPackagesListVersion, -} from "../api/aikido.js"; -import { - getNewPackagesListPath, - getNewPackagesListVersionPath, -} from "../config/configFile.js"; -import { ui } from "../environment/userInteraction.js"; -import { buildNewPackagesDatabase } from "./newPackagesDatabaseBuilder.js"; -import { warnOnceAboutUnavailableDatabase } from "./newPackagesDatabaseWarnings.js"; - -/** - * @typedef {import("./newPackagesDatabaseBuilder.js").NewPackagesDatabase} NewPackagesDatabase - */ - -// Shared per-process cache to avoid rebuilding the same feed-backed database on each request. -// 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 | null} */ -let cachedNewPackagesDatabasePromise = null; - -/** - * @returns {Promise} - */ -export function openNewPackagesDatabase() { - if (!cachedNewPackagesDatabasePromise) { - cachedNewPackagesDatabasePromise = getNewPackagesList() - .then((newPackagesList) => buildNewPackagesDatabase(newPackagesList)) - .catch((/** @type {any} */ error) => { - warnOnceAboutUnavailableDatabase(error); - cachedNewPackagesDatabasePromise = null; - return { isNewlyReleasedPackage: () => false }; - }); - } - return cachedNewPackagesDatabasePromise; -} - -/** - * @returns {Promise} - */ -async function getNewPackagesList() { - const { newPackagesList: cachedList, version: cachedVersion } = - readNewPackagesListFromLocalCache(); - - try { - if (cachedList) { - const currentVersion = await fetchNewPackagesListVersion(); - if (cachedVersion === currentVersion) { - return cachedList; - } - } - - const { newPackagesList, version } = await fetchNewPackagesList(); - - if (version) { - writeNewPackagesListToLocalCache(newPackagesList, version); - return newPackagesList; - } else { - ui.writeWarning( - "The new packages list for direct package download request blocking was downloaded, but could not be cached due to a missing version." - ); - return newPackagesList; - } - } catch (/** @type {any} */ error) { - if (cachedList) { - ui.writeWarning( - "Failed to fetch the latest new packages list for direct package download request blocking. Using cached version." - ); - return cachedList; - } - throw error; - } -} - -/** - * @param {import("../api/aikido.js").NewPackageEntry[]} data - * @param {string | number} version - * - * @returns {void} - */ -export function writeNewPackagesListToLocalCache(data, version) { - try { - const listPath = getNewPackagesListPath(); - const versionPath = getNewPackagesListVersionPath(); - - fs.writeFileSync(listPath, JSON.stringify(data)); - fs.writeFileSync(versionPath, version.toString()); - } catch { - ui.writeWarning( - "Failed to write new packages list to local cache, next time the list will be fetched from the server again." - ); - } -} - -/** - * @returns {{newPackagesList: import("../api/aikido.js").NewPackageEntry[] | null, version: string | null}} - */ -export function readNewPackagesListFromLocalCache() { - try { - const listPath = getNewPackagesListPath(); - if (!fs.existsSync(listPath)) { - return { newPackagesList: null, version: null }; - } - - const data = fs.readFileSync(listPath, "utf8"); - const newPackagesList = JSON.parse(data); - const versionPath = getNewPackagesListVersionPath(); - let version = null; - if (fs.existsSync(versionPath)) { - version = fs.readFileSync(versionPath, "utf8").trim(); - } - return { newPackagesList, version }; - } catch { - ui.writeWarning( - "Failed to read new packages list from local cache. Continuing without local cache." - ); - return { newPackagesList: null, version: null }; - } -} diff --git a/packages/safe-chain/src/scanning/newPackagesListCache.spec.js b/packages/safe-chain/src/scanning/newPackagesListCache.spec.js deleted file mode 100644 index 503a0cc..0000000 --- a/packages/safe-chain/src/scanning/newPackagesListCache.spec.js +++ /dev/null @@ -1,178 +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"; - -let writeWarningCalls = []; -let ecosystem = "js"; -let testHomeDir = ""; - -mock.module("../environment/userInteraction.js", { - namedExports: { - ui: { - writeWarning: (msg) => writeWarningCalls.push(msg), - }, - }, -}); - -mock.module("../config/settings.js", { - namedExports: { - getEcoSystem: () => ecosystem, - getMinimumPackageAgeHours: () => 24, - getMalwareListBaseUrl: () => "https://malware-list.aikido.dev", - ECOSYSTEM_JS: "js", - ECOSYSTEM_PY: "py", - }, -}); - -const { readNewPackagesListFromLocalCache, writeNewPackagesListToLocalCache } = - await import("./newPackagesListCache.js"); - -describe("newPackagesListCache", () => { - beforeEach(() => { - writeWarningCalls = []; - ecosystem = "js"; - testHomeDir = path.join( - os.tmpdir(), - `safe-chain-list-cache-${process.pid}-${Date.now()}` - ); - fs.rmSync(testHomeDir, { recursive: true, force: true }); - fs.mkdirSync(testHomeDir, { recursive: true }); - process.env.HOME = testHomeDir; - }); - - describe("readNewPackagesListFromLocalCache", () => { - it("returns null for both fields when no cache file exists", () => { - const result = readNewPackagesListFromLocalCache(); - - assert.deepStrictEqual(result, { newPackagesList: null, version: null }); - }); - - it("returns the list and version when both files exist", () => { - const list = [{ package_name: "foo", version: "1.0.0" }]; - const safeChainDir = path.join(testHomeDir, ".safe-chain"); - fs.mkdirSync(safeChainDir, { recursive: true }); - fs.writeFileSync( - path.join(safeChainDir, "newPackagesList_js.json"), - JSON.stringify(list) - ); - fs.writeFileSync( - path.join(safeChainDir, "newPackagesList_version_js.txt"), - "etag-42" - ); - - const result = readNewPackagesListFromLocalCache(); - - assert.deepStrictEqual(result.newPackagesList, list); - assert.strictEqual(result.version, "etag-42"); - }); - - it("returns null version when version file is missing", () => { - const list = [{ package_name: "foo", version: "1.0.0" }]; - const safeChainDir = path.join(testHomeDir, ".safe-chain"); - fs.mkdirSync(safeChainDir, { recursive: true }); - fs.writeFileSync( - path.join(safeChainDir, "newPackagesList_js.json"), - JSON.stringify(list) - ); - - const result = readNewPackagesListFromLocalCache(); - - assert.deepStrictEqual(result.newPackagesList, list); - assert.strictEqual(result.version, null); - }); - - it("trims whitespace from the version string", () => { - const safeChainDir = path.join(testHomeDir, ".safe-chain"); - fs.mkdirSync(safeChainDir, { recursive: true }); - fs.writeFileSync( - path.join(safeChainDir, "newPackagesList_js.json"), - JSON.stringify([]) - ); - fs.writeFileSync( - path.join(safeChainDir, "newPackagesList_version_js.txt"), - " etag-trimmed \n" - ); - - const { version } = readNewPackagesListFromLocalCache(); - - assert.strictEqual(version, "etag-trimmed"); - }); - - it("uses the ecosystem name in the file path", () => { - ecosystem = "py"; - const safeChainDir = path.join(testHomeDir, ".safe-chain"); - fs.mkdirSync(safeChainDir, { recursive: true }); - fs.writeFileSync( - path.join(safeChainDir, "newPackagesList_py.json"), - JSON.stringify([{ package_name: "requests", version: "2.0.0" }]) - ); - - const result = readNewPackagesListFromLocalCache(); - - assert.ok(result.newPackagesList !== null); - }); - - it("warns and returns nulls when the list file contains invalid JSON", () => { - const safeChainDir = path.join(testHomeDir, ".safe-chain"); - fs.mkdirSync(safeChainDir, { recursive: true }); - fs.writeFileSync( - path.join(safeChainDir, "newPackagesList_js.json"), - "not-valid-json" - ); - - const result = readNewPackagesListFromLocalCache(); - - assert.deepStrictEqual(result, { newPackagesList: null, version: null }); - assert.strictEqual(writeWarningCalls.length, 1); - assert.ok(writeWarningCalls[0].includes("local cache")); - }); - }); - - describe("writeNewPackagesListToLocalCache", () => { - it("writes the list and version to disk", () => { - const safeChainDir = path.join(testHomeDir, ".safe-chain"); - fs.mkdirSync(safeChainDir, { recursive: true }); - - const list = [{ package_name: "foo", version: "1.0.0" }]; - writeNewPackagesListToLocalCache(list, "etag-99"); - - const writtenList = JSON.parse( - fs.readFileSync(path.join(safeChainDir, "newPackagesList_js.json"), "utf8") - ); - const writtenVersion = fs.readFileSync( - path.join(safeChainDir, "newPackagesList_version_js.txt"), - "utf8" - ); - - assert.deepStrictEqual(writtenList, list); - assert.strictEqual(writtenVersion, "etag-99"); - }); - - it("converts a numeric version to a string", () => { - const safeChainDir = path.join(testHomeDir, ".safe-chain"); - fs.mkdirSync(safeChainDir, { recursive: true }); - - writeNewPackagesListToLocalCache([], 42); - - const written = fs.readFileSync( - path.join(safeChainDir, "newPackagesList_version_js.txt"), - "utf8" - ); - assert.strictEqual(written, "42"); - }); - - it("warns when writing fails", () => { - // Place a regular file at the .safe-chain path so getSafeChainDirectory - // returns it as-is (existsSync is true) but writing a child path fails. - const safeChainPath = path.join(testHomeDir, ".safe-chain"); - fs.writeFileSync(safeChainPath, "not-a-directory"); - - writeNewPackagesListToLocalCache([], "etag-fail"); - - assert.strictEqual(writeWarningCalls.length, 1); - assert.ok(writeWarningCalls[0].includes("local cache")); - }); - }); -}); diff --git a/packages/safe-chain/src/scanning/packageNameVariants.js b/packages/safe-chain/src/scanning/packageNameVariants.js deleted file mode 100644 index 64075f2..0000000 --- a/packages/safe-chain/src/scanning/packageNameVariants.js +++ /dev/null @@ -1,29 +0,0 @@ -import { ECOSYSTEM_PY } from "../config/settings.js"; - -/** - * Normalises a Python package name per PEP 503: lowercase and collapse any - * run of `.`, `_`, or `-` into a single hyphen. - * @param {string} packageName - * @returns {string} - */ -export function normalizePipPackageName(packageName) { - return packageName.toLowerCase().replace(/[._-]+/g, "-"); -} - -/** - * @param {string} packageName - * @param {string} ecosystem - * @returns {string[]} - */ -export function getEquivalentPackageNames(packageName, ecosystem) { - if (ecosystem !== ECOSYSTEM_PY) { - return [packageName]; - } - - const pythonSeparatorPattern = /[._-]/g; - const hyphenName = packageName.replaceAll(pythonSeparatorPattern, "-"); - const underscoreName = packageName.replaceAll(pythonSeparatorPattern, "_"); - const dotName = packageName.replaceAll(pythonSeparatorPattern, "."); - - return [...new Set([packageName, hyphenName, underscoreName, dotName])]; -} diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index bc4ef6c..2345022 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -2,130 +2,15 @@ import { spawnSync } from "child_process"; import * as os from "os"; import fs from "fs"; import path from "path"; -import { ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; -import { safeSpawn } from "../utils/safeSpawn.js"; -import { ui } from "../environment/userInteraction.js"; -/** - * @typedef {Object} AikidoTool - * @property {string} tool - * @property {string} aikidoCommand - * @property {string} ecoSystem - * @property {string} internalPackageManagerName - */ - -/** - * @type {AikidoTool[]} - */ export const knownAikidoTools = [ - { - tool: "npm", - aikidoCommand: "aikido-npm", - ecoSystem: ECOSYSTEM_JS, - internalPackageManagerName: "npm", - }, - { - tool: "npx", - aikidoCommand: "aikido-npx", - ecoSystem: ECOSYSTEM_JS, - internalPackageManagerName: "npx", - }, - { - tool: "yarn", - aikidoCommand: "aikido-yarn", - ecoSystem: ECOSYSTEM_JS, - internalPackageManagerName: "yarn", - }, - { - tool: "pnpm", - aikidoCommand: "aikido-pnpm", - ecoSystem: ECOSYSTEM_JS, - internalPackageManagerName: "pnpm", - }, - { - tool: "pnpx", - aikidoCommand: "aikido-pnpx", - ecoSystem: ECOSYSTEM_JS, - internalPackageManagerName: "pnpx", - }, - { - tool: "rush", - aikidoCommand: "aikido-rush", - ecoSystem: ECOSYSTEM_JS, - internalPackageManagerName: "rush", - }, - { - tool: "rushx", - aikidoCommand: "aikido-rushx", - ecoSystem: ECOSYSTEM_JS, - internalPackageManagerName: "rushx", - }, - { - tool: "bun", - aikidoCommand: "aikido-bun", - ecoSystem: ECOSYSTEM_JS, - internalPackageManagerName: "bun", - }, - { - tool: "bunx", - aikidoCommand: "aikido-bunx", - ecoSystem: ECOSYSTEM_JS, - internalPackageManagerName: "bunx", - }, - { - tool: "uv", - aikidoCommand: "aikido-uv", - ecoSystem: ECOSYSTEM_PY, - internalPackageManagerName: "uv", - }, - { - tool: "uvx", - aikidoCommand: "aikido-uvx", - ecoSystem: ECOSYSTEM_PY, - internalPackageManagerName: "uvx", - }, - { - tool: "pip", - aikidoCommand: "aikido-pip", - ecoSystem: ECOSYSTEM_PY, - internalPackageManagerName: "pip", - }, - { - tool: "pip3", - aikidoCommand: "aikido-pip3", - ecoSystem: ECOSYSTEM_PY, - internalPackageManagerName: "pip", - }, - { - tool: "poetry", - aikidoCommand: "aikido-poetry", - ecoSystem: ECOSYSTEM_PY, - internalPackageManagerName: "poetry", - }, - { - tool: "python", - aikidoCommand: "aikido-python", - ecoSystem: ECOSYSTEM_PY, - internalPackageManagerName: "pip", - }, - { - tool: "python3", - aikidoCommand: "aikido-python3", - ecoSystem: ECOSYSTEM_PY, - internalPackageManagerName: "pip", - }, - { - tool: "pipx", - aikidoCommand: "aikido-pipx", - ecoSystem: ECOSYSTEM_PY, - internalPackageManagerName: "pipx", - }, - { - tool: "pdm", - aikidoCommand: "aikido-pdm", - ecoSystem: ECOSYSTEM_PY, - internalPackageManagerName: "pdm", - }, + { tool: "npm", aikidoCommand: "aikido-npm" }, + { tool: "npx", aikidoCommand: "aikido-npx" }, + { tool: "yarn", aikidoCommand: "aikido-yarn" }, + { tool: "pnpm", aikidoCommand: "aikido-pnpm" }, + { tool: "pnpx", aikidoCommand: "aikido-pnpx" }, + { tool: "bun", aikidoCommand: "aikido-bun" }, + { tool: "bunx", aikidoCommand: "aikido-bunx" }, // When adding a new tool here, also update the documentation for the new tool in the README.md ]; @@ -145,11 +30,6 @@ export function getPackageManagerList() { return `${tools.join(", ")}, and ${lastTool} commands`; } -/** - * @param {string} executableName - * - * @returns {boolean} - */ export function doesExecutableExistOnSystem(executableName) { if (os.platform() === "win32") { const result = spawnSync("where", [executableName], { stdio: "ignore" }); @@ -160,13 +40,6 @@ export function doesExecutableExistOnSystem(executableName) { } } -/** - * @param {string} filePath - * @param {RegExp} pattern - * @param {string} [eol] - * - * @returns {void} - */ export function removeLinesMatchingPattern(filePath, pattern, eol) { if (!fs.existsSync(filePath)) { return; @@ -181,12 +54,6 @@ export function removeLinesMatchingPattern(filePath, pattern, eol) { } const maxLineLength = 100; - -/** - * @param {string} line - * @param {RegExp} pattern - * @returns {boolean} - */ function shouldRemoveLine(line, pattern) { const isPatternMatch = pattern.test(line); @@ -215,34 +82,16 @@ function shouldRemoveLine(line, pattern) { return true; } -/** - * @param {string} filePath - * @param {string} line - * @param {string} [eol] - * - * @returns {void} - */ export function addLineToFile(filePath, line, eol) { createFileIfNotExists(filePath); eol = eol || os.EOL; const fileContent = fs.readFileSync(filePath, "utf-8"); - let updatedContent = fileContent; - - if (!fileContent.endsWith(eol)) { - updatedContent += eol; - } - - updatedContent += line + eol; + const updatedContent = fileContent + eol + line + eol; fs.writeFileSync(filePath, updatedContent, "utf-8"); } -/** - * @param {string} filePath - * - * @returns {void} - */ function createFileIfNotExists(filePath) { if (fs.existsSync(filePath)) { return; @@ -255,60 +104,3 @@ function createFileIfNotExists(filePath) { fs.writeFileSync(filePath, "", "utf-8"); } - -/** - * Checks if PowerShell execution policy allows script execution - * @param {string} shellExecutableName - The name of the PowerShell executable ("pwsh" or "powershell") - * @returns {Promise<{isValid: boolean, policy: string}>} validation result - */ -export async function validatePowerShellExecutionPolicy(shellExecutableName) { - // Security: Only allow known shell executables - const validShells = ["pwsh", "powershell"]; - if (!validShells.includes(shellExecutableName)) { - return { isValid: false, policy: "Unknown" }; - } - - try { - // For Windows PowerShell (5.1), clean PSModulePath to avoid conflicts with PowerShell 7 modules - // When safe-chain is invoked from PowerShell 7, it sets its module paths to PSModulePath, causing - // Windows PowerShell to try loading incompatible PowerShell 7 modules. - // Setting the environment to Windows PowerShell's modules fixes this. - let spawnOptions; - if (shellExecutableName === "powershell") { - const userProfile = process.env.USERPROFILE || ""; - const cleanPSModulePath = [ - path.join(userProfile, "Documents", "WindowsPowerShell", "Modules"), - "C:\\Program Files\\WindowsPowerShell\\Modules", - "C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\Modules", - ].join(";"); - - spawnOptions = { - env: { - ...process.env, - PSModulePath: cleanPSModulePath, - }, - }; - } else { - spawnOptions = {}; - } - - const commandResult = await safeSpawn( - shellExecutableName, - ["-Command", "Get-ExecutionPolicy"], - spawnOptions, - ); - - const policy = commandResult.stdout.trim(); - - const acceptablePolicies = ["RemoteSigned", "Unrestricted", "Bypass"]; - return { - isValid: acceptablePolicies.includes(policy), - policy: policy, - }; - } catch (err) { - ui.writeWarning( - `An error happened while trying to find the current executionpolicy in powershell: ${err}`, - ); - return { isValid: false, policy: "Unknown" }; - } -} diff --git a/packages/safe-chain/src/shell-integration/helpers.spec.js b/packages/safe-chain/src/shell-integration/helpers.spec.js index e93a690..4f18c36 100644 --- a/packages/safe-chain/src/shell-integration/helpers.spec.js +++ b/packages/safe-chain/src/shell-integration/helpers.spec.js @@ -1,6 +1,6 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert"; -import { tmpdir, homedir } from "node:os"; +import { tmpdir } from "node:os"; import fs from "node:fs"; import path from "path"; @@ -15,7 +15,6 @@ describe("removeLinesMatchingPatternTests", () => { mock.module("node:os", { namedExports: { EOL: "\r\n", // Simulate Windows line endings - homedir, tmpdir: tmpdir, platform: () => "linux", }, @@ -183,30 +182,3 @@ describe("removeLinesMatchingPatternTests", () => { assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines"); }); }); - -describe("getSafeChainBaseDir / getBinDir / getShimsDir / getScriptsDir", () => { - it("defaults base dir to ~/.safe-chain when no packaged install dir is available", async () => { - const { getSafeChainBaseDir } = await import("../config/safeChainDir.js"); - assert.strictEqual(getSafeChainBaseDir(), path.join(homedir(), ".safe-chain")); - }); - - it("getBinDir returns ~/.safe-chain/bin by default", async () => { - const { getBinDir } = await import("../config/safeChainDir.js"); - assert.strictEqual(getBinDir(), path.join(homedir(), ".safe-chain", "bin")); - }); - - it("getShimsDir returns ~/.safe-chain/shims by default", async () => { - const { getShimsDir } = await import("../config/safeChainDir.js"); - assert.strictEqual(getShimsDir(), path.join(homedir(), ".safe-chain", "shims")); - }); - - it("getScriptsDir returns ~/.safe-chain/scripts by default", async () => { - const { getScriptsDir } = await import("../config/safeChainDir.js"); - assert.strictEqual(getScriptsDir(), path.join(homedir(), ".safe-chain", "scripts")); - }); - - it("getCertsDir returns ~/.safe-chain/certs by default", async () => { - const { getCertsDir } = await import("../config/safeChainDir.js"); - assert.strictEqual(getCertsDir(), path.join(homedir(), ".safe-chain", "certs")); - }); -}); diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index 30ab833..6e6d826 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -4,31 +4,13 @@ # Function to remove shim from PATH (POSIX-compliant) remove_shim_from_path() { - _safe_chain_phys=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) - if [ -z "$_safe_chain_phys" ]; then - echo "$PATH" - return - fi - _path=$(echo "$PATH" | sed "s|${_safe_chain_phys}:||g") - # Also remove via dirname of $0 directly — on macOS /tmp is a symlink to /private/tmp, - # so pwd -P resolves to /private/tmp/… but PATH may still contain /tmp/…. - _dir=$(dirname -- "$0") - case "$_dir" in - /*) [ "$_dir" != "$_safe_chain_phys" ] && _path=$(echo "$_path" | sed "s|${_dir}:||g") ;; - esac - echo "$_path" + echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g" } -if command -v safe-chain >/dev/null 2>&1; then - # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops. - # Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't - # mistake argv[1] for a script path and try to resolve "{{PACKAGE_MANAGER}}" against cwd. - unset PKG_EXECPATH - PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@" +if command -v {{AIKIDO_COMMAND}} >/dev/null 2>&1; then + # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops + PATH=$(remove_shim_from_path) exec {{AIKIDO_COMMAND}} "$@" else - # safe-chain is not reachable — warn the user so they know protection is inactive - printf "\033[43;30mWarning:\033[0m safe-chain is not available to protect you from installing malware. {{PACKAGE_MANAGER}} will run without it.\n" >&2 - # Dynamically find original {{PACKAGE_MANAGER}} (excluding this shim directory) original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}}) if [ -n "$original_cmd" ]; then @@ -37,4 +19,4 @@ else echo "Error: Could not find original {{PACKAGE_MANAGER}}" >&2 exit 1 fi -fi +fi \ No newline at end of file diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd index b41fcfb..b7a65fa 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd @@ -3,15 +3,14 @@ REM Generated wrapper for {{PACKAGE_MANAGER}} by safe-chain REM This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments REM Remove shim directory from PATH to prevent infinite loops -set "SHIM_DIR=%~dp0" -if "%SHIM_DIR:~-1%"=="\" set "SHIM_DIR=%SHIM_DIR:~0,-1%" +set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims" call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%" REM Check if aikido command is available with clean PATH -set "PATH=%CLEAN_PATH%" & where safe-chain >nul 2>&1 +set "PATH=%CLEAN_PATH%" & where {{AIKIDO_COMMAND}} >nul 2>&1 if %errorlevel%==0 ( REM Call aikido command with clean PATH - set "PATH=%CLEAN_PATH%" & safe-chain {{PACKAGE_MANAGER}} %* + set "PATH=%CLEAN_PATH%" & {{AIKIDO_COMMAND}} %* ) else ( REM Find the original command with clean PATH for /f "tokens=*" %%i in ('set "PATH=%CLEAN_PATH%" ^& where {{PACKAGE_MANAGER}} 2^>nul') do ( @@ -22,4 +21,4 @@ if %errorlevel%==0 ( REM If we get here, original command was not found echo Error: Could not find original {{PACKAGE_MANAGER}} >&2 exit /b 1 -) +) \ No newline at end of file diff --git a/packages/safe-chain/src/shell-integration/pkg-execpath-cleanup.spec.js b/packages/safe-chain/src/shell-integration/pkg-execpath-cleanup.spec.js deleted file mode 100644 index 4057224..0000000 --- a/packages/safe-chain/src/shell-integration/pkg-execpath-cleanup.spec.js +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(__dirname, "..", ".."); - -describe("PKG_EXECPATH cleanup", () => { - it("unix shim template unsets PKG_EXECPATH before invoking safe-chain", () => { - const file = path.join( - repoRoot, - "src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh", - ); - const content = fs.readFileSync(file, "utf-8"); - assert.match( - content, - /unset PKG_EXECPATH[\s\S]*exec safe-chain/, - "unix-wrapper.template.sh must `unset PKG_EXECPATH` before `exec safe-chain`", - ); - }); - - it("posix shell function unsets PKG_EXECPATH before invoking safe-chain", () => { - const file = path.join( - repoRoot, - "src/shell-integration/startup-scripts/init-posix.sh", - ); - const content = fs.readFileSync(file, "utf-8"); - // Scoped subshell so we don't mutate the user's interactive env. - assert.match( - content, - /\(unset PKG_EXECPATH;\s*safe-chain "\$@"\)/, - "init-posix.sh must invoke safe-chain in a subshell that unsets PKG_EXECPATH", - ); - }); - - it("fish shell function unsets PKG_EXECPATH before invoking safe-chain", () => { - const file = path.join( - repoRoot, - "src/shell-integration/startup-scripts/init-fish.fish", - ); - const content = fs.readFileSync(file, "utf-8"); - assert.match( - content, - /env -u PKG_EXECPATH safe-chain/, - "init-fish.fish must invoke safe-chain via `env -u PKG_EXECPATH`", - ); - }); - - it("safe-chain entry point deletes PKG_EXECPATH from process.env", () => { - const file = path.join(repoRoot, "bin/safe-chain.js"); - const content = fs.readFileSync(file, "utf-8"); - assert.match( - content, - /delete process\.env\.PKG_EXECPATH/, - "bin/safe-chain.js must delete process.env.PKG_EXECPATH so spawned children don't inherit it", - ); - }); -}); diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index f9e6767..0449ac4 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -1,14 +1,10 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; -import { getPackageManagerList, knownAikidoTools } from "./helpers.js"; -import { - getShimsDir, - getBinDir, - getPathWrapperTemplatePath, -} from "../config/safeChainDir.js"; +import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; import fs from "fs"; import os from "os"; import path from "path"; +import { fileURLToPath } from "url"; /** * Loops over the detected shells and calls the setup function for each. @@ -20,8 +16,7 @@ export async function setupCi() { ); ui.emptyLine(); - const shimsDir = getShimsDir(); - const binDir = getBinDir(); + const shimsDir = path.join(os.homedir(), ".safe-chain", "shims"); // Create the shims directory if it doesn't exist if (!fs.existsSync(shimsDir)) { fs.mkdirSync(shimsDir, { recursive: true }); @@ -29,18 +24,20 @@ export async function setupCi() { createShims(shimsDir); ui.writeInformation(`Created shims in ${shimsDir}`); - modifyPathForCi(shimsDir, binDir); + modifyPathForCi(shimsDir); ui.writeInformation(`Added shims directory to PATH for CI environments.`); } -/** - * @param {string} shimsDir - * - * @returns {void} - */ function createUnixShims(shimsDir) { // Read the template file - const templatePath = getPathWrapperTemplatePath(import.meta.url, "unix-wrapper.template.sh"); + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const templatePath = path.resolve( + __dirname, + "path-wrappers", + "templates", + "unix-wrapper.template.sh" + ); if (!fs.existsSync(templatePath)) { ui.writeError(`Template file not found: ${templatePath}`); @@ -50,8 +47,7 @@ function createUnixShims(shimsDir) { const template = fs.readFileSync(templatePath, "utf-8"); // Create a shim for each tool - let created = 0; - for (const toolInfo of getToolsToSetup()) { + for (const toolInfo of knownAikidoTools) { const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); @@ -61,20 +57,23 @@ function createUnixShims(shimsDir) { // Make the shim executable on Unix systems fs.chmodSync(shimPath, 0o755); - created++; } - ui.writeInformation(`Created ${created} Unix shim(s) in ${shimsDir}`); + ui.writeInformation( + `Created ${knownAikidoTools.length} Unix shim(s) in ${shimsDir}` + ); } -/** - * @param {string} shimsDir - * - * @returns {void} - */ function createWindowsShims(shimsDir) { // Read the template file - const templatePath = getPathWrapperTemplatePath(import.meta.url, "windows-wrapper.template.cmd"); + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const templatePath = path.resolve( + __dirname, + "path-wrappers", + "templates", + "windows-wrapper.template.cmd" + ); if (!fs.existsSync(templatePath)) { ui.writeError(`Windows template file not found: ${templatePath}`); @@ -84,25 +83,20 @@ function createWindowsShims(shimsDir) { const template = fs.readFileSync(templatePath, "utf-8"); // Create a shim for each tool - let created = 0; - for (const toolInfo of getToolsToSetup()) { + for (const toolInfo of knownAikidoTools) { const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); - const shimPath = `${shimsDir}/${toolInfo.tool}.cmd`; + const shimPath = path.join(shimsDir, `${toolInfo.tool}.cmd`); fs.writeFileSync(shimPath, shimContent, "utf-8"); - created++; } - ui.writeInformation(`Created ${created} Windows shim(s) in ${shimsDir}`); + ui.writeInformation( + `Created ${knownAikidoTools.length} Windows shim(s) in ${shimsDir}` + ); } -/** - * @param {string} shimsDir - * - * @returns {void} - */ function createShims(shimsDir) { if (os.platform() === "win32") { createWindowsShims(shimsDir); @@ -111,20 +105,10 @@ function createShims(shimsDir) { } } -/** - * @param {string} shimsDir - * @param {string} binDir - * - * @returns {void} - */ -function modifyPathForCi(shimsDir, binDir) { +function modifyPathForCi(shimsDir) { if (process.env.GITHUB_PATH) { // In GitHub Actions, append the shims directory to GITHUB_PATH - fs.appendFileSync( - process.env.GITHUB_PATH, - shimsDir + os.EOL + binDir + os.EOL, - "utf-8" - ); + fs.appendFileSync(process.env.GITHUB_PATH, shimsDir + os.EOL, "utf-8"); ui.writeInformation( `Added shims directory to GITHUB_PATH for GitHub Actions.` ); @@ -135,18 +119,5 @@ function modifyPathForCi(shimsDir, binDir) { // ##vso[task.prependpath]/path/to/add // Logging this to stdout will cause the Azure Pipelines agent to pick it up ui.writeInformation("##vso[task.prependpath]" + shimsDir); - ui.writeInformation("##vso[task.prependpath]" + binDir); - } - - if (process.env.BASH_ENV) { - // In CircleCI, persisting PATH across steps is done by appending shell exports - // to the file referenced by BASH_ENV. CircleCI sources this file for 'run' each step. - const exportLine = `export PATH="${shimsDir}:${binDir}:$PATH"` + os.EOL; - fs.appendFileSync(process.env.BASH_ENV, exportLine, "utf-8"); - ui.writeInformation(`Added shims directory to BASH_ENV for CircleCI.`); } } - -function getToolsToSetup() { - return knownAikidoTools; -} diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index 7af41d6..0a26124 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -22,12 +22,12 @@ describe("Setup CI shell integration", () => { fs.mkdirSync(path.join(mockTemplateDir, "path-wrappers", "templates"), { recursive: true }); fs.writeFileSync( path.join(mockTemplateDir, "path-wrappers", "templates", "unix-wrapper.template.sh"), - "#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\n_safe_chain_shims=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" 2>/dev/null && pwd -P)\nexec {{AIKIDO_COMMAND}} \"$@\"\n", + "#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\nexec {{AIKIDO_COMMAND}} \"$@\"\n", "utf-8" ); fs.writeFileSync( path.join(mockTemplateDir, "path-wrappers", "templates", "windows-wrapper.template.cmd"), - "@echo off\nset \"SHIM_DIR=%~dp0\"\n{{AIKIDO_COMMAND}} %*\n", + "@echo off\nREM Template for {{PACKAGE_MANAGER}}\n{{AIKIDO_COMMAND}} %*\n", "utf-8" ); @@ -53,15 +53,6 @@ describe("Setup CI shell integration", () => { }, }); - mock.module("../config/safeChainDir.js", { - namedExports: { - getShimsDir: () => mockShimsDir, - getBinDir: () => path.join(mockHomeDir, ".safe-chain", "bin"), - getPathWrapperTemplatePath: (_moduleUrl, fileName) => - path.join(mockTemplateDir, "path-wrappers", "templates", fileName), - }, - }); - // Mock os module mock.module("os", { namedExports: { @@ -71,6 +62,22 @@ describe("Setup CI shell integration", () => { }, }); + // Mock path module to resolve templates correctly + mock.module("path", { + namedExports: { + join: path.join, + dirname: () => mockTemplateDir, + resolve: (...args) => path.resolve(mockTemplateDir, ...args.slice(1)), + }, + }); + + // Mock fileURLToPath + mock.module("url", { + namedExports: { + fileURLToPath: () => path.join(mockTemplateDir, "setup-ci.js"), + }, + }); + // Import setupCi module after mocking setupCi = (await import("./setup-ci.js")).setupCi; }); @@ -111,10 +118,6 @@ describe("Setup CI shell integration", () => { const npmShimContent = fs.readFileSync(npmShimPath, "utf-8"); assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm"); assert.ok(npmShimContent.includes("#!/bin/bash"), "npm shim should have bash shebang"); - assert.ok( - npmShimContent.includes("_safe_chain_shims=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" 2>/dev/null && pwd -P)"), - "npm shim should derive the shims directory from its own location", - ); }); it("should create Windows .cmd shims on win32 platform", async () => { @@ -138,14 +141,10 @@ describe("Setup CI shell integration", () => { assert.ok(npmShimContent.includes("aikido-npm"), "npm.cmd should contain aikido-npm"); assert.ok(npmShimContent.includes("@echo off"), "npm.cmd should have Windows batch header"); assert.ok(npmShimContent.includes("%*"), "npm.cmd should use Windows argument passing"); - assert.ok( - npmShimContent.includes('set "SHIM_DIR=%~dp0"'), - "npm.cmd should derive the shims directory from its own location", - ); // Verify Unix shims were NOT created const unixNpmShim = path.join(mockShimsDir, "npm"); assert.ok(!fs.existsSync(unixNpmShim), "Unix npm shim should not exist on Windows"); }); }); -}); +}); \ No newline at end of file diff --git a/packages/safe-chain/src/shell-integration/setup.js b/packages/safe-chain/src/shell-integration/setup.js index 04534df..afa96e8 100644 --- a/packages/safe-chain/src/shell-integration/setup.js +++ b/packages/safe-chain/src/shell-integration/setup.js @@ -2,9 +2,10 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; import { detectShells } from "./shellDetection.js"; import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; -import { getScriptsDir, getStartupScriptSourcePath } from "../config/safeChainDir.js"; import fs from "fs"; +import os from "os"; import path from "path"; +import { fileURLToPath } from "url"; /** * Loops over the detected shells and calls the setup function for each. @@ -12,7 +13,7 @@ import path from "path"; export async function setup() { ui.writeInformation( chalk.bold("Setting up shell aliases.") + - ` This will wrap safe-chain around ${getPackageManagerList()}.`, + ` This will wrap safe-chain around ${getPackageManagerList()}.` ); ui.emptyLine(); @@ -28,12 +29,12 @@ export async function setup() { ui.writeInformation( `Detected ${shells.length} supported shell(s): ${shells .map((shell) => chalk.bold(shell.name)) - .join(", ")}.`, + .join(", ")}.` ); let updatedCount = 0; for (const shell of shells) { - if (await setupShell(shell)) { + if (setupShell(shell)) { updatedCount++; } } @@ -42,9 +43,9 @@ export async function setup() { ui.emptyLine(); ui.writeInformation(`Please restart your terminal to apply the changes.`); } - } catch (/** @type {any} */ error) { + } catch (error) { ui.writeError( - `Failed to set up shell aliases: ${error.message}. Please check your shell configuration.`, + `Failed to set up shell aliases: ${error.message}. Please check your shell configuration.` ); return; } @@ -52,15 +53,14 @@ export async function setup() { /** * Calls the setup function for the given shell and reports the result. - * @param {import("./shellDetection.js").Shell} shell */ -async function setupShell(shell) { +function setupShell(shell) { let success = false; let error; try { shell.teardown(knownAikidoTools); // First, tear down to prevent duplicate aliases - success = await shell.setup(knownAikidoTools); - } catch (/** @type {any} */ err) { + success = shell.setup(knownAikidoTools); + } catch (err) { success = false; error = err; } @@ -68,12 +68,14 @@ async function setupShell(shell) { if (success) { ui.writeInformation( `${chalk.bold("- " + shell.name + ":")} ${chalk.green( - "Setup successful", - )}`, + "Setup successful" + )}` ); } else { ui.writeError( - `${chalk.bold("- " + shell.name + ":")} ${chalk.red("Setup failed")}`, + `${chalk.bold("- " + shell.name + ":")} ${chalk.red( + "Setup failed" + )}. Please check your ${shell.name} configuration.` ); if (error) { let message = ` Error: ${error.message}`; @@ -82,12 +84,6 @@ async function setupShell(shell) { } ui.writeError(message); } - ui.emptyLine(); - ui.writeInformation(` ${chalk.bold("To set up manually:")}`); - for (const instruction of shell.getManualSetupInstructions()) { - ui.writeInformation(` ${instruction}`); - } - ui.emptyLine(); } return success; @@ -95,16 +91,19 @@ async function setupShell(shell) { function copyStartupFiles() { const startupFiles = ["init-posix.sh", "init-pwsh.ps1", "init-fish.fish"]; - const targetDir = getScriptsDir(); for (const file of startupFiles) { - const targetPath = path.join(targetDir, file); + const targetDir = path.join(os.homedir(), ".safe-chain", "scripts"); + const targetPath = path.join(os.homedir(), ".safe-chain", "scripts", file); if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } - const sourcePath = getStartupScriptSourcePath(import.meta.url, file); + // Use absolute path for source + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const sourcePath = path.resolve(__dirname, "startup-scripts", file); fs.copyFileSync(sourcePath, targetPath); } } diff --git a/packages/safe-chain/src/shell-integration/shellDetection.js b/packages/safe-chain/src/shell-integration/shellDetection.js index c471244..d868f6f 100644 --- a/packages/safe-chain/src/shell-integration/shellDetection.js +++ b/packages/safe-chain/src/shell-integration/shellDetection.js @@ -5,19 +5,6 @@ import windowsPowershell from "./supported-shells/windowsPowershell.js"; import fish from "./supported-shells/fish.js"; import { ui } from "../environment/userInteraction.js"; -/** - * @typedef {Object} Shell - * @property {string} name - * @property {() => boolean} isInstalled - * @property {(tools: import("./helpers.js").AikidoTool[]) => boolean|Promise} setup - * @property {(tools: import("./helpers.js").AikidoTool[]) => boolean} teardown - * @property {() => string[]} getManualSetupInstructions - * @property {() => string[]} getManualTeardownInstructions - */ - -/** - * @returns {Shell[]} - */ export function detectShells() { let possibleShells = [zsh, bash, powershell, windowsPowershell, fish]; let availableShells = []; @@ -28,9 +15,9 @@ export function detectShells() { availableShells.push(shell); } } - } catch (/** @type {any} */ error) { + } catch (error) { ui.writeError( - `We were not able to detect which shells are installed on your system. Please check your shell configuration. Error: ${error.message}`, + `We were not able to detect which shells are installed on your system. Please check your shell configuration. Error: ${error.message}` ); return []; } diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index 697cc80..29d6bf3 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -1,38 +1,57 @@ -set -l safe_chain_script (status filename) -set -l safe_chain_scripts_dir (dirname $safe_chain_script) -set -l safe_chain_base (dirname $safe_chain_scripts_dir) -set -gx PATH $PATH $safe_chain_base/bin +function printSafeChainWarning + set original_cmd $argv[1] + + # Fish equivalent of ANSI color codes: yellow background, black text for "Warning:" + set_color -b yellow black + printf "Warning:" + set_color normal + printf " safe-chain is not available to protect you from installing malware. %s will run without it.\n" $original_cmd + + # Cyan text for the install command + printf "Install safe-chain by using " + set_color cyan + printf "npm install -g @aikidosec/safe-chain" + set_color normal + printf ".\n" +end + +function wrapSafeChainCommand + set original_cmd $argv[1] + set aikido_cmd $argv[2] + set cmd_args $argv[3..-1] + + if type -q $aikido_cmd + # If the aikido command is available, just run it with the provided arguments + $aikido_cmd $cmd_args + else + # If the aikido command is not available, print a warning and run the original command + printSafeChainWarning $original_cmd + command $original_cmd $cmd_args + end +end function npx - wrapSafeChainCommand "npx" $argv + wrapSafeChainCommand "npx" "aikido-npx" $argv end function yarn - wrapSafeChainCommand "yarn" $argv + wrapSafeChainCommand "yarn" "aikido-yarn" $argv end function pnpm - wrapSafeChainCommand "pnpm" $argv + wrapSafeChainCommand "pnpm" "aikido-pnpm" $argv end function pnpx - wrapSafeChainCommand "pnpx" $argv -end - -function rush - wrapSafeChainCommand "rush" $argv -end - -function rushx - wrapSafeChainCommand "rushx" $argv + wrapSafeChainCommand "pnpx" "aikido-pnpx" $argv end function bun - wrapSafeChainCommand "bun" $argv + wrapSafeChainCommand "bun" "aikido-bun" $argv end function bunx - wrapSafeChainCommand "bunx" $argv + wrapSafeChainCommand "bunx" "aikido-bunx" $argv end function npm @@ -47,90 +66,5 @@ function npm end end - wrapSafeChainCommand "npm" $argv -end - -function pip - wrapSafeChainCommand "pip" $argv -end - -function pip3 - wrapSafeChainCommand "pip3" $argv -end - -function uv - wrapSafeChainCommand "uv" $argv -end - -function uvx - wrapSafeChainCommand "uvx" $argv -end - -function poetry - wrapSafeChainCommand "poetry" $argv -end - -# `python -m pip`, `python -m pip3`. -function python - wrapSafeChainCommand "python" $argv -end - -# `python3 -m pip`, `python3 -m pip3'. -function python3 - wrapSafeChainCommand "python3" $argv -end - -function pipx - wrapSafeChainCommand "pipx" $argv -end - -function pdm - wrapSafeChainCommand "pdm" $argv -end - -function printSafeChainWarning - set original_cmd $argv[1] - - # Fish equivalent of ANSI color codes: yellow background, black text for "Warning:" - set_color -b yellow black - printf "Warning:" - set_color normal - printf " safe-chain is not available to protect you from installing malware. %s will run without it.\n" $original_cmd - - # Cyan text for the install command - printf "Install safe-chain by using " - set_color cyan - printf "npm install -g @aikidosec/safe-chain" - set_color normal - printf ".\n" -end - -function wrapSafeChainCommand - set original_cmd $argv[1] - set cmd_args $argv[2..-1] - - if not type -fq $original_cmd - # If the original command is not available, don't try to wrap it: invoke - # it transparently, so the shell can report errors as if this wrapper - # didn't exist. fish always adds extra debug information when executing - # missing commands from within a function, so after the "command not - # found" handler, there will be information about how the - # wrapSafeChainCommand function errored out. To avoid users assuming this - # is a safe-chain bug, display an explicit error message afterwards. - command $original_cmd $cmd_args - set oldstatus $status - echo "safe-chain tried to run $original_cmd but it doesn't seem to be installed in your \$PATH." >&2 - return $oldstatus - end - - if type -q safe-chain - # If the safe-chain command is available, just run it with the provided arguments. - # Unset PKG_EXECPATH for this invocation so the yao-pkg bootstrap inside the - # safe-chain binary doesn't mistake argv[1] for a script path to resolve against cwd. - env -u PKG_EXECPATH safe-chain $original_cmd $cmd_args - else - # If the safe-chain command is not available, print a warning and run the original command - printSafeChainWarning $original_cmd - command $original_cmd $cmd_args - end + wrapSafeChainCommand "npm" "aikido-npm" $argv end diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index df5623d..353c6c0 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -1,97 +1,3 @@ -if [ -n "${BASH_SOURCE[0]:-}" ]; then - _sc_script_path="${BASH_SOURCE[0]}" -elif [ -n "${ZSH_VERSION:-}" ]; then - # ${(%):-%x} uses Zsh prompt expansion to get the sourced file's path. - # eval is required so other shells don't try to parse the Zsh-specific syntax. - eval '_sc_script_path="${(%):-%x}"' -else - _sc_script_path="$0" -fi -_sc_scripts_dir=$(CDPATH= cd -- "$(dirname -- "$_sc_script_path")" 2>/dev/null && pwd -P) -_sc_base=$(dirname -- "$_sc_scripts_dir") -export PATH="$PATH:${_sc_base}/bin" -unset _sc_base _sc_script_path _sc_scripts_dir - -function npx() { - wrapSafeChainCommand "npx" "$@" -} - -function yarn() { - wrapSafeChainCommand "yarn" "$@" -} - -function pnpm() { - wrapSafeChainCommand "pnpm" "$@" -} - -function pnpx() { - wrapSafeChainCommand "pnpx" "$@" -} - -function rush() { - wrapSafeChainCommand "rush" "$@" -} - -function rushx() { - wrapSafeChainCommand "rushx" "$@" -} - -function bun() { - wrapSafeChainCommand "bun" "$@" -} - -function bunx() { - wrapSafeChainCommand "bunx" "$@" -} - -function npm() { - if [[ "$1" == "-v" || "$1" == "--version" ]] && [[ $# -eq 1 ]]; then - # If args is just -v or --version and nothing else, just run the npm version command - # This is because nvm uses this to check the version of npm - command npm "$@" - return - fi - - wrapSafeChainCommand "npm" "$@" -} - -function pip() { - wrapSafeChainCommand "pip" "$@" -} - -function pip3() { - wrapSafeChainCommand "pip3" "$@" -} - -function uv() { - wrapSafeChainCommand "uv" "$@" -} - -function uvx() { - wrapSafeChainCommand "uvx" "$@" -} - -function poetry() { - wrapSafeChainCommand "poetry" "$@" -} - -# `python -m pip`, `python -m pip3`. -function python() { - wrapSafeChainCommand "python" "$@" -} - -# `python3 -m pip`, `python3 -m pip3'. -function python3() { - wrapSafeChainCommand "python3" "$@" -} - -function pipx() { - wrapSafeChainCommand "pipx" "$@" -} - -function pdm() { - wrapSafeChainCommand "pdm" "$@" -} function printSafeChainWarning() { # \033[43;30m is used to set the background color to yellow and text color to black @@ -103,24 +9,54 @@ function printSafeChainWarning() { function wrapSafeChainCommand() { local original_cmd="$1" + local aikido_cmd="$2" - if ! type -f "${original_cmd}" > /dev/null 2>&1; then - # If the original command is not available, don't try to wrap it: invoke it - # transparently, so the shell can report errors as if this wrapper didn't - # exist. - command "$@" - return $? - fi + # Remove the first 2 arguments (original_cmd and aikido_cmd) from $@ + # so that "$@" now contains only the arguments passed to the original command + shift 2 - if command -v safe-chain > /dev/null 2>&1; then - # If the aikido command is available, just run it with the provided arguments. - # Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't - # mistake argv[1] for a script path and try to resolve it against cwd. - (unset PKG_EXECPATH; safe-chain "$@") + if command -v "$aikido_cmd" > /dev/null 2>&1; then + # If the aikido command is available, just run it with the provided arguments + "$aikido_cmd" "$@" else # If the aikido command is not available, print a warning and run the original command printSafeChainWarning "$original_cmd" - command "$@" + command "$original_cmd" "$@" fi } + +function npx() { + wrapSafeChainCommand "npx" "aikido-npx" "$@" +} + +function yarn() { + wrapSafeChainCommand "yarn" "aikido-yarn" "$@" +} + +function pnpm() { + wrapSafeChainCommand "pnpm" "aikido-pnpm" "$@" +} + +function pnpx() { + wrapSafeChainCommand "pnpx" "aikido-pnpx" "$@" +} + +function bun() { + wrapSafeChainCommand "bun" "aikido-bun" "$@" +} + +function bunx() { + wrapSafeChainCommand "bunx" "aikido-bunx" "$@" +} + +function npm() { + if [[ "$1" == "-v" || "$1" == "--version" ]] && [[ $# -eq 1 ]]; then + # If args is just -v or --version and nothing else, just run the npm version command + # This is because nvm uses this to check the version of npm + command npm "$@" + return + fi + + wrapSafeChainCommand "npm" "aikido-npm" "$@" +} diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index fb63ce8..a449405 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -1,92 +1,3 @@ -# Use cross-platform path separator (: on Unix, ; on Windows) -# $IsWindows is only available in PowerShell Core 6.0+. If it doesn't exist, assume Windows PowerShell -$isWindowsPlatform = if (Test-Path variable:IsWindows) { $IsWindows } else { $true } -$pathSeparator = if ($isWindowsPlatform) { ';' } else { ':' } -$safeChainBase = Split-Path -Parent $PSScriptRoot -$safeChainBin = Join-Path $safeChainBase 'bin' -$env:PATH = "$env:PATH$pathSeparator$safeChainBin" - -function npx { - Invoke-WrappedCommand "npx" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function yarn { - Invoke-WrappedCommand "yarn" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function pnpm { - Invoke-WrappedCommand "pnpm" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function pnpx { - Invoke-WrappedCommand "pnpx" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function rush { - Invoke-WrappedCommand "rush" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function rushx { - Invoke-WrappedCommand "rushx" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function bun { - Invoke-WrappedCommand "bun" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function bunx { - Invoke-WrappedCommand "bunx" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function npm { - # If args is just -v or --version and nothing else, just run the npm version command - # This is because nvm uses this to check the version of npm - if (($args.Length -eq 1) -and (($args[0] -eq "-v") -or ($args[0] -eq "--version"))) { - Invoke-RealCommand "npm" $args - return - } - - Invoke-WrappedCommand "npm" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function pip { - Invoke-WrappedCommand "pip" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function pip3 { - Invoke-WrappedCommand "pip3" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function uv { - Invoke-WrappedCommand "uv" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function uvx { - Invoke-WrappedCommand "uvx" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function poetry { - Invoke-WrappedCommand "poetry" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -# `python -m pip`, `python -m pip3`. -function python { - Invoke-WrappedCommand 'python' $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -# `python3 -m pip`, `python3 -m pip3'. -function python3 { - Invoke-WrappedCommand 'python3' $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function pipx { - Invoke-WrappedCommand "pipx" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - -function pdm { - Invoke-WrappedCommand "pdm" $args $MyInvocation.Line $MyInvocation.OffsetInLine -} - function Write-SafeChainWarning { param([string]$Command) @@ -125,64 +36,53 @@ function Invoke-RealCommand { } } -function Get-ReconstructedArguments { - param( - [string]$RawLine, - [int]$RawOffset - ) - - if (-not $RawLine) { return $null } - - $tokens = [System.Management.Automation.PSParser]::Tokenize($RawLine, [ref]$null) - $newArgs = @() - $foundCommand = $false - - foreach ($t in $tokens) { - if (-not $foundCommand) { - if ($t.Start -eq ($RawOffset - 1)) { $foundCommand = $true } - continue - } - - if ($t.Type -eq 'Operator' -and $t.Content -match '[|;&]') { break } - - # Stop if complex variable expansion is used - if ($t.Type -eq 'Variable' -or $t.Type -eq 'Group' -or $t.Type -eq 'SubExpression') { - return $null - } - - $newArgs += $t.Content - } - - if ($foundCommand) { - return ,$newArgs - } - return $null -} - function Invoke-WrappedCommand { param( [string]$OriginalCmd, - [string[]]$Arguments, - [string]$RawLine = $null, - [int]$RawOffset = 0 + [string]$AikidoCmd, + [string[]]$Arguments ) - # Use raw line parsing to recover arguments like '--' that PowerShell consumes - if ($RawLine) { - $reconstructedArgs = Get-ReconstructedArguments $RawLine $RawOffset - if ($null -ne $reconstructedArgs) { - $Arguments = $reconstructedArgs - } - } - - if ($isWindowsPlatform -and (Test-CommandAvailable "safe-chain.cmd")) { - & safe-chain.cmd $OriginalCmd @Arguments - } - elseif (Test-CommandAvailable "safe-chain") { - & safe-chain $OriginalCmd @Arguments + if (Test-CommandAvailable $AikidoCmd) { + & $AikidoCmd @Arguments } else { Write-SafeChainWarning $OriginalCmd Invoke-RealCommand $OriginalCmd $Arguments } } + +function npx { + Invoke-WrappedCommand "npx" "aikido-npx" $args +} + +function yarn { + Invoke-WrappedCommand "yarn" "aikido-yarn" $args +} + +function pnpm { + Invoke-WrappedCommand "pnpm" "aikido-pnpm" $args +} + +function pnpx { + Invoke-WrappedCommand "pnpx" "aikido-pnpx" $args +} + +function bun { + Invoke-WrappedCommand "bun" "aikido-bun" $args +} + +function bunx { + Invoke-WrappedCommand "bunx" "aikido-bunx" $args +} + +function npm { + # If args is just -v or --version and nothing else, just run the npm version command + # This is because nvm uses this to check the version of npm + if (($args.Length -eq 1) -and (($args[0] -eq "-v") -or ($args[0] -eq "--version"))) { + Invoke-RealCommand "npm" $args + return + } + + Invoke-WrappedCommand "npm" "aikido-npm" $args +} diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 956429d..6038f95 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -3,10 +3,8 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, } from "../helpers.js"; -import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync, spawnSync } from "child_process"; import * as os from "os"; -import path from "path"; const shellName = "Bash"; const executableName = "bash"; @@ -17,11 +15,6 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } -/** - * @param {import("../helpers.js").AikidoTool[]} tools - * - * @returns {boolean} - */ function teardown(tools) { const startupFile = getStartupFile(); @@ -34,10 +27,10 @@ function teardown(tools) { ); } - // Remove sourcing line to disable safe-chain shell integration + // Removes the line that sources the safe-chain bash initialization script (~/.aikido/scripts/init-posix.sh) removeLinesMatchingPattern( startupFile, - /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, + /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/, eol ); @@ -46,11 +39,10 @@ function teardown(tools) { function setup() { const startupFile = getStartupFile(); - const scriptsDir = getShellScriptsDir(); addLineToFile( startupFile, - `source ${path.posix.join(scriptsDir, "init-posix.sh")} # Safe-chain bash initialization script`, + `source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script`, eol ); @@ -65,18 +57,13 @@ function getStartupFile() { }).trim(); return windowsFixPath(path); - } catch (/** @type {any} */ error) { + } catch (error) { throw new Error( `Command failed: ${startupFileCommand}. Error: ${error.message}` ); } } -/** - * @param {string} path - * - * @returns {string} - */ function windowsFixPath(path) { try { if (os.platform() !== "win32") { @@ -97,51 +84,6 @@ function windowsFixPath(path) { } } -function getShellScriptsDir() { - return toBashPath(getScriptsDir()); -} - -/** - * @param {string} path - * - * @returns {string} - */ -function toBashPath(path) { - try { - if (os.platform() !== "win32") { - return path.replace(/\\/g, "/"); - } - - const directWindowsPath = windowsPathToBashPath(path); - if (directWindowsPath) { - return directWindowsPath; - } - - if (hasCygpath()) { - return convertCygwinPathToUnix(path); - } - - return path.replace(/\\/g, "/"); - } catch { - return path.replace(/\\/g, "/"); - } -} - -/** - * @param {string} path - * - * @returns {string | undefined} - */ -function windowsPathToBashPath(path) { - const match = /^([A-Za-z]):[\\/](.*)$/.exec(path); - if (!match) { - return undefined; - } - - const [, driveLetter, rest] = match; - return `/${driveLetter.toLowerCase()}/${rest.replace(/\\/g, "/")}`; -} - function hasCygpath() { try { var result = spawnSync("where", ["cygpath"], { shell: executableName }); @@ -151,11 +93,6 @@ function hasCygpath() { } } -/** - * @param {string} path - * - * @returns {string} - */ function cygpathw(path) { try { var result = spawnSync("cygpath", ["-w", path], { @@ -171,52 +108,9 @@ function cygpathw(path) { } } -/** - * @param {string} path - * - * @returns {string} - */ -function convertCygwinPathToUnix(path) { - try { - var result = spawnSync("cygpath", ["-u", path], { - encoding: "utf8", - shell: executableName, - }); - if (result.status === 0) { - return result.stdout.trim(); - } - return path.replace(/\\/g, "/"); - } catch { - return path.replace(/\\/g, "/"); - } -} - -function getManualTeardownInstructions() { - const scriptsDir = getShellScriptsDir(); - return [ - `Remove the following line from your ~/.bashrc file:`, - ` source ${path.posix.join(scriptsDir, "init-posix.sh")}`, - `Then restart your terminal or run: source ~/.bashrc`, - ]; -} - -function getManualSetupInstructions() { - const scriptsDir = getShellScriptsDir(); - return [ - `Add the following line to your ~/.bashrc file:`, - ` source ${path.posix.join(scriptsDir, "init-posix.sh")}`, - `Then restart your terminal or run: source ~/.bashrc`, - ]; -} - -/** - * @type {import("../shellDetection.js").Shell} - */ export default { name: shellName, isInstalled, setup, teardown, - getManualSetupInstructions, - getManualTeardownInstructions, }; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index ac80d1f..aa7159f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -9,7 +9,6 @@ describe("Bash shell integration", () => { let mockStartupFile; let bash; let windowsCygwinPath = ""; - let mockScriptsDir = "/test-home/.safe-chain/scripts"; let platform = "linux"; beforeEach(async () => { @@ -36,12 +35,6 @@ describe("Bash shell integration", () => { }, }); - mock.module("../../config/safeChainDir.js", { - namedExports: { - getScriptsDir: () => mockScriptsDir, - }, - }); - // Mock child_process execSync mock.module("child_process", { namedExports: { @@ -68,17 +61,6 @@ describe("Bash shell integration", () => { stdout: windowsCygwinPath + "\n", }; } - - if ( - command === "cygpath" && - args[0] === "-u" && - args[1] === mockScriptsDir - ) { - return { - status: 0, - stdout: "/c/test-home/.safe-chain/scripts\n", - }; - } }, }, }); @@ -105,7 +87,6 @@ describe("Bash shell integration", () => { // Reset mocks mock.reset(); - mockScriptsDir = "/test-home/.safe-chain/scripts"; platform = "linux"; }); @@ -128,7 +109,7 @@ describe("Bash shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" + "source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" ) ); }); @@ -148,24 +129,7 @@ describe("Bash shell integration", () => { const content = fs.readFileSync(windowsCygwinPath, "utf-8"); assert.ok( content.includes( - "source /c/test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" - ) - ); - }); - - it("should write a bash-compatible scripts path on Windows", () => { - platform = "win32"; - windowsCygwinPath = mockStartupFile; - mockScriptsDir = "C:\\test-home\\.safe-chain\\scripts"; - mockStartupFile = "DUMMY"; - - const result = bash.setup(); - assert.strictEqual(result, true); - - const content = fs.readFileSync(windowsCygwinPath, "utf-8"); - assert.ok( - content.includes( - "source /c/test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" + "source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" ) ); }); @@ -245,13 +209,13 @@ describe("Bash shell integration", () => { // Setup bash.setup(tools); let content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh")); + assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh")); // Teardown bash.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") + !content.includes("source ~/.safe-chain/scripts/init-posix.sh") ); }); @@ -272,7 +236,7 @@ describe("Bash shell integration", () => { const initialContent = [ "#!/bin/bash", "alias npm='old-npm'", - "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script", + "source ~/.safe-chain/scripts/init-posix.sh", "alias ls='ls --color=auto'", ].join("\n"); @@ -283,7 +247,7 @@ describe("Bash shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("alias npm=")); assert.ok( - !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script") + !content.includes("source ~/.safe-chain/scripts/init-posix.sh") ); assert.ok(content.includes("alias ls=")); }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index 95c867b..4c39ba6 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -3,9 +3,7 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, } from "../helpers.js"; -import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; -import path from "path"; const shellName = "Fish"; const executableName = "fish"; @@ -16,11 +14,6 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } -/** - * @param {import("../helpers.js").AikidoTool[]} tools - * - * @returns {boolean} - */ function teardown(tools) { const startupFile = getStartupFile(); @@ -33,10 +26,10 @@ function teardown(tools) { ); } - // Remove sourcing line to prevent safe-chain initialization in future shell sessions + // Removes the line that sources the safe-chain fish initialization script (~/.safe-chain/scripts/init-fish.fish) removeLinesMatchingPattern( startupFile, - /^source\s+.*init-fish\.fish.*#\s*Safe-chain/, + /^source\s+~\/\.safe-chain\/scripts\/init-fish\.fish/, eol ); @@ -48,7 +41,7 @@ function setup() { addLineToFile( startupFile, - `source ${path.join(getScriptsDir(), "init-fish.fish")} # Safe-chain Fish initialization script`, + `source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script`, eol ); @@ -61,37 +54,16 @@ function getStartupFile() { encoding: "utf8", shell: executableName, }).trim(); - } catch (/** @type {any} */ error) { + } catch (error) { throw new Error( `Command failed: ${startupFileCommand}. Error: ${error.message}` ); } } -function getManualTeardownInstructions() { - return [ - `Remove the following line from your ~/.config/fish/config.fish file:`, - ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, - `Then restart your terminal or run: source ~/.config/fish/config.fish`, - ]; -} - -function getManualSetupInstructions() { - return [ - `Add the following line to your ~/.config/fish/config.fish file:`, - ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, - `Then restart your terminal or run: source ~/.config/fish/config.fish`, - ]; -} - -/** - * @type {import("../shellDetection.js").Shell} - */ export default { name: shellName, isInstalled, setup, teardown, - getManualSetupInstructions, - getManualTeardownInstructions, }; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index c1c5715..e138957 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -33,12 +33,6 @@ describe("Fish shell integration", () => { }, }); - mock.module("../../config/safeChainDir.js", { - namedExports: { - getScriptsDir: () => "/test-home/.safe-chain/scripts", - }, - }); - // Mock child_process execSync mock.module("child_process", { namedExports: { @@ -78,7 +72,7 @@ describe("Fish shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script') + content.includes('source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script') ); }); @@ -87,7 +81,7 @@ describe("Fish shell integration", () => { fish.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); - const sourceMatches = (content.match(/source \/test-home\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length; + const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length; assert.strictEqual(sourceMatches, 2, "Should allow multiple source lines (helper doesn't dedupe)"); }); }); @@ -99,7 +93,7 @@ describe("Fish shell integration", () => { "alias npm 'aikido-npm'", "alias npx 'aikido-npx'", "alias yarn 'aikido-yarn'", - "source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script", + "source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script", "alias ls 'ls --color=auto'", "alias grep 'grep --color=auto'", ].join("\n"); @@ -113,7 +107,7 @@ describe("Fish shell integration", () => { assert.ok(!content.includes("alias npm ")); assert.ok(!content.includes("alias npx ")); assert.ok(!content.includes("alias yarn ")); - assert.ok(!content.includes("source /test-home/.safe-chain/scripts/init-fish.fish")); + assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish")); assert.ok(content.includes("alias ls ")); assert.ok(content.includes("alias grep ")); }); @@ -168,12 +162,12 @@ describe("Fish shell integration", () => { // Setup fish.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes('source /test-home/.safe-chain/scripts/init-fish.fish')); + assert.ok(content.includes('source ~/.safe-chain/scripts/init-fish.fish')); // Teardown fish.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes("source /test-home/.safe-chain/scripts/init-fish.fish")); + assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish")); }); it("should handle multiple setup calls", () => { @@ -182,7 +176,7 @@ describe("Fish shell integration", () => { fish.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); - const sourceMatches = (content.match(/source \/test-home\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length; + const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length; assert.strictEqual(sourceMatches, 1, "Should have exactly one source line after setup-teardown-setup cycle"); }); }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 2717e36..47524c2 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -2,11 +2,8 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, - validatePowerShellExecutionPolicy, } from "../helpers.js"; -import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; -import path from "path"; const shellName = "PowerShell Core"; const executableName = "pwsh"; @@ -16,11 +13,6 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } -/** - * @param {import("../helpers.js").AikidoTool[]} tools - * - * @returns {boolean} - */ function teardown(tools) { const startupFile = getStartupFile(); @@ -28,33 +20,25 @@ function teardown(tools) { // Remove any existing alias for the tool removeLinesMatchingPattern( startupFile, - new RegExp(`^Set-Alias\\s+${tool}\\s+`), + new RegExp(`^Set-Alias\\s+${tool}\\s+`) ); } - // Remove sourcing line to prevent shell from loading safe-chain after uninstallation + // Remove the line that sources the safe-chain PowerShell initialization script removeLinesMatchingPattern( startupFile, - /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, + /^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/ ); return true; } -async function setup() { - const { isValid, policy } = - await validatePowerShellExecutionPolicy(executableName); - if (!isValid) { - throw new Error( - `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running.\n -> To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned.\n For more information, see: https://help.aikido.dev/code-scanning/aikido-malware-scanning/safe-chain-troubleshooting#powershell-execution-policy-blocks-scripts-windows`, - ); - } - +function setup() { const startupFile = getStartupFile(); addLineToFile( startupFile, - `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, + `. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script` ); return true; @@ -66,37 +50,16 @@ function getStartupFile() { encoding: "utf8", shell: executableName, }).trim(); - } catch (/** @type {any} */ error) { + } catch (error) { throw new Error( - `Command failed: ${startupFileCommand}. Error: ${error.message}`, + `Command failed: ${startupFileCommand}. Error: ${error.message}` ); } } -function getManualTeardownInstructions() { - return [ - `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, - `Then restart your terminal or run: . $PROFILE`, - ]; -} - -function getManualSetupInstructions() { - return [ - `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, - `Then restart your terminal or run: . $PROFILE`, - ]; -} - -/** - * @type {import("../shellDetection.js").Shell} - */ export default { name: shellName, isInstalled, setup, teardown, - getManualSetupInstructions, - getManualTeardownInstructions, }; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index b14c73f..3a15376 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -8,20 +8,14 @@ import { knownAikidoTools } from "../helpers.js"; describe("PowerShell Core shell integration", () => { let mockStartupFile; let powershell; - let executionPolicyResult; beforeEach(async () => { // Create temporary startup file for testing mockStartupFile = path.join( tmpdir(), - `test-powershell-profile-${Date.now()}.ps1`, + `test-powershell-profile-${Date.now()}.ps1` ); - executionPolicyResult = { - isValid: true, - policy: "RemoteSigned", - }; - // Mock the helpers module mock.module("../helpers.js", { namedExports: { @@ -39,13 +33,6 @@ describe("PowerShell Core shell integration", () => { const filteredLines = lines.filter((line) => !pattern.test(line)); fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); }, - validatePowerShellExecutionPolicy: () => executionPolicyResult, - }, - }); - - mock.module("../../config/safeChainDir.js", { - namedExports: { - getScriptsDir: () => "/test-home/.safe-chain/scripts", }, }); @@ -82,15 +69,15 @@ describe("PowerShell Core shell integration", () => { }); describe("setup", () => { - it("should add init-pwsh.ps1 source line", async () => { - const result = await powershell.setup(); + it("should add init-pwsh.ps1 source line", () => { + const result = powershell.setup(); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', - ), + '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script' + ) ); }); }); @@ -99,7 +86,7 @@ describe("PowerShell Core shell integration", () => { it("should remove init-pwsh.ps1 source line", () => { const initialContent = [ "# PowerShell profile", - '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', + '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', "Set-Alias ls Get-ChildItem", "Set-Alias grep Select-String", ].join("\n"); @@ -111,7 +98,7 @@ describe("PowerShell Core shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), + !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"') ); assert.ok(content.includes("Set-Alias ls ")); assert.ok(content.includes("Set-Alias grep ")); @@ -181,50 +168,33 @@ describe("PowerShell Core shell integration", () => { }); describe("integration tests", () => { - it("should handle complete setup and teardown cycle", async () => { + it("should handle complete setup and teardown cycle", () => { // Setup - await powershell.setup(); + powershell.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), + content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"') ); // Teardown powershell.teardown(knownAikidoTools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), + !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"') ); }); - it("should handle multiple setup calls", async () => { - await powershell.setup(); + it("should handle multiple setup calls", () => { + powershell.setup(); powershell.teardown(knownAikidoTools); - await powershell.setup(); + powershell.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); const sourceMatches = ( - content.match(/\. "\/test-home\/\.safe-chain\/scripts\/init-pwsh\.ps1"/g) || + content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) || [] ).length; assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines"); }); }); - - describe("execution policy", () => { - it(`should throw for restricted policies`, async () => { - executionPolicyResult = { - isValid: false, - policy: "Restricted", - }; - - await assert.rejects( - () => powershell.setup(), - (err) => - err.message.startsWith( - "PowerShell execution policy is set to 'Restricted'", - ), - ); - }); - }); }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 7213d38..03ff7f8 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -2,11 +2,8 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, - validatePowerShellExecutionPolicy, } from "../helpers.js"; -import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; -import path from "path"; const shellName = "Windows PowerShell"; const executableName = "powershell"; @@ -16,11 +13,6 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } -/** - * @param {import("../helpers.js").AikidoTool[]} tools - * - * @returns {boolean} - */ function teardown(tools) { const startupFile = getStartupFile(); @@ -28,33 +20,25 @@ function teardown(tools) { // Remove any existing alias for the tool removeLinesMatchingPattern( startupFile, - new RegExp(`^Set-Alias\\s+${tool}\\s+`), + new RegExp(`^Set-Alias\\s+${tool}\\s+`) ); } - // Remove sourcing line to clean up safe-chain integration from the shell profile + // Remove the line that sources the safe-chain PowerShell initialization script removeLinesMatchingPattern( startupFile, - /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, + /^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/ ); return true; } -async function setup() { - const { isValid, policy } = - await validatePowerShellExecutionPolicy(executableName); - if (!isValid) { - throw new Error( - `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running.\n -> To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned.\n For more information, see: https://help.aikido.dev/code-scanning/aikido-malware-scanning/safe-chain-troubleshooting#powershell-execution-policy-blocks-scripts-windows`, - ); - } - +function setup() { const startupFile = getStartupFile(); addLineToFile( startupFile, - `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, + `. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script` ); return true; @@ -66,37 +50,16 @@ function getStartupFile() { encoding: "utf8", shell: executableName, }).trim(); - } catch (/** @type {any} */ error) { + } catch (error) { throw new Error( - `Command failed: ${startupFileCommand}. Error: ${error.message}`, + `Command failed: ${startupFileCommand}. Error: ${error.message}` ); } } -function getManualTeardownInstructions() { - return [ - `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, - `Then restart your terminal or run: . $PROFILE`, - ]; -} - -function getManualSetupInstructions() { - return [ - `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, - `Then restart your terminal or run: . $PROFILE`, - ]; -} - -/** - * @type {import("../shellDetection.js").Shell} - */ export default { name: shellName, isInstalled, setup, teardown, - getManualSetupInstructions, - getManualTeardownInstructions, }; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index 277a3f7..c201c60 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -8,20 +8,14 @@ import { knownAikidoTools } from "../helpers.js"; describe("Windows PowerShell shell integration", () => { let mockStartupFile; let windowsPowershell; - let executionPolicyResult; beforeEach(async () => { // Create temporary startup file for testing mockStartupFile = path.join( tmpdir(), - `test-windows-powershell-profile-${Date.now()}.ps1`, + `test-windows-powershell-profile-${Date.now()}.ps1` ); - executionPolicyResult = { - isValid: true, - policy: "RemoteSigned", - }; - // Mock the helpers module mock.module("../helpers.js", { namedExports: { @@ -39,13 +33,6 @@ describe("Windows PowerShell shell integration", () => { const filteredLines = lines.filter((line) => !pattern.test(line)); fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); }, - validatePowerShellExecutionPolicy: () => executionPolicyResult, - }, - }); - - mock.module("../../config/safeChainDir.js", { - namedExports: { - getScriptsDir: () => "/test-home/.safe-chain/scripts", }, }); @@ -82,15 +69,15 @@ describe("Windows PowerShell shell integration", () => { }); describe("setup", () => { - it("should add init-pwsh.ps1 source line", async () => { - const result = await windowsPowershell.setup(); + it("should add init-pwsh.ps1 source line", () => { + const result = windowsPowershell.setup(); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', - ), + '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script' + ) ); }); }); @@ -99,7 +86,7 @@ describe("Windows PowerShell shell integration", () => { it("should remove init-pwsh.ps1 source line", () => { const initialContent = [ "# Windows PowerShell profile", - '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', + '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', "Set-Alias ls Get-ChildItem", "Set-Alias grep Select-String", ].join("\n"); @@ -111,7 +98,7 @@ describe("Windows PowerShell shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), + !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"') ); assert.ok(content.includes("Set-Alias ls ")); assert.ok(content.includes("Set-Alias grep ")); @@ -181,50 +168,33 @@ describe("Windows PowerShell shell integration", () => { }); describe("integration tests", () => { - it("should handle complete setup and teardown cycle", async () => { + it("should handle complete setup and teardown cycle", () => { // Setup - await windowsPowershell.setup(); + windowsPowershell.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), + content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"') ); // Teardown windowsPowershell.teardown(knownAikidoTools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), + !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"') ); }); - it("should handle multiple setup calls", async () => { - await windowsPowershell.setup(); + it("should handle multiple setup calls", () => { + windowsPowershell.setup(); windowsPowershell.teardown(knownAikidoTools); - await windowsPowershell.setup(); + windowsPowershell.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); const sourceMatches = ( - content.match(/\. "\/test-home\/\.safe-chain\/scripts\/init-pwsh\.ps1"/g) || + content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) || [] ).length; assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines"); }); }); - - describe("execution policy", () => { - it(`should throw for restricted policies`, async () => { - executionPolicyResult = { - isValid: false, - policy: "Restricted", - }; - - await assert.rejects( - () => windowsPowershell.setup(), - (err) => - err.message.startsWith( - "PowerShell execution policy is set to 'Restricted'", - ), - ); - }); - }); }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index c3e8d73..b90f769 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -3,9 +3,7 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, } from "../helpers.js"; -import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; -import path from "path"; const shellName = "Zsh"; const executableName = "zsh"; @@ -16,11 +14,6 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } -/** - * @param {import("../helpers.js").AikidoTool[]} tools - * - * @returns {boolean} - */ function teardown(tools) { const startupFile = getStartupFile(); @@ -33,10 +26,10 @@ function teardown(tools) { ); } - // Remove sourcing line to complete shell integration cleanup + // Removes the line that sources the safe-chain zsh initialization script (~/.aikido/scripts/init-posix.sh) removeLinesMatchingPattern( startupFile, - /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, + /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/, eol ); @@ -48,7 +41,7 @@ function setup() { addLineToFile( startupFile, - `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain Zsh initialization script`, + `source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script`, eol ); @@ -61,34 +54,16 @@ function getStartupFile() { encoding: "utf8", shell: executableName, }).trim(); - } catch (/** @type {any} */ error) { + } catch (error) { throw new Error( `Command failed: ${startupFileCommand}. Error: ${error.message}` ); } } -function getManualTeardownInstructions() { - return [ - `Remove the following line from your ~/.zshrc file:`, - ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, - `Then restart your terminal or run: source ~/.zshrc`, - ]; -} - -function getManualSetupInstructions() { - return [ - `Add the following line to your ~/.zshrc file:`, - ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, - `Then restart your terminal or run: source ~/.zshrc`, - ]; -} - export default { name: shellName, isInstalled, setup, teardown, - getManualSetupInstructions, - getManualTeardownInstructions, }; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index 50af5ca..99106ec 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -33,12 +33,6 @@ describe("Zsh shell integration", () => { }, }); - mock.module("../../config/safeChainDir.js", { - namedExports: { - getScriptsDir: () => "/test-home/.safe-chain/scripts", - }, - }); - // Mock child_process execSync mock.module("child_process", { namedExports: { @@ -79,7 +73,7 @@ describe("Zsh shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script" + "source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script" ) ); }); @@ -89,7 +83,7 @@ describe("Zsh shell integration", () => { assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh")); + assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh")); }); }); @@ -120,7 +114,7 @@ describe("Zsh shell integration", () => { it("should remove zsh initialization script source line", () => { const initialContent = [ "#!/bin/zsh", - "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script", + "source ~/.safe-chain/scripts/init-posix.sh", "alias ls='ls --color=auto'", ].join("\n"); @@ -131,7 +125,7 @@ describe("Zsh shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script") + !content.includes("source ~/.safe-chain/scripts/init-posix.sh") ); assert.ok(content.includes("alias ls=")); }); @@ -186,13 +180,13 @@ describe("Zsh shell integration", () => { // Setup zsh.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh")); + assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh")); // Teardown zsh.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") + !content.includes("source ~/.safe-chain/scripts/init-posix.sh") ); }); @@ -213,7 +207,7 @@ describe("Zsh shell integration", () => { const initialContent = [ "#!/bin/zsh", "alias npm='old-npm'", - "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script", + "source ~/.safe-chain/scripts/init-posix.sh", "alias ls='ls --color=auto'", ].join("\n"); @@ -224,7 +218,7 @@ describe("Zsh shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("alias npm=")); assert.ok( - !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script") + !content.includes("source ~/.safe-chain/scripts/init-posix.sh") ); assert.ok(content.includes("alias ls=")); }); diff --git a/packages/safe-chain/src/shell-integration/teardown.js b/packages/safe-chain/src/shell-integration/teardown.js index cdeeae2..d6b1277 100644 --- a/packages/safe-chain/src/shell-integration/teardown.js +++ b/packages/safe-chain/src/shell-integration/teardown.js @@ -2,12 +2,7 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; import { detectShells } from "./shellDetection.js"; import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; -import { getShimsDir, getScriptsDir } from "../config/safeChainDir.js"; -import fs from "fs"; -/** - * @returns {Promise} - */ export async function teardown() { ui.writeInformation( chalk.bold("Removing shell aliases.") + @@ -48,14 +43,8 @@ export async function teardown() { ui.writeError( `${chalk.bold("- " + shell.name + ":")} ${chalk.red( "Teardown failed" - )}` + )}. Please check your ${shell.name} configuration.` ); - ui.emptyLine(); - ui.writeInformation(` ${chalk.bold("To tear down manually:")}`); - for (const instruction of shell.getManualTeardownInstructions()) { - ui.writeInformation(` ${instruction}`); - } - ui.emptyLine(); } } @@ -63,52 +52,10 @@ export async function teardown() { ui.emptyLine(); ui.writeInformation(`Please restart your terminal to apply the changes.`); } - } catch (/** @type {any} */ error) { + } catch (error) { ui.writeError( `Failed to remove shell aliases: ${error.message}. Please check your shell configuration.` ); return; } } - -/** - * Removes directories created by setup-ci and setup commands - * @returns {Promise} - */ -export async function teardownDirectories() { - const shimsDir = getShimsDir(); - const scriptsDir = getScriptsDir(); - - // Remove CI shims directory - if (fs.existsSync(shimsDir)) { - try { - fs.rmSync(shimsDir, { recursive: true, force: true }); - ui.writeInformation( - `${chalk.bold("- CI Shims:")} ${chalk.green("Removed successfully")}` - ); - } catch (/** @type {any} */ error) { - ui.writeError( - `${chalk.bold("- CI Shims:")} ${chalk.red( - "Failed to remove" - )}. Error: ${error.message}` - ); - } - } - - // Remove scripts directory - if (fs.existsSync(scriptsDir)) { - try { - fs.rmSync(scriptsDir, { recursive: true, force: true }); - ui.writeInformation( - `${chalk.bold("- Scripts:")} ${chalk.green("Removed successfully")}` - ); - } catch (/** @type {any} */ error) { - ui.writeError( - `${chalk.bold("- Scripts:")} ${chalk.red( - "Failed to remove" - )}. Error: ${error.message}` - ); - } - } - -} diff --git a/packages/safe-chain/src/utils/safeSpawn.js b/packages/safe-chain/src/utils/safeSpawn.js index 69c827a..c5cd913 100644 --- a/packages/safe-chain/src/utils/safeSpawn.js +++ b/packages/safe-chain/src/utils/safeSpawn.js @@ -1,110 +1,27 @@ -import { spawn, execSync } from "child_process"; -import os from "os"; -import { ui } from "../environment/userInteraction.js"; +import { spawnSync, spawn } from "child_process"; -/** - * @param {string} arg - * - * @returns {string} - */ -function sanitizeShellArgument(arg) { - // If argument contains shell metacharacters, wrap in double quotes - // and escape characters that are special even inside double quotes - if (hasShellMetaChars(arg)) { - // Inside double quotes, we need to escape: " $ ` \ - return '"' + escapeDoubleQuoteContent(arg) + '"'; +function escapeArg(arg) { + // If argument contains spaces or quotes, wrap in double quotes and escape double quotes + if (arg.includes(" ") || arg.includes('"') || arg.includes("'")) { + return '"' + arg.replaceAll('"', '\\"') + '"'; } return arg; } -/** - * @param {string} arg - * - * @returns {boolean} - */ -function hasShellMetaChars(arg) { - // Shell metacharacters that need escaping - // These characters have special meaning in shells and need to be quoted - // Whenever one of these characters is present, we should quote the argument - // Characters: space, ", &, ', |, ;, <, >, (, ), $, `, \, !, *, ?, [, ], {, }, ~, # - const shellMetaChars = /[ "&'|;<>()$`\\!*?[\]{}~#]/; - return shellMetaChars.test(arg); -} - -/** - * @param {string} arg - * - * @returns {string} - */ -function escapeDoubleQuoteContent(arg) { - // Escape special characters for shell safety - // This escapes ", $, `, and \ by prefixing them with a backslash - return arg.replace(/(["`$\\])/g, "\\$1"); -} - -/** - * @param {string} command - * @param {string[]} args - * - * @returns {string} - */ function buildCommand(command, args) { - if (args.length === 0) { - return command; - } - - const escapedArgs = args.map(sanitizeShellArgument); - + const escapedArgs = args.map(escapeArg); return `${command} ${escapedArgs.join(" ")}`; } -/** - * @param {string} command - * - * @returns {string} - */ -function resolveCommandPath(command) { - // command will be "npm", "yarn", etc. - // Use 'command -v' to find the full path - const fullPath = execSync(`command -v ${command}`, { - encoding: "utf8", - }).trim(); - - if (!fullPath) { - throw new Error(`Command not found: ${command}`); - } - - return fullPath; +export function safeSpawnSync(command, args, options = {}) { + const fullCommand = buildCommand(command, args); + return spawnSync(fullCommand, { ...options, shell: true }); } -/** - * @param {string} command - * @param {string[]} args - * @param {import("child_process").SpawnOptions} options - * - * @returns {Promise<{status: number, stdout: string, stderr: string}>} - */ export async function safeSpawn(command, args, options = {}) { - // The command is always one of our supported package managers. - // It should always be alphanumeric or _ or - - // Reject any command names with suspicious characters - if (!/^[a-zA-Z0-9_-]+$/.test(command)) { - throw new Error(`Invalid command name: ${command}`); - } - + const fullCommand = buildCommand(command, args); return new Promise((resolve, reject) => { - // Windows requires shell: true because .bat and .cmd files are not executable - // without a terminal. On Unix/macOS, we resolve the full path first, then use - // array args (safer, no escaping needed). - // See: https://nodejs.org/api/child_process.html#child_processspawncommand-args-options - let child; - if (os.platform() === "win32") { - const fullCommand = buildCommand(command, args); - child = spawn(fullCommand, { ...options, shell: true }); - } else { - const fullPath = resolveCommandPath(command); - child = spawn(fullPath, args, options); - } + const child = spawn(fullCommand, { ...options, shell: true }); // When stdio is piped, we need to collect the output let stdout = ""; @@ -119,11 +36,6 @@ export async function safeSpawn(command, args, options = {}) { }); child.on("close", (code) => { - // Code is null if it terminated by a signal. This should never - // happen in our code. If this happens, return 1 error code. - - code = code ?? 1; - resolve({ status: code, stdout: stdout, @@ -136,18 +48,3 @@ export async function safeSpawn(command, args, options = {}) { }); }); } - -/** - * @param {string} command - * @param {string[]} args - * @param {import("child_process").SpawnOptions} options - * - * @returns {Promise<{status: number, stdout: string, stderr: string}>} - */ -export async function printVerboseAndSafeSpawn(command, args, options = {}) { - ui.writeVerbose(`Running: ${command} ${args.join(" ")}`); - - const result = await safeSpawn(command, args, options); - - return result; -} diff --git a/packages/safe-chain/src/utils/safeSpawn.spec.js b/packages/safe-chain/src/utils/safeSpawn.spec.js index cbc5583..d325f8a 100644 --- a/packages/safe-chain/src/utils/safeSpawn.spec.js +++ b/packages/safe-chain/src/utils/safeSpawn.spec.js @@ -2,52 +2,40 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert"; describe("safeSpawn", () => { - let safeSpawn; + let safeSpawnSync, safeSpawn; let spawnCalls = []; - let os; beforeEach(async () => { spawnCalls = []; - os = "win32"; // Test Windows behavior by default // Mock child_process module to capture what command string gets built mock.module("child_process", { namedExports: { - spawn: (command, argsOrOptions, options) => { - // Handle both signatures: spawn(cmd, {opts}) and spawn(cmd, [args], {opts}) - if (Array.isArray(argsOrOptions)) { - spawnCalls.push({ command, args: argsOrOptions, options: options || {} }); - } else { - spawnCalls.push({ command, options: argsOrOptions || {} }); - } + spawnSync: (command, options) => { + spawnCalls.push({ command, options }); + return { + status: 0, + stdout: Buffer.from(""), + stderr: Buffer.from(""), + }; + }, + spawn: (command, options) => { + spawnCalls.push({ command, options }); return { on: (event, callback) => { - if (event === "close") { + if (event === 'close') { // Simulate immediate success setTimeout(() => callback(0), 0); } - }, + } }; }, - execSync: (cmd) => { - // Simulate 'command -v' returning full path - const match = cmd.match(/command -v (.+)/); - if (match) { - return `/usr/bin/${match[1]}\n`; - } - return ""; - }, - }, - }); - - mock.module("os", { - namedExports: { - platform: () => os, }, }); // Import after mocking const safeSpawnModule = await import("./safeSpawn.js"); + safeSpawnSync = safeSpawnModule.safeSpawnSync; safeSpawn = safeSpawnModule.safeSpawn; }); @@ -55,204 +43,67 @@ describe("safeSpawn", () => { mock.reset(); }); - it("should pass basic command and arguments correctly", async () => { - await safeSpawn("echo", ["hello"]); + // Helper to run either sync or async variant + async function runSafeSpawn(variant, command, args, options) { + if (variant === "sync") { + return safeSpawnSync(command, args, options); + } else { + return await safeSpawn(command, args, options); + } + } - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual(spawnCalls[0].command, "echo hello"); - assert.strictEqual(spawnCalls[0].options.shell, true); - }); + for (let variant of ["sync", "async"]) { + it(`should pass basic command and arguments correctly (${variant})`, async () => { + await runSafeSpawn(variant, "echo", ["hello"]); - it("should escape arguments containing spaces", async () => { - await safeSpawn("echo", ["hello world"]); - - assert.strictEqual(spawnCalls.length, 1); - // Argument should be escaped to prevent shell interpretation - assert.strictEqual(spawnCalls[0].command, 'echo "hello world"'); - assert.strictEqual(spawnCalls[0].options.shell, true); - }); - - it("should prevent shell injection attacks", async () => { - await safeSpawn("ls", ["; rm test123.txt"]); - - assert.strictEqual(spawnCalls.length, 1); - // Malicious command should be escaped to prevent execution - assert.strictEqual(spawnCalls[0].command, 'ls "; rm test123.txt"'); - assert.strictEqual(spawnCalls[0].options.shell, true); - }); - - it("should escape single quotes in arguments", async () => { - await safeSpawn("echo", ["don't break"]); - - assert.strictEqual(spawnCalls.length, 1); - // Single quote should be properly escaped with double quotes - assert.strictEqual(spawnCalls[0].command, 'echo "don\'t break"'); - assert.strictEqual(spawnCalls[0].options.shell, true); - }); - - it("should handle double quotes with simpler escaping", async () => { - await safeSpawn("echo", ['say "hello"']); - - assert.strictEqual(spawnCalls.length, 1); - // If we switch to double quotes, this should be: "say \"hello\"" - assert.strictEqual(spawnCalls[0].command, 'echo "say \\"hello\\""'); - assert.strictEqual(spawnCalls[0].options.shell, true); - }); - - it("should not escape arguments with only safe characters", async () => { - await safeSpawn("npm", ["install", "axios", "--save"]); - - assert.strictEqual(spawnCalls.length, 1); - // Safe arguments (alphanumeric, dash, underscore, dot, slash) shouldn't be quoted - assert.strictEqual(spawnCalls[0].command, "npm install axios --save"); - assert.strictEqual(spawnCalls[0].options.shell, true); - }); - - it(`should escape ampersand character`, async () => { - await safeSpawn("npx", ["cypress", "run", "--env", "password=foo&bar"]); - - assert.strictEqual(spawnCalls.length, 1); - // & should be escaped by wrapping the arg in quotes - assert.strictEqual( - spawnCalls[0].command, - 'npx cypress run --env "password=foo&bar"' - ); - assert.strictEqual(spawnCalls[0].options.shell, true); - }); - - it("should escape dollar signs to prevent variable expansion", async () => { - await safeSpawn("echo", ["$HOME/test"]); - - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual(spawnCalls[0].command, 'echo "\\$HOME/test"'); - }); - - it("should escape backticks to prevent command substitution", async () => { - await safeSpawn("echo", ["file`whoami`.txt"]); - - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual(spawnCalls[0].command, 'echo "file\\`whoami\\`.txt"'); - }); - - it("should escape backslashes properly", async () => { - await safeSpawn("echo", ["path\\with\\backslash"]); - - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual( - spawnCalls[0].command, - 'echo "path\\\\with\\\\backslash"' - ); - }); - - it("should handle multiple special characters in one argument", async () => { - await safeSpawn("cmd", ['test "quoted" $var `cmd` & more']); - - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual( - spawnCalls[0].command, - 'cmd "test \\"quoted\\" \\$var \\`cmd\\` & more"' - ); - }); - - it("should handle pipe character", async () => { - await safeSpawn("echo", ["foo|bar"]); - - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual(spawnCalls[0].command, 'echo "foo|bar"'); - }); - - it("should handle parentheses", async () => { - await safeSpawn("echo", ["(test)"]); - - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual(spawnCalls[0].command, 'echo "(test)"'); - }); - - it("should handle angle brackets for redirection", async () => { - await safeSpawn("echo", ["foo>output.txt"]); - - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual(spawnCalls[0].command, 'echo "foo>output.txt"'); - }); - - it("should handle wildcard characters", async () => { - await safeSpawn("echo", ["*.txt"]); - - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual(spawnCalls[0].command, 'echo "*.txt"'); - }); - - it("should handle multiple arguments with mixed escaping needs", async () => { - await safeSpawn("cmd", ["safe", "needs space", "$dangerous", "also-safe"]); - - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual( - spawnCalls[0].command, - 'cmd safe "needs space" "\\$dangerous" also-safe' - ); - }); - - it("should reject command names with special characters", async () => { - await assert.rejects(async () => await safeSpawn("npm; echo hacked", []), { - message: "Invalid command name: npm; echo hacked", + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, "echo hello"); + assert.strictEqual(spawnCalls[0].options.shell, true); }); - }); - it("should reject command names with spaces", async () => { - await assert.rejects(async () => await safeSpawn("npm install", []), { - message: "Invalid command name: npm install", + it(`should escape arguments containing spaces (${variant})`, async () => { + await runSafeSpawn(variant, "echo", ["hello world"]); + + assert.strictEqual(spawnCalls.length, 1); + // Argument should be escaped to prevent shell interpretation + assert.strictEqual(spawnCalls[0].command, 'echo "hello world"'); + assert.strictEqual(spawnCalls[0].options.shell, true); }); - }); - it("should reject command names with slashes", async () => { - await assert.rejects(async () => await safeSpawn("../../malicious", []), { - message: "Invalid command name: ../../malicious", + it(`should prevent shell injection attacks (${variant})`, async () => { + await runSafeSpawn(variant, "ls", ["; rm test123.txt"]); + + assert.strictEqual(spawnCalls.length, 1); + // Malicious command should be escaped to prevent execution + assert.strictEqual(spawnCalls[0].command, 'ls "; rm test123.txt"'); + assert.strictEqual(spawnCalls[0].options.shell, true); }); - }); - it("should accept valid command names with letters, numbers, underscores and hyphens", async () => { - await safeSpawn("valid_command-123", []); + it(`should escape single quotes in arguments (${variant})`, async () => { + await runSafeSpawn(variant, "echo", ["don't break"]); - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual(spawnCalls[0].command, "valid_command-123"); - }); + assert.strictEqual(spawnCalls.length, 1); + // Single quote should be properly escaped with double quotes + assert.strictEqual(spawnCalls[0].command, 'echo "don\'t break"'); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); - it("should handle Python version specifiers with comparison operators on Windows", async () => { - os = "win32"; - await safeSpawn("pip3", ["install", "Jinja2>=3.1,<3.2"]); + it(`should handle double quotes with simpler escaping (${variant})`, async () => { + await runSafeSpawn(variant, "echo", ['say "hello"']); - assert.strictEqual(spawnCalls.length, 1); - // On Windows, args are built into a command string with proper escaping - assert.strictEqual(spawnCalls[0].command, 'pip3 install "Jinja2>=3.1,<3.2"'); - assert.strictEqual(spawnCalls[0].options.shell, true); - }); + assert.strictEqual(spawnCalls.length, 1); + // If we switch to double quotes, this should be: "say \"hello\"" + assert.strictEqual(spawnCalls[0].command, 'echo "say \\"hello\\""'); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); - it("should handle Python version specifiers with comparison operators on Unix", async () => { - os = "darwin"; // or "linux" - await safeSpawn("pip3", ["install", "Jinja2>=3.1,<3.2"]); + it(`should not escape arguments with only safe characters (${variant})`, async () => { + await runSafeSpawn(variant, "npm", ["install", "axios", "--save"]); - assert.strictEqual(spawnCalls.length, 1); - // On Unix, resolves full path and passes args as array (no shell interpretation) - assert.strictEqual(spawnCalls[0].command, "/usr/bin/pip3"); - assert.deepStrictEqual(spawnCalls[0].args, ["install", "Jinja2>=3.1,<3.2"]); - assert.deepStrictEqual(spawnCalls[0].options, {}); - }); - - it("should handle Python not-equal version specifiers", async () => { - os = "win32"; - await safeSpawn("pip3", ["install", "idna!=3.5,>=3.0"]); - - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual(spawnCalls[0].command, 'pip3 install "idna!=3.5,>=3.0"'); - assert.strictEqual(spawnCalls[0].options.shell, true); - }); - - it("should handle Python extras with square brackets", async () => { - os = "win32"; - await safeSpawn("pip3", ["install", "requests[socks]"]); - - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual(spawnCalls[0].command, 'pip3 install "requests[socks]"'); - assert.strictEqual(spawnCalls[0].options.shell, true); - }); -}); + assert.strictEqual(spawnCalls.length, 1); + // Safe arguments (alphanumeric, dash, underscore, dot, slash) shouldn't be quoted + assert.strictEqual(spawnCalls[0].command, "npm install axios --save"); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); + } +}); \ No newline at end of file diff --git a/packages/safe-chain/tsconfig.json b/packages/safe-chain/tsconfig.json deleted file mode 100644 index c357bb1..0000000 --- a/packages/safe-chain/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "lib": ["es2023"], - "module": "node16", - "strict": true, - "skipLibCheck": true, - "moduleResolution": "node16", - "allowJs": true, - "checkJs": true, - "noEmit": true, - "resolveJsonModule": true - }, - "include": [ - "src/**/*.js", - "bin/**/*.js" - ], - "exclude": [ - "node_modules", - "src/**/*.spec.js" - ] -} diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index 543b1a3..483f03a 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -33,16 +33,12 @@ export class DockerTestContainer { ].join(" "); execSync( - `docker build --progress=plain -t ${imageName} -f ${dockerFile} ${contextPath} ${buildArgs}`, + `docker build -t ${imageName} -f ${dockerFile} ${contextPath} ${buildArgs}`, { - stdio: "pipe", - maxBuffer: 10 * 1024 * 1024, // Default is 1MB, increase to 10MB to account for large build logs + stdio: "ignore", } ); } catch (error) { - // Only print the build logs if the build fails - if (error.stdout) console.log(error.stdout.toString()); - if (error.stderr) console.error(error.stderr.toString()); throw new Error(`Failed to build Docker image: ${error.message}`); } } @@ -58,49 +54,16 @@ export class DockerTestContainer { `docker run -d --name ${this.containerName} ${imageName} sleep infinity`, { stdio: "ignore" } ); - - await this.startMalwareMirror(); - this.isRunning = true; } catch (error) { throw new Error(`Failed to start container: ${error.message}`); } } - async startMalwareMirror() { - const shell = await this.openShell("zsh"); - await shell.runCommand("node /utils/malwarelistmirror.mjs &"); - await shell.runCommand("until curl -sf http://127.0.0.1:5555/ready; do sleep 0.2; done"); - } - - dockerExec(command, daemon = false) { - if (!this.isRunning) { - throw new Error("Container is not running"); - } - - try { - const dockerExecCommand = `docker exec ${daemon ? "-d " : " "}${ - this.containerName - } bash -c "${command}"`; - const output = execSync(dockerExecCommand, { - encoding: "utf-8", - stdio: "pipe", - timeout: 10000, - }); - return output; - } catch (error) { - throw new Error(`Failed to execute command: ${error.message}`); - } - } - - async openShell(shell, { user } = {}) { - const execArgs = user - ? ["exec", "-it", "-u", user, this.containerName, shell] - : ["exec", "-it", this.containerName, shell]; - + async openShell(shell) { let ptyProcess = pty.spawn( "docker", - execArgs, + ["exec", "-it", this.containerName, shell], { name: "xterm-color", cols: 80, @@ -133,11 +96,9 @@ export class DockerTestContainer { const timeout = setTimeout(() => { // Fallback in case the command doesn't finish in a reasonable time - // oxlint-disable-next-line no-console - having this log in CI helps diagnose issues - console.log(`Command timeout reached for "${command}"`); resolve({ allData, output: parseShellOutput(allData), command }); ptyProcess.removeListener("data", handleInput); - }, 15000); + }, 10000); function handleInput(data) { allData.push(data); diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index 290922d..484f5fe 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -25,8 +25,6 @@ ARG NODE_VERSION=latest ARG NPM_VERSION=latest ARG YARN_VERSION=latest ARG PNPM_VERSION=latest -ARG RUSH_VERSION=latest -ARG PYTHON_VERSION=3 SHELL ["/bin/bash", "-c"] ENV BASH_ENV=~/.bashrc @@ -42,47 +40,15 @@ RUN apt-get install -y fish && \ touch /root/.config/fish/config.fish # Install Volta and Node.js -RUN curl -fsSL https://get.volta.sh | bash +RUN curl https://get.volta.sh | bash RUN volta install node@${NODE_VERSION} RUN volta install npm@${NPM_VERSION} RUN volta install yarn@${YARN_VERSION} RUN volta install pnpm@${PNPM_VERSION} -RUN volta install @microsoft/rush@${RUSH_VERSION} # Install Bun RUN curl -fsSL https://bun.sh/install | bash -# Install Python and pip (pip3) -RUN apt-get update && apt-get install -y python${PYTHON_VERSION} python3-pip && \ - ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python3 && \ - ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python && \ - ln -sf /usr/bin/pip3 /usr/local/bin/pip3 && \ - cat <<'EOF' > /usr/lib/python3/dist-packages/pip3.py -""" -Shim module so 'python[3] -m pip3 …' resolves to pip's CLI entry point. -""" -try: - import pip._internal - pip._internal.main() -except Exception as exc: - print("pip3 module shim failed:", exc) - raise -EOF - -# Install uv -RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ - echo 'source $HOME/.local/bin/env' >> ~/.bashrc - -# Install pipx (recommended installer for Poetry) and Poetry itself -RUN apt-get update && apt-get install -y pipx && \ - pipx ensurepath && \ - pipx install poetry && \ - ln -sf /root/.local/bin/poetry /usr/local/bin/poetry - -# Install PDM -RUN pipx install pdm && \ - ln -sf /root/.local/bin/pdm /usr/local/bin/pdm - # Copy and install Safe chain COPY --from=builder /app/*.tgz /pkgs/ RUN npm install -g /pkgs/*.tgz @@ -90,5 +56,3 @@ RUN npm install -g /pkgs/*.tgz WORKDIR /testapp RUN npm init -y -COPY test/e2e/utils/malwarelistmirror.mjs /utils/malwarelistmirror.mjs -ENV SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:5555 diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index 589d863..8dea93b 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -28,12 +28,10 @@ describe("E2E: bun coverage", () => { it(`safe-chain succesfully installs safe packages`, async () => { const shell = await container.openShell("bash"); - const result = await shell.runCommand( - "bun i axios@1.13.0 --safe-chain-logging=verbose" - ); + const result = await shell.runCommand("bun i axios"); assert.ok( - result.output.includes("no malware found."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -46,9 +44,8 @@ describe("E2E: bun coverage", () => { var result = await shell.runCommand("bun install"); - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads/, + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -66,9 +63,8 @@ describe("E2E: bun coverage", () => { const result = await shell.runCommand("bunx safe-chain-test"); - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads/, + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/certbundle.e2e.spec.js b/test/e2e/certbundle.e2e.spec.js deleted file mode 100644 index 9c5102b..0000000 --- a/test/e2e/certbundle.e2e.spec.js +++ /dev/null @@ -1,347 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - // Run a new Docker container for each test - container = new DockerTestContainer(); - await container.start(); - - const installationShell = await container.openShell("zsh"); - await installationShell.runCommand("safe-chain setup"); - }); - - afterEach(async () => { - // Stop and clean up the container after each test - if (container) { - await container.stop(); - container = null; - } - }); - - it(`npm install works without NODE_EXTRA_CA_CERTS set`, async () => { - const shell = await container.openShell("zsh"); - - // Ensure NODE_EXTRA_CA_CERTS is not set - await shell.runCommand("unset NODE_EXTRA_CA_CERTS"); - - const result = await shell.runCommand("npm install axios@1.13.0"); - - assert.ok( - result.output.includes("added") || result.output.includes("up to date"), - `npm install failed without NODE_EXTRA_CA_CERTS. Output was:\n${result.output}` - ); - }); - - it(`npm install works with valid NODE_EXTRA_CA_CERTS set`, async () => { - const shell = await container.openShell("zsh"); - - // Create a temporary valid certificate (using the system's Mozilla CA bundle) - await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/valid-certs.pem"); - - // Verify the cert file was created - const { output: checkOutput } = await shell.runCommand("test -f /tmp/valid-certs.pem && echo exists"); - assert.ok( - checkOutput.includes("exists"), - `Certificate file was not created at /tmp/valid-certs.pem` - ); - - // Set NODE_EXTRA_CA_CERTS and run npm install - const result = await shell.runCommand( - "NODE_EXTRA_CA_CERTS=/tmp/valid-certs.pem npm install axios@1.13.0" - ); - - assert.ok( - result.output.includes("added") || result.output.includes("up to date"), - `npm install failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}` - ); - }); - - it(`npm install works with non-existent NODE_EXTRA_CA_CERTS path`, async () => { - const shell = await container.openShell("zsh"); - - // Set NODE_EXTRA_CA_CERTS to a non-existent path - const result = await shell.runCommand( - 'export NODE_EXTRA_CA_CERTS="/tmp/nonexistent-certs.pem" && npm install axios@1.13.0' - ); - - // Should still succeed - safe-chain should gracefully handle missing user certs - assert.ok( - result.output.includes("added") || result.output.includes("up to date"), - `npm install failed with non-existent NODE_EXTRA_CA_CERTS. Output was:\n${result.output}` - ); - - // Should show a warning - assert.ok( - result.output.includes("Safe-chain") || result.output.includes("Could not read"), - `Expected safe-chain warning about missing certs. Output was:\n${result.output}` - ); - }); - - it(`npm install works with invalid (non-PEM) NODE_EXTRA_CA_CERTS`, async () => { - const shell = await container.openShell("zsh"); - - // Create an invalid certificate file (not valid PEM) - await shell.runCommand( - 'echo "This is not a valid PEM certificate" > /tmp/invalid-certs.pem' - ); - - // Set NODE_EXTRA_CA_CERTS to invalid cert - const result = await shell.runCommand( - 'export NODE_EXTRA_CA_CERTS="/tmp/invalid-certs.pem" && npm install axios@1.13.0' - ); - - // Should still succeed - safe-chain should skip invalid user certs - assert.ok( - result.output.includes("added") || result.output.includes("up to date"), - `npm install failed with invalid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}` - ); - - // Should show a warning about invalid cert - assert.ok( - result.output.includes("Safe-chain") || result.output.includes("Could not read"), - `Expected safe-chain warning about invalid certs. Output was:\n${result.output}` - ); - }); - - it(`npm install handles NODE_EXTRA_CA_CERTS with path traversal attempt`, async () => { - const shell = await container.openShell("zsh"); - - // Try to set NODE_EXTRA_CA_CERTS with path traversal - const result = await shell.runCommand( - 'export NODE_EXTRA_CA_CERTS="/tmp/../../../etc/passwd" && npm install axios@1.13.0' - ); - - // Should still succeed - safe-chain should reject path traversal - assert.ok( - result.output.includes("added") || result.output.includes("up to date"), - `npm install failed with path traversal NODE_EXTRA_CA_CERTS. Output was:\n${result.output}` - ); - }); - - it(`npm install handles empty NODE_EXTRA_CA_CERTS`, async () => { - const shell = await container.openShell("zsh"); - - // Create an empty certificate file - await shell.runCommand("touch /tmp/empty-certs.pem"); - - const result = await shell.runCommand( - 'export NODE_EXTRA_CA_CERTS="/tmp/empty-certs.pem" && npm install axios@1.13.0' - ); - - // Should still succeed - empty file should be ignored gracefully - assert.ok( - result.output.includes("added") || result.output.includes("up to date"), - `npm install failed with empty NODE_EXTRA_CA_CERTS. Output was:\n${result.output}` - ); - }); - - it(`npm install handles NODE_EXTRA_CA_CERTS pointing to a directory`, async () => { - const shell = await container.openShell("zsh"); - - // Create a directory instead of a file - await shell.runCommand("mkdir -p /tmp/cert-dir"); - - const result = await shell.runCommand( - 'export NODE_EXTRA_CA_CERTS="/tmp/cert-dir" && npm install axios@1.13.0' - ); - - // Should still succeed - directory should be treated as invalid cert file - assert.ok( - result.output.includes("added") || result.output.includes("up to date"), - `npm install failed when NODE_EXTRA_CA_CERTS points to directory. Output was:\n${result.output}` - ); - }); - - it(`npm install handles relative NODE_EXTRA_CA_CERTS path`, async () => { - const shell = await container.openShell("zsh"); - - // Create a cert file and try to reference it with relative path - await shell.runCommand( - "mkdir -p /tmp/cert-test && cp /etc/ssl/certs/ca-certificates.crt /tmp/cert-test/certs.pem" - ); - - const result = await shell.runCommand( - 'cd /tmp/cert-test && export NODE_EXTRA_CA_CERTS="./certs.pem" && npm install axios@1.13.0' - ); - - // Should still succeed - relative paths should be resolved properly - assert.ok( - result.output.includes("added") || result.output.includes("up to date"), - `npm install failed with relative NODE_EXTRA_CA_CERTS path. Output was:\n${result.output}` - ); - }); - - it(`npm install handles absolute NODE_EXTRA_CA_CERTS path`, async () => { - const shell = await container.openShell("zsh"); - - // Create cert file with absolute path - await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/absolute-certs.pem"); - - const result = await shell.runCommand( - "NODE_EXTRA_CA_CERTS=/tmp/absolute-certs.pem npm install axios@1.13.0" - ); - - assert.ok( - result.output.includes("added") || result.output.includes("up to date"), - `npm install failed with absolute NODE_EXTRA_CA_CERTS path. Output was:\n${result.output}` - ); - }); - - it(`npm install with multiple packages still respects merged certificates`, async () => { - const shell = await container.openShell("zsh"); - - // Create valid cert - await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/merge-certs.pem"); - - const result = await shell.runCommand( - "NODE_EXTRA_CA_CERTS=/tmp/merge-certs.pem npm install axios@1.13.0 lodash" - ); - - assert.ok( - result.output.includes("added") || result.output.includes("up to date"), - `npm install with multiple packages failed. Output was:\n${result.output}` - ); - }); - - it(`npm install correctly blocks malware even with merged certificates`, async () => { - const shell = await container.openShell("zsh"); - - // Create valid cert - await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/secure-merge-certs.pem"); - - const result = await shell.runCommand( - "NODE_EXTRA_CA_CERTS=/tmp/secure-merge-certs.pem npm install safe-chain-test" - ); - - // Should block the malware package - assert.ok( - result.output.includes("Malicious") || result.output.includes("blocked"), - `Malware package should be blocked even with merged certificates. Output was:\n${result.output}` - ); - }); - - it(`pip install works without NODE_EXTRA_CA_CERTS set`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("safe-chain setup"); - await shell.runCommand("unset NODE_EXTRA_CA_CERTS"); - - const result = await shell.runCommand( - "pip3 install --break-system-packages requests" - ); - - assert.ok( - result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"), - `pip3 install failed without NODE_EXTRA_CA_CERTS. Output was:\n${result.output}` - ); - }); - - it(`pip install works with valid NODE_EXTRA_CA_CERTS set`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("safe-chain setup"); - - // Create a temporary valid certificate - await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/pip-valid-certs.pem"); - - const result = await shell.runCommand( - "NODE_EXTRA_CA_CERTS=/tmp/pip-valid-certs.pem pip3 install --break-system-packages requests" - ); - - assert.ok( - result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"), - `pip3 install failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}` - ); - }); - - it(`pip install handles non-existent NODE_EXTRA_CA_CERTS gracefully`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("safe-chain setup"); - - const result = await shell.runCommand( - 'export NODE_EXTRA_CA_CERTS="/tmp/nonexistent-pip-certs.pem" && pip3 install --break-system-packages requests' - ); - - // Should still work - gracefully handle missing user certs - assert.ok( - result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"), - `pip3 install failed with non-existent NODE_EXTRA_CA_CERTS. Output was:\n${result.output}` - ); - }); - - it(`pip install handles invalid NODE_EXTRA_CA_CERTS gracefully`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("safe-chain setup"); - - // Create invalid cert - await shell.runCommand( - 'echo "invalid certificate content" > /tmp/pip-invalid-certs.pem' - ); - - const result = await shell.runCommand( - 'export NODE_EXTRA_CA_CERTS="/tmp/pip-invalid-certs.pem" && pip3 install --break-system-packages requests' - ); - - // Should still work - skip invalid user certs - assert.ok( - result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"), - `pip3 install failed with invalid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}` - ); - }); - - it(`yarn install works with valid NODE_EXTRA_CA_CERTS set`, async () => { - const shell = await container.openShell("zsh"); - - // Create valid cert - await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/yarn-certs.pem"); - - const result = await shell.runCommand( - "NODE_EXTRA_CA_CERTS=/tmp/yarn-certs.pem yarn add axios@1.13.0" - ); - - assert.ok( - !result.output.toLowerCase().includes("error") || result.output.includes("Done"), - `yarn add failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}` - ); - }); - - it(`pnpm install works with valid NODE_EXTRA_CA_CERTS set`, async () => { - const shell = await container.openShell("zsh"); - - // Create valid cert - await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/pnpm-certs.pem"); - - const result = await shell.runCommand( - "NODE_EXTRA_CA_CERTS=/tmp/pnpm-certs.pem pnpm add axios@1.13.0" - ); - - assert.ok( - !result.output.toLowerCase().includes("error") || result.output.includes("Progress"), - `pnpm add failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}` - ); - }); - - it(`bun install works with valid NODE_EXTRA_CA_CERTS set`, async () => { - const shell = await container.openShell("bash"); - - // Create valid cert and run bun in the same command to ensure file exists - const result = await shell.runCommand( - "cp /etc/ssl/certs/ca-certificates.crt /tmp/bun-certs.pem && NODE_EXTRA_CA_CERTS=/tmp/bun-certs.pem bun i axios@1.13.0" - ); - - assert.ok( - !result.output.toLowerCase().includes("error") || result.output.includes("installed"), - `bun i failed with valid NODE_EXTRA_CA_CERTS. Output was:\n${result.output}` - ); - }); -}); diff --git a/test/e2e/include-python-deprecation.e2e.spec.js b/test/e2e/include-python-deprecation.e2e.spec.js deleted file mode 100644 index a7019b7..0000000 --- a/test/e2e/include-python-deprecation.e2e.spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -describe("E2E: deprecated --include-python handling", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - container = new DockerTestContainer(); - await container.start(); - }); - - afterEach(async () => { - if (container) { - await container.stop(); - container = null; - } - }); - - for (let shell of ["bash", "zsh"]) { - it(`safe-chain setup warns and continues for ${shell}`, async () => { - const sh = await container.openShell(shell); - const result = await sh.runCommand("safe-chain setup --include-python"); - - assert.ok( - result.output.toLowerCase().includes("deprecated and ignored"), - `Expected warning about deprecated --include-python. Output was:\n${result.output}` - ); - }); - - it(`safe-chain setup-ci warns and continues for ${shell}`, async () => { - const sh = await container.openShell(shell); - const result = await sh.runCommand("safe-chain setup-ci --include-python"); - - assert.ok( - result.output.toLowerCase().includes("deprecated and ignored"), - `Expected warning about deprecated --include-python. Output was:\n${result.output}` - ); - }); - } -}); diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js index 1698759..dc1c23f 100644 --- a/test/e2e/npm-ci.e2e.spec.js +++ b/test/e2e/npm-ci.e2e.spec.js @@ -33,12 +33,10 @@ describe("E2E: npm coverage using PATH", () => { it(`safe-chain succesfully installs safe packages`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "npm i axios@1.13.0 --safe-chain-logging=verbose" - ); + const result = await shell.runCommand("npm i axios"); assert.ok( - result.output.includes("no malware found."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index 810359e..c744835 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -28,12 +28,10 @@ describe("E2E: npm coverage", () => { it(`safe-chain succesfully installs safe packages`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "npm i axios@1.13.0 --safe-chain-logging=verbose" - ); + const result = await shell.runCommand("npm i axios"); assert.ok( - result.output.includes("no malware found."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -64,25 +62,48 @@ describe("E2E: npm coverage", () => { it(`safe-chain blocks download of malicious packages already in package.json`, async () => { const shell = await container.openShell("zsh"); + const npmVersion = (await shell.runCommand("npm --version")).output.trim(); + const majorVersion = parseInt(npmVersion.split(".")[0]); + const minorVersion = parseInt(npmVersion.split(".")[1]); + const isBelow10_4 = + majorVersion < 10 || (majorVersion === 10 && minorVersion < 4); await shell.runCommand( 'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json' ); var result = await shell.runCommand("npm install"); - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads/, - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("- safe-chain-test"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Output did not include expected text. Output was:\n${result.output}` - ); + if (isBelow10_4) { + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes( + "Exiting without installing malicious packages." + ), + `Output did not include expected text. Output was:\n${result.output}` + ); + } else { + assert.ok( + result.output.includes("Malicious changes detected:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes( + "Exiting without installing malicious packages." + ), + `Output did not include expected text. Output was:\n${result.output}` + ); + } }); it("safe-chain blocks npx from executing malicious packages", async () => { diff --git a/test/e2e/pdm.e2e.spec.js b/test/e2e/pdm.e2e.spec.js deleted file mode 100644 index 94bb5e0..0000000 --- a/test/e2e/pdm.e2e.spec.js +++ /dev/null @@ -1,300 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -describe("E2E: pdm coverage", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - // Run a new Docker container for each test - container = new DockerTestContainer(); - await container.start(); - - const installationShell = await container.openShell("zsh"); - await installationShell.runCommand("safe-chain setup"); - - // Clear pdm cache - await installationShell.runCommand("command pdm cache clear"); - }); - - afterEach(async () => { - // Stop and clean up the container after each test - if (container) { - await container.stop(); - container = null; - } - }); - - it(`successfully installs known safe packages with pdm add`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new pdm project - await shell.runCommand("mkdir /tmp/test-pdm-project && cd /tmp/test-pdm-project"); - await shell.runCommand("cd /tmp/test-pdm-project && pdm init --non-interactive"); - - // Add a safe package - const result = await shell.runCommand( - "cd /tmp/test-pdm-project && pdm add requests" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pdm add with specific version`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-pdm-version && cd /tmp/test-pdm-version"); - await shell.runCommand("cd /tmp/test-pdm-version && pdm init --non-interactive"); - - const result = await shell.runCommand( - "cd /tmp/test-pdm-version && pdm add requests==2.32.3" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`safe-chain blocks installation of malicious Python packages via pdm`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-pdm-malware && cd /tmp/test-pdm-malware"); - await shell.runCommand("cd /tmp/test-pdm-malware && pdm init --non-interactive"); - - const result = await shell.runCommand( - "cd /tmp/test-pdm-malware && pdm add numpy==2.4.4" - ); - - assert.ok( - result.output.includes("blocked") && result.output.includes("malicious package downloads"), - `Expected malware to be blocked. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - }); - - it(`pdm install installs dependencies from pyproject.toml`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-pdm-install && cd /tmp/test-pdm-install"); - await shell.runCommand("cd /tmp/test-pdm-install && pdm init --non-interactive"); - await shell.runCommand("cd /tmp/test-pdm-install && pdm add requests"); - - // Now remove the virtualenv and run install - await shell.runCommand("cd /tmp/test-pdm-install && rm -rf .venv"); - - const result = await shell.runCommand( - "cd /tmp/test-pdm-install && pdm install" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pdm update with specific packages`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-pdm-update-specific && cd /tmp/test-pdm-update-specific"); - await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm init --non-interactive"); - await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm add requests certifi"); - - const result = await shell.runCommand( - "cd /tmp/test-pdm-update-specific && pdm update requests" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Updating"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pdm add with multiple packages`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-pdm-multi && cd /tmp/test-pdm-multi"); - await shell.runCommand("cd /tmp/test-pdm-multi && pdm init --non-interactive"); - - const result = await shell.runCommand( - "cd /tmp/test-pdm-multi && pdm add requests certifi" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pdm add with extras`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-pdm-extras && cd /tmp/test-pdm-extras"); - await shell.runCommand("cd /tmp/test-pdm-extras && pdm init --non-interactive"); - - // Use quotes to prevent shell expansion of square brackets - const result = await shell.runCommand( - 'cd /tmp/test-pdm-extras && pdm add "requests[security]"' - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pdm add with development group`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-pdm-dev && cd /tmp/test-pdm-dev"); - await shell.runCommand("cd /tmp/test-pdm-dev && pdm init --non-interactive"); - - const result = await shell.runCommand( - "cd /tmp/test-pdm-dev && pdm add -dG dev pytest" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pdm lock creates/updates lock file`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-pdm-lock && cd /tmp/test-pdm-lock"); - await shell.runCommand("cd /tmp/test-pdm-lock && pdm init --non-interactive"); - await shell.runCommand("cd /tmp/test-pdm-lock && pdm add requests"); - await shell.runCommand("cd /tmp/test-pdm-lock && rm pdm.lock"); - - const result = await shell.runCommand( - "cd /tmp/test-pdm-lock && pdm lock" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Resolving") || result.output.includes("lock file"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pdm remove does not download packages`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-pdm-remove && cd /tmp/test-pdm-remove"); - await shell.runCommand("cd /tmp/test-pdm-remove && pdm init --non-interactive"); - await shell.runCommand("cd /tmp/test-pdm-remove && pdm add requests"); - - const result = await shell.runCommand( - "cd /tmp/test-pdm-remove && pdm remove requests" - ); - - // Remove should succeed - it doesn't download packages, just modifies pyproject.toml - assert.ok( - !result.output.includes("blocked"), - `Remove command should not trigger downloads. Output was:\n${result.output}` - ); - }); - - it(`blocks malware during pdm install`, async () => { - const shell = await container.openShell("zsh"); - - // Create a project with malware in dependencies - await shell.runCommand("mkdir /tmp/test-pdm-install-malware && cd /tmp/test-pdm-install-malware"); - await shell.runCommand("cd /tmp/test-pdm-install-malware && pdm init --non-interactive"); - - // Add malware package - this will create lock file and attempt download - const result = await shell.runCommand( - "cd /tmp/test-pdm-install-malware && pdm add numpy==2.4.4 2>&1" - ); - - assert.ok( - result.output.includes("blocked") && result.output.includes("malicious package downloads"), - `Expected malware to be blocked during add (which triggers install). Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - }); - - it(`blocks malware when adding malicious dependency alongside safe one`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-pdm-batch && cd /tmp/test-pdm-batch"); - await shell.runCommand("cd /tmp/test-pdm-batch && pdm init --non-interactive"); - - // Try to add malware alongside safe package - const result = await shell.runCommand( - "cd /tmp/test-pdm-batch && pdm add numpy==2.4.4 requests 2>&1" - ); - - assert.ok( - result.output.includes("blocked") && result.output.includes("malicious package downloads"), - `Expected malware to be blocked. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - - // Verify safe package was also not installed due to malware in batch - const listResult = await shell.runCommand("cd /tmp/test-pdm-batch && pdm list"); - assert.ok( - !listResult.output.includes("requests"), - `Safe package should not be installed when batch includes malware. Output was:\n${listResult.output}` - ); - }); - - it(`pdm non-network commands work correctly`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-pdm-nonnetwork && cd /tmp/test-pdm-nonnetwork"); - await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm init --non-interactive"); - await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm add requests"); - - // Test pdm --version - const versionResult = await shell.runCommand("pdm --version"); - assert.ok( - versionResult.output.includes("PDM") || versionResult.output.includes("pdm"), - `Expected version output. Output was:\n${versionResult.output}` - ); - - // Test pdm list (list installed packages) - const listResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm list"); - assert.ok( - listResult.output.includes("requests"), - `Expected to see installed package. Output was:\n${listResult.output}` - ); - - // Test pdm info (show project info) - const infoResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm info"); - assert.ok( - infoResult.output.includes("PDM") || infoResult.output.includes("Python") || infoResult.output.includes("Project"), - `Expected project info. Output was:\n${infoResult.output}` - ); - - // Test pdm config (show configuration) - const configResult = await shell.runCommand("pdm config"); - assert.ok( - configResult.output.length > 0, - `Expected configuration output. Output was:\n${configResult.output}` - ); - - // Test pdm run (execute command in virtualenv) - non-network command - const runResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm run python --version"); - assert.ok( - runResult.output.includes("Python"), - `Expected Python version output. Output was:\n${runResult.output}` - ); - }); -}); diff --git a/test/e2e/pip-ci.e2e.spec.js b/test/e2e/pip-ci.e2e.spec.js deleted file mode 100644 index 49db6ce..0000000 --- a/test/e2e/pip-ci.e2e.spec.js +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -describe("E2E: safe-chain setup-ci command for pip/pip3", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - container = new DockerTestContainer(); - await container.start(); - - // Clear pip cache before each test to ensure fresh downloads through proxy - const shell = await container.openShell("zsh"); - await shell.runCommand("pip3 cache purge"); - }); - - afterEach(async () => { - if (container) { - await container.stop(); - container = null; - } - }); - - describe("E2E: pip CI support", () => { - it("does not intercept python3 --version", async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand("python3 --version"); - assert.ok( - result.output.match(/Python \d+\.\d+\.\d+/), - `Output was: ${result.output}` - ); - assert.ok( - !result.output.includes("Safe-chain"), - "Safe Chain should not intercept generic python3 command" - ); - }); - - it("does not intercept python3 -c 'print(\"hello\")'", async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand("python3 -c 'print(\"hello\")'"); - assert.ok( - result.output.includes("hello"), - `Output was: ${result.output}` - ); - assert.ok( - !result.output.includes("Safe-chain"), - "Safe Chain should not intercept generic python3 -c command" - ); - }); - - it("does not intercept python3 test.py", async () => { - const shell = await container.openShell("zsh"); - await shell.runCommand("echo 'print(\"Hello from test.py!\")' > test.py"); - const result = await shell.runCommand("python3 test.py"); - assert.ok( - result.output.includes("Hello from test.py!"), - `Output was: ${result.output}` - ); - assert.ok( - !result.output.includes("Safe-chain"), - "Safe Chain should not intercept generic python3 script execution" - ); - }); - - it("does not intercept python test.py", async () => { - const shell = await container.openShell("zsh"); - await shell.runCommand("echo 'print(\"Hello from test.py!\")' > test.py"); - const result = await shell.runCommand("python test.py"); - assert.ok( - result.output.includes("Hello from test.py!"), - `Output was: ${result.output}` - ); - assert.ok( - !result.output.includes("Safe-chain"), - "Safe Chain should not intercept generic python script execution" - ); - }); - }); - - for (let shell of ["bash", "zsh"]) { - it(`safe-chain setup-ci wraps pip3 command with PATH shim after installation for ${shell}`, async () => { - // Setup safe-chain CI shims - const installationShell = await container.openShell(shell); - await installationShell.runCommand( - "safe-chain setup-ci" - ); - - // Add $HOME/.safe-chain/shims to PATH for subsequent shells - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" - ); - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" - ); - - const projectShell = await container.openShell(shell); - // Use --break-system-packages to avoid Debian/Ubuntu external management restrictions - const result = await projectShell.runCommand( - "pip3 install --break-system-packages certifi --safe-chain-logging=verbose" - ); - - const hasExpectedOutput = result.output.includes("no malware found."); - assert.ok( - hasExpectedOutput, - hasExpectedOutput - ? "Expected pip3 command to be wrapped by safe-chain" - : `Output did not contain \"no malware found.\": \n${result.output}` - ); - }); - - it(`setup-ci routes python -m pip through safe-chain for ${shell}`, async () => { - const installationShell = await container.openShell(shell); - await installationShell.runCommand( - "safe-chain setup-ci" - ); - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" - ); - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" - ); - - const projectShell = await container.openShell(shell); - const result = await projectShell.runCommand( - "python -m pip install --break-system-packages certifi --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not contain scan message. Output was:\n${result.output}` - ); - }); - - it(`setup-ci routes python3 -m pip through safe-chain for ${shell}`, async () => { - const installationShell = await container.openShell(shell); - await installationShell.runCommand( - "safe-chain setup-ci" - ); - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" - ); - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" - ); - - const projectShell = await container.openShell(shell); - const result = await projectShell.runCommand( - "python3 -m pip install --break-system-packages certifi --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not contain scan message. Output was:\n${result.output}` - ); - }); - - it(`setup-ci routes pip through safe-chain for ${shell}`, async () => { - const installationShell = await container.openShell(shell); - await installationShell.runCommand( - "safe-chain setup-ci" - ); - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" - ); - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" - ); - - const projectShell = await container.openShell(shell); - const result = await projectShell.runCommand( - "pip install --break-system-packages certifi --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not contain scan message. Output was:\n${result.output}` - ); - }); - - it(`setup-ci routes pip3 through safe-chain for ${shell}`, async () => { - const installationShell = await container.openShell(shell); - await installationShell.runCommand( - "safe-chain setup-ci" - ); - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" - ); - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" - ); - - const projectShell = await container.openShell(shell); - const result = await projectShell.runCommand( - "pip3 install --break-system-packages certifi --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not contain scan message. Output was:\n${result.output}` - ); - }); - } -}); diff --git a/test/e2e/pip-minimum-age.e2e.spec.js b/test/e2e/pip-minimum-age.e2e.spec.js deleted file mode 100644 index 36705db..0000000 --- a/test/e2e/pip-minimum-age.e2e.spec.js +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import assert from "node:assert"; -import { DockerTestContainer } from "./DockerTestContainer.js"; - -describe("E2E: pip minimum package age", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - container = new DockerTestContainer(); - await container.start(); - - const installationShell = await container.openShell("zsh"); - await installationShell.runCommand("safe-chain setup"); - await installationShell.runCommand("pip3 cache purge"); - }); - - afterEach(async () => { - if (container) { - await container.stop(); - container = null; - } - }); - - it("falls back to an older PyPI version for flexible constraints", async () => { - const shell = await container.openShell("zsh"); - const latestVersion = await getLatestPackageVersion(shell, "openai"); - const tooYoungTimestamps = getTooYoungReleaseTimestamps(); - - await startFeedServer(container, [ - { - source: "pypi", - package_name: "openai", - version: latestVersion, - ...tooYoungTimestamps, - }, - ]); - - const installResult = await shell.runCommand( - 'SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:8123 pip3 install --break-system-packages "openai>=2.8.0,<3" --safe-chain-logging=verbose' - ); - - assert.ok( - installResult.output.includes(`openai@${latestVersion} is newer than 48 hours and was removed`), - `Expected Safe Chain to suppress the latest openai version. Output was:\n${installResult.output}` - ); - assert.ok( - !installResult.output.includes("blocked by safe-chain direct download minimum package age"), - `Expected fallback during resolution, not a direct-download block. Output was:\n${installResult.output}` - ); - assert.ok( - installResult.output.includes("Successfully installed"), - `Expected pip install to succeed after fallback. Output was:\n${installResult.output}` - ); - - const installedVersion = await getInstalledVersion(shell, "openai"); - assert.notEqual( - installedVersion, - latestVersion, - `Expected fallback to an older openai version, but installed ${latestVersion}.` - ); - }); - - it("fails cleanly for exact pinned too-young PyPI versions", async () => { - const shell = await container.openShell("zsh"); - const latestVersion = await getLatestPackageVersion(shell, "openai"); - const tooYoungTimestamps = getTooYoungReleaseTimestamps(); - - await startFeedServer(container, [ - { - source: "pypi", - package_name: "openai", - version: latestVersion, - ...tooYoungTimestamps, - }, - ]); - - const installResult = await shell.runCommand( - `SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:8123 pip3 install --break-system-packages openai==${latestVersion} --safe-chain-logging=verbose` - ); - - assert.ok( - installResult.output.includes(`openai@${latestVersion} is newer than 48 hours and was removed`), - `Expected Safe Chain to suppress the pinned openai version. Output was:\n${installResult.output}` - ); - assert.ok( - installResult.output.includes(`No matching distribution found for openai==${latestVersion}`) || - installResult.output.includes(`Could not find a version that satisfies the requirement openai==${latestVersion}`), - `Expected pip to fail because the exact version was suppressed. Output was:\n${installResult.output}` - ); - assert.ok( - !installResult.output.includes("blocked by safe-chain direct download minimum package age"), - `Expected resolver failure for an exact pin, not a direct-download block. Output was:\n${installResult.output}` - ); - }); -}); - -async function getLatestPackageVersion(shell, packageName) { - const result = await shell.runCommand(`/usr/bin/pip3 index versions ${packageName}`); - const version = result.output.match(new RegExp(`${packageName} \\(([^)]+)\\)`))?.[1]; - - assert.ok( - version, - `Could not determine latest ${packageName} version from pip output:\n${result.output}` - ); - - return version; -} - -async function getInstalledVersion(shell, packageName) { - const result = await shell.runCommand( - `python3 - <<'PY' -import importlib.metadata -print(importlib.metadata.version("${packageName}")) -PY` - ); - - return result.output.trim(); -} - -async function startFeedServer(container, releases) { - const shell = await container.openShell("bash"); - const releasesJson = JSON.stringify(releases, null, 2); - - await shell.runCommand(`mkdir -p /tmp/safe-chain-feed/releases -cat > /tmp/safe-chain-feed/malware_pypi.json <<'EOF' -[] -EOF -cat > /tmp/safe-chain-feed/releases/pypi.json <<'EOF' -${releasesJson} -EOF`); - - container.dockerExec( - "nohup python3 -m http.server 8123 -d /tmp/safe-chain-feed >/tmp/safe-chain-feed.log 2>&1 /dev/null; then - break - fi - sleep 0.1 - i=$((i + 1)) -done -if [ "$i" -ge 100 ]; then - echo "feed server did not become ready" >&2 - cat /tmp/safe-chain-feed.log >&2 || true -fi`); - - assert.equal( - readinessResult.output.includes("feed server did not become ready"), - false, - `Expected local feed server to become ready. Output was:\n${readinessResult.output}` - ); -} - -function getTooYoungReleaseTimestamps() { - const now = Math.floor(Date.now() / 1000); - - return { - released_on: now, - scraped_on: now, - }; -} diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js deleted file mode 100644 index 8044a0f..0000000 --- a/test/e2e/pip.e2e.spec.js +++ /dev/null @@ -1,848 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -describe("E2E: pip coverage", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - // Run a new Docker container for each test - container = new DockerTestContainer(); - await container.start(); - - const installationShell = await container.openShell("zsh"); - await installationShell.runCommand("safe-chain setup"); - - // Clear pip cache before each test to ensure fresh downloads through proxy - await installationShell.runCommand("pip3 cache purge"); - }); - - afterEach(async () => { - // Stop and clean up the container after each test - if (container) { - await container.stop(); - container = null; - } - }); - - it(`successfully installs known safe packages with pip3`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "pip3 install --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pip3 download`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "pip3 download requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pip3 .whl`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "pip3 wheel requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pip3 install --dry-run is respected by scanner`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "pip3 install --dry-run --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pip3 install with extras such as requests[socks]`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - 'pip3 install --break-system-packages "requests[socks]==2.32.3" --safe-chain-logging=verbose' - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pip3 install with range version specifier`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - 'pip3 install --break-system-packages "Jinja2>=3.1,<3.2" --safe-chain-logging=verbose' - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`python3 -m pip install routes through safe-chain`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "python3 -m pip install --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`python3 -m pip download routes through safe-chain`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "python3 -m pip download requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`safe-chain blocks installation of malicious Python packages`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "pip3 install --break-system-packages numpy==2.4.4 --safe-chain-logging=verbose" - ); - - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads:/, - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("numpy@2.4.4"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - const listResult = await shell.runCommand("pip3 list"); - assert.ok( - !listResult.output.includes("numpy"), - `Malicious package was installed despite safe-chain protection. Output of 'pip3 list' was:\n${listResult.output}` - ); - }); - - it(`python -m pip routes to aikido-pip (uses pip command)`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "python -m pip install --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - // Verify it completed successfully (would fail if routing was incorrect) - assert.ok( - result.output.includes("Successfully installed") || - result.output.includes("Requirement already satisfied"), - `Installation did not succeed. Output was:\n${result.output}` - ); - }); - - it(`python -m pip3 routes to aikido-pip3 (uses pip3 command)`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "python -m pip3 install --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - // Verify it completed successfully (would fail if routing was incorrect) - assert.ok( - result.output.includes("Successfully installed") || - result.output.includes("Requirement already satisfied"), - `Installation did not succeed. Output was:\n${result.output}` - ); - }); - - it(`python3 -m pip routes to aikido-pip3 (uses pip3 command)`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "python3 -m pip install --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - // Verify it completed successfully (would fail if routing was incorrect) - assert.ok( - result.output.includes("Successfully installed") || - result.output.includes("Requirement already satisfied"), - `Installation did not succeed. Output was:\n${result.output}` - ); - }); - - it(`python3 -m pip3 routes to aikido-pip3 (uses pip3 command)`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "python3 -m pip3 install --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - // Verify it completed successfully (would fail if routing was incorrect) - assert.ok( - result.output.includes("Successfully installed") || - result.output.includes("Requirement already satisfied"), - `Installation did not succeed. Output was:\n${result.output}` - ); - }); - - it(`pip3 can install from GitHub URL using the CA bundle`, async () => { - const shell = await container.openShell("zsh"); - // Install a simple package from GitHub - this should use TCP tunnel, not MITM - // Using a popular, small package for testing - const result = await shell.runCommand( - "pip3 install --break-system-packages git+https://github.com/psf/requests.git@v2.32.3 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - // Verify installation succeeded (would fail if certificate validation via env CA bundle broke) - assert.ok( - result.output.includes("Successfully installed") || - result.output.includes("Requirement already satisfied"), - `Installation from GitHub failed - CA bundle may not be working. Output was:\n${result.output}` - ); - - // Verify package was actually installed - const listResult = await shell.runCommand("pip3 list"); - assert.ok( - listResult.output.includes("requests"), - `Package from GitHub was not installed. Output was:\n${listResult.output}` - ); - }); - - it(`pip3 successfully validates certificates for HTTPS downloads`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "pip3 install --break-system-packages certifi --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - // Verify successful installation (would fail with SSL/certificate errors if the env CA bundle wasn't working) - assert.ok( - result.output.includes("Successfully installed"), - `Installation should succeed with proper certificate validation. Output was:\n${result.output}` - ); - - // Should NOT contain SSL or certificate errors - assert.ok( - !result.output.match( - /SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i - ), - `Should not have SSL/certificate errors. Output was:\n${result.output}` - ); - }); - - it(`pip3 handles external HTTPS correctly (e.g., downloading from CDN)`, async () => { - const shell = await container.openShell("zsh"); - // Test installing from a direct HTTPS URL (not a registry) - // This validates that non-registry HTTPS traffic works with our env-provided CA bundle - const result = await shell.runCommand( - "pip3 install --break-system-packages https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - // Since this is from pythonhosted.org, it should be MITM'd by safe-chain - // But the certificate validation should still work - assert.ok( - result.output.includes("Successfully installed") || - result.output.includes("Requirement already satisfied"), - `Installation from direct HTTPS URL failed. Output was:\n${result.output}` - ); - }); - - it(`pip3 can install from alternate PyPI mirror (tunneled, not MITM)`, async () => { - const shell = await container.openShell("zsh"); - // Use Test PyPI which is NOT in knownPipRegistries - // This tests tunneled HTTPS with our env-provided CA bundle (Safe Chain CA + Mozilla + Node roots) - // If the CA bundle doesn't include public roots, this will fail with CERTIFICATE_VERIFY_FAILED - const result = await shell.runCommand( - "pip3 install --break-system-packages --index-url https://test.pypi.org/simple certifi --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - // Should succeed if CA bundle properly handles tunneled hosts - assert.ok( - result.output.includes("Successfully installed") || - result.output.includes("Requirement already satisfied"), - `Installation from Test PyPI failed. This may indicate the CA bundle lacks public roots. Output was:\n${result.output}` - ); - - // Should NOT contain certificate verification errors - assert.ok( - !result.output.match( - /SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i - ), - `Should not have SSL/certificate errors for tunneled hosts. Output was:\n${result.output}` - ); - }); - - it(`pip3 install requests with --safe-chain-logging=verbose`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "pip3 install --break-system-packages requests --safe-chain-logging=verbose" - ); - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pip3 config set should work and persist configuration`, async () => { - const shell = await container.openShell("zsh"); - - // Set a config value - const setResult = await shell.runCommand( - "pip3 config set global.timeout 60" - ); - - assert.ok( - setResult.output.includes("Writing to"), - `pip3 config set should write config. Output was:\n${setResult.output}` - ); - - // Verify it was persisted by reading it back - const getResult = await shell.runCommand("pip3 config get global.timeout"); - - assert.ok( - getResult.output.includes("60"), - `Config value should be 60. Output was:\n${getResult.output}` - ); - }); - - it(`pip3 config list should show user configuration`, async () => { - const shell = await container.openShell("zsh"); - - // Set a value first - await shell.runCommand("pip3 config set global.timeout 90"); - - // List config - const listResult = await shell.runCommand("pip3 config list"); - - assert.ok( - listResult.output.includes("timeout") && listResult.output.includes("90"), - `Config list should show timeout=90. Output was:\n${listResult.output}` - ); - }); - - it(`pip3 config unset should remove configuration`, async () => { - const shell = await container.openShell("zsh"); - - // Set a value - await shell.runCommand("pip3 config set global.timeout 120"); - - // Verify it exists - const getResult = await shell.runCommand("pip3 config get global.timeout"); - assert.ok(getResult.output.includes("120")); - - // Unset it - const unsetResult = await shell.runCommand( - "pip3 config unset global.timeout" - ); - assert.ok( - unsetResult.output.includes("Writing to"), - `pip3 config unset should write config. Output was:\n${unsetResult.output}` - ); - }); - - it(`pip3 cache dir should return cache directory path`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand("pip3 cache dir"); - - // Should output a directory path - assert.ok( - result.output.includes("/") && result.output.includes("cache"), - `Should output a cache directory path. Output was:\n${result.output}` - ); - }); - - it(`pip3 cache info should show cache information`, async () => { - const shell = await container.openShell("zsh"); - - // Install something first to populate cache - await shell.runCommand("pip3 install --break-system-packages certifi"); - - const result = await shell.runCommand("pip3 cache info"); - - // Output should contain cache-related information - assert.ok( - result.output.match(/cache|wheel|http/i), - `Should output cache information. Output was:\n${result.output}` - ); - }); - - it(`pip3 cache list should list cached packages`, async () => { - const shell = await container.openShell("zsh"); - - // Download a package to ensure something is in cache - await shell.runCommand("pip3 download certifi"); - - const result = await shell.runCommand("pip3 cache list certifi"); - - // Should show either cached wheels or "No locally built wheels" - assert.ok( - result.output.includes("certifi") || - result.output.includes("No locally built"), - `Should output cache list information. Output was:\n${result.output}` - ); - }); - - it(`pip3 debug should output debug information`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand("pip3 debug"); - - // Should contain debug information about pip environment - assert.ok( - result.output.match(/pip version|sys\.version|sys\.executable/i), - `Should output debug information. Output was:\n${result.output}` - ); - - // Should NOT show safe-chain's temporary config file in the debug output - assert.ok( - !result.output.includes("safe-chain-pip-"), - `Debug output should not reference safe-chain temp config. Output was:\n${result.output}` - ); - }); - - it(`pip3 completion should generate shell completion script`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand("pip3 completion --zsh"); - - // Should output shell completion code - assert.ok( - result.output.includes("compdef") || - result.output.includes("_pip") || - result.output.includes("pip completion"), - `Should output completion code. Output was:\n${result.output}` - ); - }); - - it(`pip3 install still works after config operations`, async () => { - const shell = await container.openShell("zsh"); - - // Perform config operations - await shell.runCommand("pip3 config set global.timeout 60"); - await shell.runCommand("pip3 cache dir"); - - // Now install should still work with malware protection - const result = await shell.runCommand( - "pip3 install --break-system-packages certifi --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("Successfully installed") || - result.output.includes("Requirement already satisfied"), - `Install should succeed after config operations. Output was:\n${result.output}` - ); - - assert.ok( - result.output.includes("no malware found."), - `Should still scan for malware. Output was:\n${result.output}` - ); - }); - - it(`pip3 download works after configuring pip settings`, async () => { - const shell = await container.openShell("zsh"); - - // Configure pip with timeout and extra index URL - const configTimeout = await shell.runCommand( - "pip3 config set global.timeout 60" - ); - assert.ok( - configTimeout.output.includes("Writing to"), - `Config set should succeed. Output was:\n${configTimeout.output}` - ); - - const configIndex = await shell.runCommand( - "pip3 config set global.extra-index-url https://pypi.org/simple" - ); - assert.ok( - configIndex.output.includes("Writing to"), - `Config set should succeed. Output was:\n${configIndex.output}` - ); - - // Verify config persisted - const listConfig = await shell.runCommand("pip3 config list"); - assert.ok( - listConfig.output.includes("timeout") && listConfig.output.includes("60"), - `Config should show timeout=60. Output was:\n${listConfig.output}` - ); - assert.ok( - listConfig.output.includes("extra-index-url") && - listConfig.output.includes("pypi.org"), - `Config should show extra-index-url. Output was:\n${listConfig.output}` - ); - - // Now download packages with the configured settings - const downloadResult = await shell.runCommand( - "pip3 download -d /tmp/packages requests certifi --safe-chain-logging=verbose" - ); - - assert.ok( - downloadResult.output.includes("no malware found."), - `Should scan for malware. Output was:\n${downloadResult.output}` - ); - - // Verify downloads succeeded - assert.ok( - downloadResult.output.includes("Saved") || - downloadResult.output.includes("requests"), - `Download should succeed with configured settings. Output was:\n${downloadResult.output}` - ); - assert.ok( - downloadResult.output.includes("certifi"), - `Should download certifi. Output was:\n${downloadResult.output}` - ); - }); - - // Tests for python/python3 bypass (non-pip invocations should go directly without safe-chain) - - it(`python3 --version should bypass safe-chain and work normally`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand("python3 --version"); - - // Should output Python version - assert.ok( - result.output.match(/Python 3\.\d+\.\d+/), - `Should output Python version. Output was:\n${result.output}` - ); - - // Should NOT go through safe-chain proxy - assert.ok( - !result.output.includes("Safe-chain"), - `python3 --version should not go through safe-chain. Output was:\n${result.output}` - ); - }); - - it(`python --version should bypass safe-chain and work normally`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand("python --version"); - - // Should output Python version - assert.ok( - result.output.match(/Python \d+\.\d+\.\d+/), - `Should output Python version. Output was:\n${result.output}` - ); - - // Should NOT go through safe-chain - assert.ok( - !result.output.includes("Safe-chain"), - `python --version should not go through safe-chain. Output was:\n${result.output}` - ); - }); - - it(`python3 -c "print('hello')" should bypass safe-chain and execute code`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "python3 -c \"print('hello world')\"" - ); - - // Should execute Python code - assert.ok( - result.output.includes("hello world"), - `Should execute Python code. Output was:\n${result.output}` - ); - - // Should NOT go through safe-chain - assert.ok( - !result.output.includes("Safe-chain"), - `python3 -c should not go through safe-chain. Output was:\n${result.output}` - ); - }); - - it(`python -c should bypass safe-chain and execute code`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - 'python -c "import sys; print(sys.version)"' - ); - - // Should execute Python code and print version - assert.ok( - result.output.match(/\d+\.\d+\.\d+/), - `Should execute Python code. Output was:\n${result.output}` - ); - - // Should NOT go through safe-chain - assert.ok( - !result.output.includes("Safe-chain"), - `python -c should not go through safe-chain. Output was:\n${result.output}` - ); - }); - - it(`python3 script.py should bypass safe-chain and execute script`, async () => { - const shell = await container.openShell("zsh"); - - // Create a simple Python script - await shell.runCommand( - "echo \"print('script executed')\" > /tmp/test_script.py" - ); - - const result = await shell.runCommand("python3 /tmp/test_script.py"); - - // Should execute the script - assert.ok( - result.output.includes("script executed"), - `Should execute Python script. Output was:\n${result.output}` - ); - - // Should NOT go through safe-chain - assert.ok( - !result.output.includes("Safe-chain"), - `python3 script.py should not go through safe-chain. Output was:\n${result.output}` - ); - }); - - it(`python script.py should bypass safe-chain and execute script`, async () => { - const shell = await container.openShell("zsh"); - - // Create a simple Python script - await shell.runCommand( - "echo \"print('python2/3 compatible')\" > /tmp/test_script2.py" - ); - - const result = await shell.runCommand("python /tmp/test_script2.py"); - - // Should execute the script - assert.ok( - result.output.includes("python2/3 compatible"), - `Should execute Python script. Output was:\n${result.output}` - ); - - // Should NOT go through safe-chain - assert.ok( - !result.output.includes("Safe-chain"), - `python script.py should not go through safe-chain. Output was:\n${result.output}` - ); - }); - - it(`python3 -m json.tool should bypass safe-chain (module other than pip)`, async () => { - const shell = await container.openShell("zsh"); - - // json.tool is a built-in Python module for formatting JSON - const result = await shell.runCommand( - "echo '{\"test\": 123}' | python3 -m json.tool" - ); - - // Should format JSON - assert.ok( - result.output.includes('"test"') && result.output.includes("123"), - `Should format JSON. Output was:\n${result.output}` - ); - - // Should NOT go through safe-chain - assert.ok( - !result.output.includes("Safe-chain"), - `python3 -m json.tool should not go through safe-chain. Output was:\n${result.output}` - ); - }); - - it(`python3 -m http.server should bypass safe-chain (module other than pip)`, async () => { - const shell = await container.openShell("zsh"); - - // Start http.server in background and kill it immediately - // We just want to verify it starts without safe-chain interference - const result = await shell.runCommand( - "timeout 1 python3 -m http.server 8999 || true" - ); - - // Should NOT go through safe-chain - assert.ok( - !result.output.includes("Safe-chain"), - `python3 -m http.server should not go through safe-chain. Output was:\n${result.output}` - ); - - // Should either start the server or timeout (both are success for bypass test) - assert.ok( - result.output.includes("Serving HTTP") || - result.output === "" || - result.exitCode !== undefined, - `Should attempt to start server. Output was:\n${result.output}` - ); - }); - - it(`python3 interactive mode should bypass safe-chain`, async () => { - const shell = await container.openShell("zsh"); - - // Run python3 with a command piped to stdin to simulate interactive mode - const result = await shell.runCommand("echo 'print(2+2)' | python3"); - - // Should execute the command - assert.ok( - result.output.includes("4"), - `Should execute Python interactively. Output was:\n${result.output}` - ); - - // Should NOT go through safe-chain - assert.ok( - !result.output.includes("Safe-chain"), - `python3 interactive should not go through safe-chain. Output was:\n${result.output}` - ); - }); - - it(`python3 with no arguments should bypass safe-chain`, async () => { - const shell = await container.openShell("zsh"); - - // Python with no args goes to interactive REPL, pipe exit command - const result = await shell.runCommand("echo 'exit()' | python3"); - - // Should NOT go through safe-chain - assert.ok( - !result.output.includes("Safe-chain"), - `python3 with no args should not go through safe-chain. Output was:\n${result.output}` - ); - }); - - it(`python3 -m venv should bypass safe-chain (venv module)`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand("python3 -m venv /tmp/test_venv"); - - // Should create venv without safe-chain - assert.ok( - !result.output.includes("Safe-chain"), - `python3 -m venv should not go through safe-chain. Output was:\n${result.output}` - ); - - // Verify venv was created - const checkVenv = await shell.runCommand( - "test -f /tmp/test_venv/bin/python3 && echo 'exists'" - ); - assert.ok( - checkVenv.output.includes("exists"), - `venv should be created. Output was:\n${checkVenv.output}` - ); - }); - - it(`python3 -m pytest should bypass safe-chain (pytest module)`, async () => { - const shell = await container.openShell("zsh"); - - // pytest may not be installed, but the bypass should work regardless - const result = await shell.runCommand( - "python3 -m pytest --version 2>&1 || true" - ); - - // Should NOT go through safe-chain - assert.ok( - !result.output.includes("Safe-chain"), - `python3 -m pytest should not go through safe-chain. Output was:\n${result.output}` - ); - }); - - it(`python3 -m site should bypass safe-chain (site module)`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand("python3 -m site"); - - // Should output site information - assert.ok( - result.output.includes("sys.path") || result.output.includes("USER_BASE"), - `Should output site information. Output was:\n${result.output}` - ); - - // Should NOT go through safe-chain - assert.ok( - !result.output.includes("Safe-chain"), - `python3 -m site should not go through safe-chain. Output was:\n${result.output}` - ); - }); - - // Verify that -m pip* still goes through safe-chain (sanity check) - - it(`python3 -m pip DOES go through safe-chain (sanity check)`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "python3 -m pip install --break-system-packages certifi --safe-chain-logging=verbose" - ); - - // SHOULD go through safe-chain - assert.ok( - result.output.includes("Safe-chain") || - result.output.includes("no malware found"), - `python3 -m pip SHOULD go through safe-chain. Output was:\n${result.output}` - ); - }); - - it(`python3 -m pip3 DOES go through safe-chain (sanity check)`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "python3 -m pip3 install --break-system-packages certifi --safe-chain-logging=verbose" - ); - - // SHOULD go through safe-chain - assert.ok( - result.output.includes("Safe-chain") || - result.output.includes("no malware found"), - `python3 -m pip3 SHOULD go through safe-chain. Output was:\n${result.output}` - ); - }); - - it(`python -m pip DOES go through safe-chain (sanity check)`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "python -m pip install --break-system-packages certifi --safe-chain-logging=verbose" - ); - - // SHOULD go through safe-chain - assert.ok( - result.output.includes("Safe-chain") || - result.output.includes("no malware found"), - `python -m pip SHOULD go through safe-chain. Output was:\n${result.output}` - ); - }); -}); diff --git a/test/e2e/pipx.e2e.spec.js b/test/e2e/pipx.e2e.spec.js deleted file mode 100644 index 332709d..0000000 --- a/test/e2e/pipx.e2e.spec.js +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -describe("E2E: pipx coverage", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - container = new DockerTestContainer(); - await container.start(); - - const installationShell = await container.openShell("zsh"); - await installationShell.runCommand("safe-chain setup"); - }); - - afterEach(async () => { - if (container) { - await container.stop(); - container = null; - } - }); - - it(`successfully installs known safe packages with pipx install`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand( - "pipx install ruff --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("installed successfully"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`safe-chain blocks installation of malicious Python packages via pipx`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand( - "pipx install numpy==2.4.4" - ); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malware to be blocked. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - }); - - it(`pipx upgrade upgrades installed packages`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("pipx install ruff==0.1.0"); - - const result = await shell.runCommand( - "pipx upgrade ruff" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Upgraded") || result.output.includes("upgraded"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pipx run downloads and executes a safe tool`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand( - "pipx run ruff --version" - ); - - assert.ok( - result.output.includes("no malware found.") || /ruff/i.test(result.output), - `Expected safe run to succeed. Output was:\n${result.output}` - ); - }); - - it(`pipx run blocks malicious tool download`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand( - "pipx run numpy==2.4.4 --version" - ); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malicious run to be blocked. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - }); - - it(`pipx runpip installs safe dependency inside an app venv`, async () => { - const shell = await container.openShell("zsh"); - - // Prepare an app environment - await shell.runCommand("pipx install ruff"); - - const result = await shell.runCommand( - "pipx runpip ruff install requests==2.32.3" - ); - - assert.ok( - result.output.includes("no malware found.") || /Successfully installed/i.test(result.output) || /requests/i.test(result.output), - `Expected safe dependency install inside app venv. Output was:\n${result.output}` - ); - }); - - it(`pipx runpip blocks malicious dependency install`, async () => { - const shell = await container.openShell("zsh"); - - // Prepare an app environment - await shell.runCommand("pipx install ruff"); - - const result = await shell.runCommand( - "pipx runpip ruff install numpy==2.4.4" - ); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malicious dependency to be blocked. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - }); - - it(`pipx list shows installed packages`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("pipx install ruff"); - - const result = await shell.runCommand( - "pipx list" - ); - - assert.ok( - result.output.includes("ruff"), - `Expected ruff in list output. Output was:\n${result.output}` - ); - }); - - it(`pipx uninstall removes packages`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("pipx install ruff --safe-chain-logging=verbose"); - await shell.runCommand("pipx uninstall ruff --safe-chain-logging=verbose"); - - const result = await shell.runCommand( - "pipx list" - ); - - assert.ok( - !result.output.includes("ruff"), - `Expected ruff to be removed from list. Output was:\n${result.output}` - ); - }); - - it('pipx inject installs safe packages into existing venvs', async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("pipx install ruff --safe-chain-logging=verbose"); - const result = await shell.runCommand( - "pipx inject ruff requests==2.32.3 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found.") || /Successfully installed/i.test(result.output) || /requests/i.test(result.output), - `Expected safe package to be injected. Output was:\n${result.output}` - ); - }); - - it('pipx inject blocks malicious packages from being installed into existing venvs', async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("pipx install ruff --safe-chain-logging=verbose"); - const result = await shell.runCommand( - "pipx inject ruff numpy==2.4.4 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malicious package to be blocked. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - }); -}); diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js index a56bb77..339a5e0 100644 --- a/test/e2e/pnpm-ci.e2e.spec.js +++ b/test/e2e/pnpm-ci.e2e.spec.js @@ -33,12 +33,10 @@ describe("E2E: pnpm coverage", () => { it(`safe-chain succesfully installs safe packages`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "pnpm add axios@1.13.0 --safe-chain-logging=verbose" - ); + const result = await shell.runCommand("pnpm add axios"); assert.ok( - result.output.includes("no malware found."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index 6f9dacf..c0187d7 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -28,12 +28,10 @@ describe("E2E: pnpm coverage", () => { it(`safe-chain succesfully installs safe packages`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "pnpm add axios --safe-chain-logging=verbose" - ); + const result = await shell.runCommand("pnpm add axios"); assert.ok( - result.output.includes("no malware found."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -70,9 +68,8 @@ describe("E2E: pnpm coverage", () => { var result = await shell.runCommand("pnpm install"); - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads/, + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/poetry.e2e.spec.js b/test/e2e/poetry.e2e.spec.js deleted file mode 100644 index 7d77d9c..0000000 --- a/test/e2e/poetry.e2e.spec.js +++ /dev/null @@ -1,425 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -describe("E2E: poetry coverage", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - // Run a new Docker container for each test - container = new DockerTestContainer(); - await container.start(); - - const installationShell = await container.openShell("zsh"); - await installationShell.runCommand("safe-chain setup"); - - // Clear poetry cache - await installationShell.runCommand("command poetry cache clear pypi --all -n"); - }); - - afterEach(async () => { - // Stop and clean up the container after each test - if (container) { - await container.stop(); - container = null; - } - }); - - it(`successfully installs known safe packages with poetry add`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new poetry project - await shell.runCommand("mkdir /tmp/test-poetry-project && cd /tmp/test-poetry-project"); - await shell.runCommand("cd /tmp/test-poetry-project && poetry init --no-interaction"); - - // Add a safe package - const result = await shell.runCommand( - "cd /tmp/test-poetry-project && poetry add requests" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`poetry add with specific version`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-version && cd /tmp/test-poetry-version"); - await shell.runCommand("cd /tmp/test-poetry-version && poetry init --no-interaction"); - - const result = await shell.runCommand( - "cd /tmp/test-poetry-version && poetry add requests==2.32.3" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`safe-chain blocks installation of malicious Python packages via poetry`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-malware && cd /tmp/test-poetry-malware"); - await shell.runCommand("cd /tmp/test-poetry-malware && poetry init --no-interaction"); - - const result = await shell.runCommand( - "cd /tmp/test-poetry-malware && poetry add numpy==2.4.4" - ); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malware to be blocked. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - }); - - it(`poetry install installs dependencies from pyproject.toml`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-install && cd /tmp/test-poetry-install"); - await shell.runCommand("cd /tmp/test-poetry-install && poetry init --no-interaction"); - await shell.runCommand("cd /tmp/test-poetry-install && poetry add requests"); - - // Now remove the virtualenv and run install - await shell.runCommand("cd /tmp/test-poetry-install && rm -rf .venv"); - - const result = await shell.runCommand( - "cd /tmp/test-poetry-install && poetry install" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`poetry update updates dependencies`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-update && cd /tmp/test-poetry-update"); - await shell.runCommand("cd /tmp/test-poetry-update && poetry init --no-interaction"); - await shell.runCommand("cd /tmp/test-poetry-update && poetry add requests"); - - const result = await shell.runCommand( - "cd /tmp/test-poetry-update && poetry update" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Updating"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`poetry update with specific packages`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-update-specific && cd /tmp/test-poetry-update-specific"); - await shell.runCommand("cd /tmp/test-poetry-update-specific && poetry init --no-interaction"); - await shell.runCommand("cd /tmp/test-poetry-update-specific && poetry add requests certifi"); - - const result = await shell.runCommand( - "cd /tmp/test-poetry-update-specific && poetry update requests" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Updating"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`poetry sync synchronizes environment`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-sync && cd /tmp/test-poetry-sync"); - await shell.runCommand("cd /tmp/test-poetry-sync && poetry init --no-interaction"); - await shell.runCommand("cd /tmp/test-poetry-sync && poetry add requests"); - - const result = await shell.runCommand( - "cd /tmp/test-poetry-sync && poetry sync" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`poetry add with multiple packages`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-multi && cd /tmp/test-poetry-multi"); - await shell.runCommand("cd /tmp/test-poetry-multi && poetry init --no-interaction"); - - const result = await shell.runCommand( - "cd /tmp/test-poetry-multi && poetry add requests certifi" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`poetry add with extras`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-extras && cd /tmp/test-poetry-extras"); - await shell.runCommand("cd /tmp/test-poetry-extras && poetry init --no-interaction"); - - // Use quotes to prevent shell expansion of square brackets - const result = await shell.runCommand( - 'cd /tmp/test-poetry-extras && poetry add "requests[security]"' - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`poetry add with development group`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-dev && cd /tmp/test-poetry-dev"); - await shell.runCommand("cd /tmp/test-poetry-dev && poetry init --no-interaction"); - - const result = await shell.runCommand( - "cd /tmp/test-poetry-dev && poetry add --group dev pytest" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`poetry install with extras`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-install-extras && cd /tmp/test-poetry-install-extras"); - await shell.runCommand("cd /tmp/test-poetry-install-extras && poetry init --no-interaction"); - await shell.runCommand('cd /tmp/test-poetry-install-extras && poetry add requests'); - await shell.runCommand("cd /tmp/test-poetry-install-extras && rm -rf .venv"); - - const result = await shell.runCommand( - 'cd /tmp/test-poetry-install-extras && poetry install' - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`poetry install with dependency groups`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-install-groups && cd /tmp/test-poetry-install-groups"); - await shell.runCommand("cd /tmp/test-poetry-install-groups && poetry init --no-interaction"); - await shell.runCommand("cd /tmp/test-poetry-install-groups && poetry add requests"); - await shell.runCommand("cd /tmp/test-poetry-install-groups && rm -rf .venv"); - - const result = await shell.runCommand( - "cd /tmp/test-poetry-install-groups && poetry install" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`poetry lock creates/updates lock file`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-lock && cd /tmp/test-poetry-lock"); - await shell.runCommand("cd /tmp/test-poetry-lock && poetry init --no-interaction"); - await shell.runCommand("cd /tmp/test-poetry-lock && poetry add requests"); - await shell.runCommand("cd /tmp/test-poetry-lock && rm poetry.lock"); - - const result = await shell.runCommand( - "cd /tmp/test-poetry-lock && poetry lock" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Resolving") || result.output.includes("lock file"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`poetry add with version constraint using @`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-constraint && cd /tmp/test-poetry-constraint"); - await shell.runCommand("cd /tmp/test-poetry-constraint && poetry init --no-interaction"); - - const result = await shell.runCommand( - "cd /tmp/test-poetry-constraint && poetry add requests@^2.32.0" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Installing"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`poetry remove does not download packages`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-remove && cd /tmp/test-poetry-remove"); - await shell.runCommand("cd /tmp/test-poetry-remove && poetry init --no-interaction"); - await shell.runCommand("cd /tmp/test-poetry-remove && poetry add requests"); - - const result = await shell.runCommand( - "cd /tmp/test-poetry-remove && poetry remove requests" - ); - - // Remove should succeed - it doesn't download packages, just modifies pyproject.toml - assert.ok( - !result.output.includes("blocked"), - `Remove command should not trigger downloads. Output was:\n${result.output}` - ); - }); - - it(`blocks malware during poetry install`, async () => { - const shell = await container.openShell("zsh"); - - // Create a project with malware in dependencies - await shell.runCommand("mkdir /tmp/test-poetry-install-malware && cd /tmp/test-poetry-install-malware"); - await shell.runCommand("cd /tmp/test-poetry-install-malware && poetry init --no-interaction"); - - // Add malware package - this will create lock file and attempt download - const result = await shell.runCommand( - "cd /tmp/test-poetry-install-malware && poetry add numpy==2.4.4 2>&1" - ); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malware to be blocked during add (which triggers install). Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - }); - - it(`blocks malware when updating to add malicious dependency`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-update-add && cd /tmp/test-poetry-update-add"); - await shell.runCommand("cd /tmp/test-poetry-update-add && poetry init --no-interaction"); - - // Start with a safe dependency - await shell.runCommand("cd /tmp/test-poetry-update-add && poetry add requests"); - - // Now try to add malware via add command - const result = await shell.runCommand( - "cd /tmp/test-poetry-update-add && poetry add numpy==2.4.4 2>&1" - ); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malware to be blocked. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - }); - - it(`blocks malware when installing from requirements with malicious package`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-req-malware && cd /tmp/test-poetry-req-malware"); - await shell.runCommand("cd /tmp/test-poetry-req-malware && poetry init --no-interaction"); - - // Try to add malware directly - this is the primary vector - const result = await shell.runCommand( - "cd /tmp/test-poetry-req-malware && poetry add numpy==2.4.4 requests 2>&1" - ); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malware to be blocked. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - - // Verify safe package was also not installed due to malware in batch - const listResult = await shell.runCommand("cd /tmp/test-poetry-req-malware && poetry show"); - assert.ok( - !listResult.output.includes("requests"), - `Safe package should not be installed when batch includes malware. Output was:\n${listResult.output}` - ); - }); - - it(`poetry non-network commands work correctly`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-poetry-nonnetwork && cd /tmp/test-poetry-nonnetwork"); - await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry init --no-interaction"); - await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry add requests"); - - // Test poetry --version - const versionResult = await shell.runCommand("poetry --version"); - assert.ok( - versionResult.output.includes("Poetry") && versionResult.output.includes("version"), - `Expected version output. Output was:\n${versionResult.output}` - ); - - // Test poetry show (list installed packages) - const showResult = await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry show"); - assert.ok( - showResult.output.includes("requests"), - `Expected to see installed package. Output was:\n${showResult.output}` - ); - - // Test poetry env info (show virtual environment info) - const envInfoResult = await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry env info"); - assert.ok( - envInfoResult.output.includes("Virtualenv") || envInfoResult.output.includes("Path"), - `Expected environment info. Output was:\n${envInfoResult.output}` - ); - - // Test poetry check (validate pyproject.toml) - const checkResult = await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry check"); - assert.ok( - checkResult.output.includes("valid") || checkResult.output.includes("All"), - `Expected validation success. Output was:\n${checkResult.output}` - ); - - // Test poetry config --list (show configuration) - const configResult = await shell.runCommand("poetry config --list"); - assert.ok( - configResult.output.length > 0, - `Expected configuration output. Output was:\n${configResult.output}` - ); - - // Test poetry run (execute command in virtualenv) - non-network command - const runResult = await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry run python --version"); - assert.ok( - runResult.output.includes("Python"), - `Expected Python version output. Output was:\n${runResult.output}` - ); - - // Test poetry shell would start an interactive shell, so we skip that - // Test poetry env list (list virtual environments) - const envListResult = await shell.runCommand("cd /tmp/test-poetry-nonnetwork && poetry env list"); - assert.ok( - envListResult.output.includes("py3") || envListResult.output.includes("Activated"), - `Expected env list output. Output was:\n${envListResult.output}` - ); - }); -}); diff --git a/test/e2e/rush.e2e.spec.js b/test/e2e/rush.e2e.spec.js deleted file mode 100644 index fb6895f..0000000 --- a/test/e2e/rush.e2e.spec.js +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import { - buildRushConfig, - resolveRushVersions, - writeTextFile, -} from "./utils/rushtestutils.mjs"; -import assert from "node:assert"; - -// These tests cover safe-chain's Rush wrapper: pre-scanning `rush add` and -// blocking malicious packages downloaded during `rush update` via the MITM -// proxy. They use a single Rush-internal package manager (pnpm) — see -// `utils/rushtestutils.mjs` for why this suite isn't parameterised over the -// CI matrix's NPM_VERSION/PNPM_VERSION/YARN_VERSION values. - -describe("E2E: rush coverage", () => { - let container; - /** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */ - let resolvedVersions; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - container = new DockerTestContainer(); - await container.start(); - - const installationShell = await container.openShell("zsh"); - await installationShell.runCommand("safe-chain setup"); - - if (!resolvedVersions) { - resolvedVersions = await resolveRushVersions(installationShell); - } - - await setupRushWorkspace(installationShell, { resolvedVersions }); - }); - - afterEach(async () => { - if (container) { - await container.stop(); - container = null; - } - }); - - it("safe-chain successfully adds safe packages", async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "cd /testapp/apps/test-app && rush add --package axios@1.13.0 --exact --skip-update --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it("safe-chain blocks rush add of malicious packages", async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "cd /testapp/apps/test-app && rush add --package safe-chain-test --skip-update" - ); - - assert.ok( - result.output.includes("Malicious changes detected:"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("- safe-chain-test"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - const packageJson = await shell.runCommand( - "cat /testapp/apps/test-app/package.json" - ); - - assert.ok( - !packageJson.output.includes("safe-chain-test"), - `Malicious package was added despite safe-chain protection. Output was:\n${packageJson.output}` - ); - }); - - it("safe-chain proxy blocks malicious package downloads during rush update", async () => { - const shell = await container.openShell("zsh"); - await setupRushWorkspace(shell, { - resolvedVersions, - packageJson: `{ - "name": "test-app", - "version": "1.0.0", - "dependencies": { - "safe-chain-test": "0.0.1-security" - } -}`, - }); - - // `--safe-chain-skip-minimum-package-age` is needed because Rush's - // internal pnpm bootstrap (`npm install pnpm@`) goes - // through the safe-chain proxy. When the CI matrix selects pnpm - // `latest`, the just-released version can be below the minimum age - // threshold and Rush's install would otherwise be blocked before our - // malicious-download assertion is reached. - const result = await shell.runCommand( - "cd /testapp/apps/test-app && rush update --safe-chain-skip-minimum-package-age" - ); - - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads/, - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("- safe-chain-test"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); -}); - -async function setupRushWorkspace(shell, { resolvedVersions, packageJson }) { - const rushConfig = buildRushConfig({ - rushVersion: resolvedVersions.rushVersion, - pnpmVersion: resolvedVersions.pnpmVersion, - }); - - await shell.runCommand("rm -rf /testapp/common /testapp/apps/test-app"); - await shell.runCommand("mkdir -p /testapp/apps/test-app"); - await writeTextFile( - shell, - "/testapp/rush.json", - JSON.stringify(rushConfig, null, 2) - ); - await writeTextFile( - shell, - "/testapp/apps/test-app/package.json", - packageJson ?? - `{ - "name": "test-app", - "version": "1.0.0" -}` - ); -} diff --git a/test/e2e/rushx.e2e.spec.js b/test/e2e/rushx.e2e.spec.js deleted file mode 100644 index ec5ff75..0000000 --- a/test/e2e/rushx.e2e.spec.js +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import { - buildRushConfig, - resolveRushVersions, - writeTextFile, -} from "./utils/rushtestutils.mjs"; -import assert from "node:assert"; - -describe("E2E: rushx coverage", () => { - let container; - /** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */ - let resolvedVersions; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - container = new DockerTestContainer(); - await container.start(); - - const installationShell = await container.openShell("zsh"); - await installationShell.runCommand("safe-chain setup"); - - if (!resolvedVersions) { - resolvedVersions = await resolveRushVersions(installationShell); - } - - await setupRushWorkspace(installationShell, { resolvedVersions }); - }); - - afterEach(async () => { - if (container) { - await container.stop(); - container = null; - } - }); - - it("safe-chain successfully scans safe package downloads from rushx scripts", async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "cd /testapp/apps/test-app && rushx install-safe --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it("safe-chain blocks malicious package downloads from rushx scripts", async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "cd /testapp/apps/test-app && rushx install-malicious" - ); - - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads/, - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("- safe-chain-test"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); -}); - -async function setupRushWorkspace(shell, { resolvedVersions }) { - const rushConfig = buildRushConfig({ - rushVersion: resolvedVersions.rushVersion, - pnpmVersion: resolvedVersions.pnpmVersion, - }); - - await shell.runCommand( - "mkdir -p /testapp/common/config/rush /testapp/apps/test-app" - ); - await writeTextFile( - shell, - "/testapp/rush.json", - JSON.stringify(rushConfig, null, 2) - ); - await writeTextFile( - shell, - "/testapp/apps/test-app/package.json", - `{ - "name": "test-app", - "version": "1.0.0", - "scripts": { - "install-safe": "npm install axios@1.13.0", - "install-malicious": "npm install safe-chain-test@0.0.1-security" - } -}` - ); -} diff --git a/test/e2e/safe-chain-cli-python.e2e.spec.js b/test/e2e/safe-chain-cli-python.e2e.spec.js deleted file mode 100644 index 43187d8..0000000 --- a/test/e2e/safe-chain-cli-python.e2e.spec.js +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -describe("E2E: safe-chain CLI python/pip support", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - container = new DockerTestContainer(); - await container.start(); - // Note: We do NOT run 'safe-chain setup' here. - // We want to test the 'safe-chain' CLI command directly. - - // Clear pip cache - const shell = await container.openShell("zsh"); - await shell.runCommand("pip3 cache purge"); - }); - - afterEach(async () => { - if (container) { - await container.stop(); - container = null; - } - }); - - it("safe-chain pip3 install routes through proxy", async () => { - const shell = await container.openShell("zsh"); - // Invoke safe-chain directly with pip3 command - const result = await shell.runCommand( - "safe-chain pip3 install --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Successfully installed") || - result.output.includes("Requirement already satisfied"), - `Installation failed. Output was:\n${result.output}` - ); - }); - - it("safe-chain python3 -m pip install routes through proxy", async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "safe-chain python3 -m pip install --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it("safe-chain python3 script.py bypasses proxy", async () => { - const shell = await container.openShell("zsh"); - - // Create a simple script - await shell.runCommand("echo \"print('direct execution')\" > /tmp/test.py"); - - const result = await shell.runCommand("safe-chain python3 /tmp/test.py"); - - // Should execute the script - assert.ok( - result.output.includes("direct execution"), - `Script execution failed. Output was:\n${result.output}` - ); - - // Should NOT show safe-chain logs - assert.ok( - !result.output.includes("Safe-chain"), - `Should have bypassed safe-chain. Output was:\n${result.output}` - ); - }); - - it("safe-chain python3 --version bypasses proxy", async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand("safe-chain python3 --version"); - - assert.ok( - result.output.match(/Python 3\.\d+\.\d+/), - `Should show python version. Output was:\n${result.output}` - ); - assert.ok( - !result.output.includes("Safe-chain"), - `Should have bypassed safe-chain. Output was:\n${result.output}` - ); - }); - - it("safe-chain blocks malicious package via pip3", async () => { - const shell = await container.openShell("zsh"); - await shell.runCommand("pip3 cache purge"); - - const result = await shell.runCommand( - "safe-chain pip3 install --break-system-packages numpy==2.4.4" - ); - - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads/, - `Should have blocked malware. Output was:\n${result.output}` - ); - }); -}); diff --git a/test/e2e/setup-ci.e2e.spec.js b/test/e2e/setup-ci.e2e.spec.js index 7237b1a..9356f88 100644 --- a/test/e2e/setup-ci.e2e.spec.js +++ b/test/e2e/setup-ci.e2e.spec.js @@ -39,11 +39,11 @@ describe("E2E: safe-chain setup-ci command", () => { ); const projectShell = await container.openShell(shell); - const result = await projectShell.runCommand( - "npm i axios@1.13.0 --safe-chain-logging=verbose" - ); + const result = await projectShell.runCommand("npm i axios"); - const hasExpectedOutput = result.output.includes("Safe-chain: Scanned"); + const hasExpectedOutput = result.output.includes( + "Scanning for malicious packages..." + ); assert.ok( hasExpectedOutput, hasExpectedOutput diff --git a/test/e2e/setup.teardown.e2e.spec.js b/test/e2e/setup.teardown.e2e.spec.js index 0ddfaf4..c4a0c49 100644 --- a/test/e2e/setup.teardown.e2e.spec.js +++ b/test/e2e/setup.teardown.e2e.spec.js @@ -29,11 +29,11 @@ describe("E2E: safe-chain setup command", () => { const projectShell = await container.openShell(shell); await projectShell.runCommand("cd /testapp"); - const result = await projectShell.runCommand( - "npm i axios@1.13.0 --safe-chain-logging=verbose" - ); + const result = await projectShell.runCommand("npm i axios"); - const hasExpectedOutput = result.output.includes("Safe-chain: Scanned"); + const hasExpectedOutput = result.output.includes( + "Scanning for malicious packages..." + ); assert.ok( hasExpectedOutput, hasExpectedOutput @@ -50,8 +50,8 @@ describe("E2E: safe-chain setup command", () => { const projectShell = await container.openShell(shell); await projectShell.runCommand("cd /testapp"); - await projectShell.runCommand("npm i axios@1.13.0"); - const result = await projectShell.runCommand("npm i axios@1.13.0"); + await projectShell.runCommand("npm i axios"); + const result = await projectShell.runCommand("npm i axios"); assert.ok( !result.output.includes("Scanning for malicious packages..."), diff --git a/test/e2e/teardown-dirs.e2e.spec.js b/test/e2e/teardown-dirs.e2e.spec.js deleted file mode 100644 index 853c503..0000000 --- a/test/e2e/teardown-dirs.e2e.spec.js +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -describe("E2E: safe-chain teardown command", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - container = new DockerTestContainer(); - await container.start(); - }); - - afterEach(async () => { - if (container) { - await container.stop(); - container = null; - } - }); - - it("safe-chain teardown removes shims directory created by setup-ci", async () => { - const shell = await container.openShell("bash"); - - // Run setup-ci - await shell.runCommand("safe-chain setup-ci"); - - // Verify shims directory exists - const checkShimsExist = await shell.runCommand("test -d ~/.safe-chain/shims && echo 'exists' || echo 'missing'"); - assert.ok(checkShimsExist.output.includes("exists"), "Shims directory should exist after setup-ci"); - - // Run teardown - await shell.runCommand("safe-chain teardown"); - - // Verify shims directory is gone - const checkShimsGone = await shell.runCommand("test -d ~/.safe-chain/shims && echo 'exists' || echo 'missing'"); - assert.ok(checkShimsGone.output.includes("missing"), "Shims directory should be removed after teardown"); - }); - - it("safe-chain teardown removes scripts directory created by setup", async () => { - const shell = await container.openShell("bash"); - - // Run setup - await shell.runCommand("safe-chain setup"); - - // Verify scripts directory exists - const checkScriptsExist = await shell.runCommand("test -d ~/.safe-chain/scripts && echo 'exists' || echo 'missing'"); - assert.ok(checkScriptsExist.output.includes("exists"), "Scripts directory should exist after setup"); - - // Run teardown - await shell.runCommand("safe-chain teardown"); - - // Verify scripts directory is gone - const checkScriptsGone = await shell.runCommand("test -d ~/.safe-chain/scripts && echo 'exists' || echo 'missing'"); - assert.ok(checkScriptsGone.output.includes("missing"), "Scripts directory should be removed after teardown"); - }); - - it("safe-chain teardown removes shims directory created by setup-ci", async () => { - const shell = await container.openShell("bash"); - - // Run setup-ci - await shell.runCommand("safe-chain setup-ci"); - // Verify shims directory exists - const checkShimsExist = await shell.runCommand("test -d ~/.safe-chain/shims && echo 'exists' || echo 'missing'"); - assert.ok(checkShimsExist.output.includes("exists"), "Shims directory should exist after setup-ci"); - - // Verify Python shims were created - const checkPythonShims = await shell.runCommand("test -f ~/.safe-chain/shims/pip && echo 'exists' || echo 'missing'"); - assert.ok(checkPythonShims.output.includes("exists"), "Python shims should exist after setup-ci"); - // Run teardown - await shell.runCommand("safe-chain teardown"); - - // Verify shims directory is gone - const checkShimsGone = await shell.runCommand("test -d ~/.safe-chain/shims && echo 'exists' || echo 'missing'"); - assert.ok(checkShimsGone.output.includes("missing"), "Shims directory should be removed after teardown"); - }); - - it("safe-chain teardown removes scripts directory created by setup", async () => { - const shell = await container.openShell("bash"); - - // Run setup - await shell.runCommand("safe-chain setup"); - // Verify scripts directory exists - const checkScriptsExist = await shell.runCommand("test -d ~/.safe-chain/scripts && echo 'exists' || echo 'missing'"); - assert.ok(checkScriptsExist.output.includes("exists"), "Scripts directory should exist after setup"); - - // Run teardown - await shell.runCommand("safe-chain teardown"); - - // Verify scripts directory is gone - const checkScriptsGone = await shell.runCommand("test -d ~/.safe-chain/scripts && echo 'exists' || echo 'missing'"); - assert.ok(checkScriptsGone.output.includes("missing"), "Scripts directory should be removed after teardown"); - }); -}); diff --git a/test/e2e/utils/malwarelistmirror.mjs b/test/e2e/utils/malwarelistmirror.mjs deleted file mode 100644 index e8091b0..0000000 --- a/test/e2e/utils/malwarelistmirror.mjs +++ /dev/null @@ -1,79 +0,0 @@ -// Test-only mirror of the malware list. Injects known-safe packages as malicious -// to simulate blocking behavior in e2e tests without affecting real data. - -import * as http from "node:http"; - -const lists = await downloadLists(); -const server = http.createServer(handleRequest); -server.listen(5555, "127.0.0.1"); -console.log("listening on http://127.0.0.1:5555"); - -function handleRequest(req, res) { - if (req.method !== "GET" || !req.url) { - res.writeHead(404); - res.end(); - return; - } - - if (req.url.startsWith("/ready")) { - res.writeHead(200); - res.end(); - return; - } - - for (const list of lists) { - if (req.url.startsWith(list.path)) { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(list.data)); - return; - } - } - - res.writeHead(404); - res.end(); -} - -async function downloadLists() { - const lists = [ - { - "path": "/malware_predictions.json", - "patchFunc": (data) => data, - }, - { - "path": "/malware_pypi.json", - "patchFunc": patchPypi, - }, - { - "path": "/releases/npm.json", - "patchFunc": (data) => data, - }, - { - "path": "/releases/pypi.json", - "patchFunc": (data) => data, - }, - ] - - for (const list of lists) { - list.data = list.patchFunc(await downloadList(list.path)); - } - - return lists; -} - -async function downloadList(path) { - const baseUrl = "https://malware-list.aikido.dev"; - const url = `${baseUrl}${path}`; - const response = await fetch(url); - return await response.json(); -} - -function patchPypi(data) { - - data.push({ - "package_name": "numpy", - "version": "2.4.4", - "reason": "MALWARE" - }); - - return data; -} diff --git a/test/e2e/utils/rushtestutils.mjs b/test/e2e/utils/rushtestutils.mjs deleted file mode 100644 index 285c50e..0000000 --- a/test/e2e/utils/rushtestutils.mjs +++ /dev/null @@ -1,69 +0,0 @@ -// Helpers for the Rush E2E suites. -// -// What these suites actually test: that safe-chain's shim intercepts `rush` -// and `rushx` invocations correctly. The contents of `rush.json` are just -// fixture noise needed to make Rush run at all — Rush's schema requires -// exact semver for `rushVersion`/`pnpmVersion` and refuses dist-tags like -// "latest", so we read both back from the binaries baked into the image. -// -// * `rushVersion` ← `rush --version` (image installs -// `@microsoft/rush@${RUSH_VERSION:-latest}`). -// * `pnpmVersion` ← `pnpm --version` (image installs -// `pnpm@${PNPM_VERSION:-latest}`). Rush downloads its own copy of this -// into `~/.rush/...`; using the same exact version as the system pnpm -// just keeps the fixture in lockstep with whatever the CI matrix picks. - -/** Resolves the versions to put into `rush.json`. */ -export async function resolveRushVersions(shell) { - // Sequential: the helper drives a single PTY shell. - const rushVersion = await getInstalledVersion(shell, "rush"); - const pnpmVersion = await getInstalledVersion(shell, "pnpm"); - return { rushVersion, pnpmVersion }; -} - -/** Builds the standard `rush.json` body for the e2e fixtures. */ -export function buildRushConfig({ rushVersion, pnpmVersion, projects }) { - return { - $schema: - "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", - rushVersion, - pnpmVersion, - nodeSupportedVersionRange: ">=18.0.0", - projectFolderMinDepth: 1, - projectFolderMaxDepth: 2, - gitPolicy: {}, - repository: { - url: "https://example.com/testapp.git", - defaultBranch: "main", - }, - eventHooks: { - preRushInstall: [], - postRushInstall: [], - preRushBuild: [], - postRushBuild: [], - }, - projects: projects ?? [ - { packageName: "test-app", projectFolder: "apps/test-app" }, - ], - }; -} - -/** - * Writes a UTF-8 text file inside the container, base64-encoding the payload - * to avoid shell escaping issues for arbitrary content. - */ -export async function writeTextFile(shell, filePath, content) { - const encoded = Buffer.from(content).toString("base64"); - await shell.runCommand(`printf '%s' '${encoded}' | base64 -d > ${filePath}`); -} - -async function getInstalledVersion(shell, command) { - const { output } = await shell.runCommand(`${command} --version`); - const match = output.match(/\b(\d+\.\d+\.\d+)\b/); - if (!match) { - throw new Error( - `Could not determine installed ${command} version. Output was:\n${output}` - ); - } - return match[1]; -} diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js deleted file mode 100644 index 728d4c5..0000000 --- a/test/e2e/uv.e2e.spec.js +++ /dev/null @@ -1,576 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -describe("E2E: uv coverage", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - // Run a new Docker container for each test - container = new DockerTestContainer(); - await container.start(); - - const installationShell = await container.openShell("zsh"); - await installationShell.runCommand("safe-chain setup"); - - // Clear uv cache - await installationShell.runCommand("uv cache clean"); - }); - - afterEach(async () => { - // Stop and clean up the container after each test - if (container) { - await container.stop(); - container = null; - } - }); - - it(`successfully installs known safe packages with uv pip install`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with specific version`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages requests==2.32.3 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with version specifiers (>=)`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - 'uv pip install --system --break-system-packages "Jinja2>=3.1" --safe-chain-logging=verbose' - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with extras such as requests[socks]`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - 'uv pip install --system --break-system-packages "requests[socks]==2.32.3" --safe-chain-logging=verbose' - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install multiple packages`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages requests certifi urllib3 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install from requirements file`, async () => { - const shell = await container.openShell("zsh"); - - // Create a requirements.txt file - await shell.runCommand("echo 'requests==2.32.3' > requirements.txt"); - await shell.runCommand("echo 'certifi>=2024.0.0' >> requirements.txt"); - - const result = await shell.runCommand( - "uv pip install --system --break-system-packages -r requirements.txt --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip sync with requirements file`, async () => { - const shell = await container.openShell("zsh"); - - // Create a requirements.txt file - await shell.runCommand("echo 'requests==2.32.3' > requirements-sync.txt"); - - const result = await shell.runCommand( - "uv pip sync --system --break-system-packages requirements-sync.txt --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`safe-chain blocks installation of malicious Python packages via uv`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand( - "uv pip install --system --break-system-packages numpy==2.4.4" - ); - - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads:/, - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("numpy@2.4.4"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - const listResult = await shell.runCommand("uv pip list --system"); - assert.ok( - !listResult.output.includes("numpy"), - `Malicious package was installed despite safe-chain protection. Output of 'uv pip list' was:\n${listResult.output}` - ); - }); - - it(`uv pip install from GitHub URL using the CA bundle`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages git+https://github.com/psf/requests.git@v2.32.3 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - // Verify installation succeeded (would fail if certificate validation via env CA bundle broke) - assert.ok( - result.output.includes("Installed") || - result.output.includes("installed"), - `Installation from GitHub failed - CA bundle may not be working. Output was:\n${result.output}` - ); - }); - - it(`uv pip successfully validates certificates for HTTPS downloads`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand( - "uv pip install --system --break-system-packages certifi --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - // Verify successful installation (would fail with SSL/certificate errors if the env CA bundle wasn't working) - assert.ok( - result.output.includes("Installed") || - result.output.includes("installed"), - `Installation should succeed with proper certificate validation. Output was:\n${result.output}` - ); - - // Should NOT contain SSL or certificate errors - assert.ok( - !result.output.match( - /SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i - ), - `Should not have SSL/certificate errors. Output was:\n${result.output}` - ); - }); - - it(`uv pip install from direct HTTPS wheel URL`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - assert.ok( - result.output.includes("Installed") || - result.output.includes("installed"), - `Installation from direct HTTPS URL failed. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with --upgrade flag`, async () => { - const shell = await container.openShell("zsh"); - - // First install a package - await shell.runCommand( - "uv pip install --system --break-system-packages requests==2.31.0" - ); - - // Then upgrade it - const result = await shell.runCommand( - "uv pip install --system --break-system-packages --upgrade requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with --no-deps flag`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages --no-deps requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with --editable flag from local directory`, async () => { - const shell = await container.openShell("zsh"); - - // Create a simple package structure - await shell.runCommand("mkdir -p /tmp/test-pkg"); - await shell.runCommand( - "echo 'from setuptools import setup' > /tmp/test-pkg/setup.py" - ); - await shell.runCommand( - "echo \"setup(name='test-pkg', version='0.1.0')\" >> /tmp/test-pkg/setup.py" - ); - - const result = await shell.runCommand( - "uv pip install --system --break-system-packages -e /tmp/test-pkg --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip compile creates locked requirements`, async () => { - const shell = await container.openShell("zsh"); - - // Create an input requirements file - await shell.runCommand("echo 'requests' > requirements.in"); - - const result = await shell.runCommand("uv pip compile requirements.in"); - - // uv pip compile doesn't install packages, just resolves dependencies - // It should complete successfully and output resolved requirements - assert.ok( - result.output.includes("requests==") || result.output.includes("# via"), - `Output did not include compiled requirements. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with --index-url for alternate registry`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages --index-url https://test.pypi.org/simple certifi --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - // Should succeed if CA bundle properly handles tunneled hosts - assert.ok( - result.output.includes("Installed") || - result.output.includes("installed"), - `Installation from Test PyPI failed. This may indicate the CA bundle lacks public roots. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with --safe-chain-logging=verbose`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with version range constraint`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - 'uv pip install --system --break-system-packages "requests>=2.31.0,<2.33.0" --safe-chain-logging=verbose' - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip list shows installed packages`, async () => { - const shell = await container.openShell("zsh"); - - // Install a package first - await shell.runCommand( - "uv pip install --system --break-system-packages requests" - ); - - // Then list packages - this shouldn't trigger safe-chain scanning - const result = await shell.runCommand("uv pip list --system"); - - // List command should work without malware scanning - assert.ok( - result.output.includes("requests") || result.output.length > 0, - `Output did not show package list. Output was:\n${result.output}` - ); - }); - - it(`uv add installs package and updates project`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new uv project and add package in same command - const result = await shell.runCommand( - "uv init test-project && cd test-project && uv add requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv add with specific version`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new uv project - await shell.runCommand("uv init test-project-version"); - - const result = await shell.runCommand( - "cd test-project-version && uv add requests==2.32.3 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv add --dev for development dependencies`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new uv project - await shell.runCommand("uv init test-project-dev"); - - const result = await shell.runCommand( - "cd test-project-dev && uv add --dev pytest --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv add multiple packages at once`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new uv project - await shell.runCommand("uv init test-project-multi"); - - const result = await shell.runCommand( - "cd test-project-multi && uv add requests certifi urllib3 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`safe-chain blocks malicious packages via uv add`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new uv project - await shell.runCommand("uv init test-project-malware"); - - const result = await shell.runCommand( - "cd test-project-malware && uv add numpy==2.4.4" - ); - - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads:/, - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("numpy@2.4.4"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv tool install installs a global tool`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv tool install ruff --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found.") || - result.output.includes("Installed"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`safe-chain blocks malicious packages via uv tool install`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand("uv tool install numpy==2.4.4"); - - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads:/, - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("numpy@2.4.4"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv run --with installs ephemeral dependency`, async () => { - const shell = await container.openShell("zsh"); - - // Create a simple Python script - await shell.runCommand( - "echo 'import requests; print(requests.__version__)' > test_script.py" - ); - - const result = await shell.runCommand( - "uv run --with requests test_script.py --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`safe-chain blocks malicious packages via uv run --with`, async () => { - const shell = await container.openShell("zsh"); - - // Create a simple Python script - await shell.runCommand("echo 'print(\"test\")' > test_script2.py"); - - const result = await shell.runCommand( - "uv run --with numpy==2.4.4 test_script2.py" - ); - - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads:/, - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv sync syncs project dependencies`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new uv project, add a dependency, remove venv, and sync in one command chain - const result = await shell.runCommand( - "uv init test-sync-project && cd test-sync-project && uv add requests --safe-chain-logging=verbose && rm -rf .venv && uv sync --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv add from git URL`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new uv project - await shell.runCommand("uv init test-git-add"); - - const result = await shell.runCommand( - "cd test-git-add && uv add git+https://github.com/psf/requests.git@v2.32.3 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv add with --optional group`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new uv project - await shell.runCommand("uv init test-optional"); - - const result = await shell.runCommand( - "cd test-optional && uv add --optional dev pytest --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv run --with-requirements installs from requirements file`, async () => { - const shell = await container.openShell("zsh"); - - // Create requirements file and script - await shell.runCommand("echo 'requests' > run_requirements.txt"); - await shell.runCommand( - "echo 'import requests; print(requests.__version__)' > run_script.py" - ); - - const result = await shell.runCommand( - "uv run --with-requirements run_requirements.txt run_script.py --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv sync --all-extras syncs all optional dependencies`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize project with optional dependency and sync in one command chain - const result = await shell.runCommand( - "uv init test-extras && cd test-extras && uv add --optional dev pytest --safe-chain-logging=verbose && uv sync --all-extras" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); -}); diff --git a/test/e2e/uvx.e2e.spec.js b/test/e2e/uvx.e2e.spec.js deleted file mode 100644 index 61fb924..0000000 --- a/test/e2e/uvx.e2e.spec.js +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -describe("E2E: uvx coverage", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - container = new DockerTestContainer(); - await container.start(); - - const installationShell = await container.openShell("zsh"); - await installationShell.runCommand("safe-chain setup"); - - // Clear uv cache - await installationShell.runCommand("uv cache clean"); - }); - - afterEach(async () => { - if (container) { - await container.stop(); - container = null; - } - }); - - it(`successfully runs a known safe tool with uvx`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand( - "uvx ruff --version --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found.") || /ruff/i.test(result.output), - `Expected safe tool to run successfully. Output was:\n${result.output}` - ); - }); - - it(`safe-chain blocks malicious packages via uvx`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand( - "uvx numpy==2.4.4" - ); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malicious package to be blocked. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - }); - - it(`uvx with --from flag runs a safe tool`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand( - "uvx --from ruff ruff --version --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found.") || /ruff/i.test(result.output), - `Expected safe tool to run successfully with --from. Output was:\n${result.output}` - ); - }); - - it(`uvx with --from flag blocks malicious packages`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand( - "uvx --from numpy==2.4.4 some-command" - ); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malicious package to be blocked with --from. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - }); - - it(`uvx with specific version runs successfully`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand( - "uvx ruff@0.4.0 --version --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found.") || /ruff/i.test(result.output), - `Expected safe tool with version to run. Output was:\n${result.output}` - ); - }); - - it(`uvx with --with flag for additional dependencies`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand( - "uvx --with requests ruff --version --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found.") || /ruff/i.test(result.output), - `Expected safe tool with --with dependency to run. Output was:\n${result.output}` - ); - }); - - it(`uvx with --with flag blocks malicious additional dependencies`, async () => { - const shell = await container.openShell("zsh"); - - const result = await shell.runCommand( - "uvx --with numpy==2.4.4 ruff --version" - ); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malicious --with dependency to be blocked. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected exit message. Output was:\n${result.output}` - ); - }); -}); diff --git a/test/e2e/yarn-ci.e2e.spec.js b/test/e2e/yarn-ci.e2e.spec.js index 47e2120..33ef4f2 100644 --- a/test/e2e/yarn-ci.e2e.spec.js +++ b/test/e2e/yarn-ci.e2e.spec.js @@ -33,12 +33,10 @@ describe("E2E: yarn coverage", () => { it(`safe-chain succesfully installs safe packages`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "yarn add axios@1.13.0 --safe-chain-logging=verbose" - ); + const result = await shell.runCommand("yarn add axios"); assert.ok( - result.output.includes("no malware found."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index e70d6fc..3909318 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -28,12 +28,10 @@ describe("E2E: yarn coverage", () => { it(`safe-chain succesfully installs safe packages`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "yarn add axios@1.13.0 --safe-chain-logging=verbose" - ); + const result = await shell.runCommand("yarn add axios"); assert.ok( - result.output.includes("no malware found."), + result.output.includes("no malicious packages found."), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -70,9 +68,8 @@ describe("E2E: yarn coverage", () => { var result = await shell.runCommand("yarn"); - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads/, + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok(