diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..dd15949 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,493 @@ +version: 2.1 +# env: +# GITHUB_TOKEN — GitHub token with repo write access (used by gh CLI) +# NPM_PUBLISH_TOKEN — npm access token with publish rights + +orbs: + windows: circleci/windows@5.0 + +executors: + linux-node20: + docker: + - image: cimg/node:20.18 + resource_class: medium + + linux-arm64-node20: + docker: + - image: cimg/node:20.18 + resource_class: arm.medium + + linux-machine: + machine: + image: ubuntu-2404:current + resource_class: medium + + # Intel Mac — used for node20-macos-x64 target + macos-x64: + macos: + xcode: "16.0.0" + resource_class: macos.x86.medium.gen2 + + macos-arm64: + macos: + xcode: "16.0.0" + resource_class: macos.m2.medium.gen1 + +commands: + setup-node-20-macos: + steps: + - run: + name: Install Node.js 20 + command: | + echo 'export NVM_DIR="$HOME/.nvm"' >> "$BASH_ENV" + echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> "$BASH_ENV" + source "$BASH_ENV" + nvm install 20 + nvm alias default 20 + node --version + npm --version + + setup-node-20-windows: + steps: + - run: + name: Install Node.js 20 + command: | + nvm install 20.18.0 + nvm use 20.18.0 + node --version + npm --version + + install-safe-chain: + steps: + - run: + name: Setup safe-chain + command: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + + set-package-version: + steps: + - run: + name: Set version in safe-chain package + command: | + source version.env + if [ -n "${VERSION}" ]; then + npm --no-git-tag-version version "${VERSION}" --workspace=packages/safe-chain --ignore-scripts + fi + +# --------------------------------------------------------------------------- +# Jobs +# --------------------------------------------------------------------------- + +jobs: + set-version: + executor: linux-machine + steps: + - checkout + - run: + name: Install GitHub CLI + command: | + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list + sudo apt-get update && sudo apt-get install -y gh + - run: + name: Extract version and check pre-release status + command: | + VERSION="${CIRCLE_TAG}" + echo "VERSION=${VERSION}" > version.env + IS_PRERELEASE=$(gh release view "${VERSION}" \ + --json isPrerelease --jq '.isPrerelease' \ + --repo AikidoSec/safe-chain) + echo "IS_PRERELEASE=${IS_PRERELEASE}" >> version.env + cat version.env + - persist_to_workspace: + root: . + paths: + - version.env + + build-macos-x64: + executor: macos-x64 + steps: + - checkout + - attach_workspace: + at: . + - setup-node-20-macos + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create binary + command: node build.js node20-macos-x64 + - run: + name: Stage artifact + command: | + mkdir -p artifacts + cp dist/safe-chain artifacts/safe-chain-macos-x64 + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-macos-x64 + + build-macos-arm64: + executor: macos-arm64 + steps: + - checkout + - attach_workspace: + at: . + - setup-node-20-macos + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create binary + command: node build.js node20-macos-arm64 + - run: + name: Stage artifact + command: | + mkdir -p artifacts + cp dist/safe-chain artifacts/safe-chain-macos-arm64 + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-macos-arm64 + + build-linux-x64: + executor: linux-node20 + steps: + - checkout + - attach_workspace: + at: . + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create binary + command: node build.js node20-linux-x64 + - run: + name: Stage artifact + command: | + mkdir -p artifacts + cp dist/safe-chain artifacts/safe-chain-linux-x64 + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-linux-x64 + + build-linux-arm64: + executor: linux-arm64-node20 + steps: + - checkout + - attach_workspace: + at: . + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create binary + command: node build.js node20-linux-arm64 + - run: + name: Stage artifact + command: | + mkdir -p artifacts + cp dist/safe-chain artifacts/safe-chain-linux-arm64 + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-linux-arm64 + + build-linuxstatic-x64: + executor: linux-node20 + steps: + - checkout + - attach_workspace: + at: . + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create binary + command: node build.js node20-linuxstatic-x64 + - run: + name: Stage artifact + command: | + mkdir -p artifacts + cp dist/safe-chain artifacts/safe-chain-linuxstatic-x64 + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-linuxstatic-x64 + + build-linuxstatic-arm64: + executor: linux-arm64-node20 + steps: + - checkout + - attach_workspace: + at: . + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create binary + command: node build.js node20-linuxstatic-arm64 + - run: + name: Stage artifact + command: | + mkdir -p artifacts + cp dist/safe-chain artifacts/safe-chain-linuxstatic-arm64 + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-linuxstatic-arm64 + + build-win: + # CircleCI has no Windows ARM64 runner, so both Windows targets are built on x64 + executor: + name: windows/server-2022 + shell: bash.exe + steps: + - checkout + - attach_workspace: + at: . + - setup-node-20-windows + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create win-x64 binary + command: node build.js node20-win-x64 + - run: + name: Stage win-x64 artifact + command: | + mkdir -p artifacts + cp dist/safe-chain.exe artifacts/safe-chain-win-x64.exe + - run: + name: Create win-arm64 binary + command: node build.js node20-win-arm64 + - run: + name: Stage win-arm64 artifact + command: cp dist/safe-chain.exe artifacts/safe-chain-win-arm64.exe + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-win-x64.exe + - artifacts/safe-chain-win-arm64.exe + + publish-binaries: + machine: + image: ubuntu-2404:current + resource_class: medium + circleci_ip_ranges: true + steps: + - checkout + - attach_workspace: + at: . + - run: + name: Prepare release artifacts + command: | + source version.env + mkdir -p release-artifacts + cp artifacts/safe-chain-macos-x64 release-artifacts/safe-chain-macos-x64 + cp artifacts/safe-chain-macos-arm64 release-artifacts/safe-chain-macos-arm64 + cp artifacts/safe-chain-linux-x64 release-artifacts/safe-chain-linux-x64 + cp artifacts/safe-chain-linux-arm64 release-artifacts/safe-chain-linux-arm64 + cp artifacts/safe-chain-linuxstatic-x64 release-artifacts/safe-chain-linuxstatic-x64 + cp artifacts/safe-chain-linuxstatic-arm64 release-artifacts/safe-chain-linuxstatic-arm64 + cp artifacts/safe-chain-win-x64.exe release-artifacts/safe-chain-win-x64.exe + cp artifacts/safe-chain-win-arm64.exe release-artifacts/safe-chain-win-arm64.exe + sed "s/\$(fetch_latest_version)/${VERSION}/" \ + install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh + sed "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" \ + install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1 + cp install-scripts/uninstall-safe-chain.sh release-artifacts/uninstall-safe-chain.sh + cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1 + cp install-scripts/install-endpoint-mac.sh release-artifacts/install-endpoint-mac.sh + cp install-scripts/install-endpoint-windows.ps1 release-artifacts/install-endpoint-windows.ps1 + cp install-scripts/uninstall-endpoint-mac.sh release-artifacts/uninstall-endpoint-mac.sh + cp install-scripts/uninstall-endpoint-windows.ps1 release-artifacts/uninstall-endpoint-windows.ps1 + - run: + name: Upload binaries to GitHub Release + command: | + source version.env + gh release upload "${VERSION}" \ + release-artifacts/safe-chain-macos-x64 \ + release-artifacts/safe-chain-macos-arm64 \ + release-artifacts/safe-chain-linux-x64 \ + release-artifacts/safe-chain-linux-arm64 \ + release-artifacts/safe-chain-linuxstatic-x64 \ + release-artifacts/safe-chain-linuxstatic-arm64 \ + release-artifacts/safe-chain-win-x64.exe \ + release-artifacts/safe-chain-win-arm64.exe \ + release-artifacts/install-safe-chain.sh \ + release-artifacts/install-safe-chain.ps1 \ + release-artifacts/uninstall-safe-chain.sh \ + release-artifacts/uninstall-safe-chain.ps1 \ + release-artifacts/install-endpoint-mac.sh \ + release-artifacts/install-endpoint-windows.ps1 \ + release-artifacts/uninstall-endpoint-mac.sh \ + release-artifacts/uninstall-endpoint-windows.ps1 \ + --repo AikidoSec/safe-chain + + publish-npm: + executor: linux-node20 + steps: + - checkout + - attach_workspace: + at: . + - run: + name: Skip if pre-release + command: | + source version.env + if [ "${IS_PRERELEASE}" = "true" ]; then + echo "Pre-release tag detected — skipping npm publish" + circleci-agent step halt + fi + - install-safe-chain + - run: + name: Set the version in safe-chain package + command: | + source version.env + npm --no-git-tag-version version "${VERSION}" --workspace=packages/safe-chain + - run: + name: Install dependencies + command: npm ci + - run: + name: Run tests + command: npm run test + - run: + name: Copy documentation files to package + command: | + cp README.md packages/safe-chain/ + cp LICENSE packages/safe-chain/ + cp -r docs packages/safe-chain/ + - run: + name: Configure npm authentication + command: echo "//registry.npmjs.org/:_authToken=${NPM_PUBLISH_TOKEN}" >> ~/.npmrc + - run: + name: Publish to npm + command: | + source version.env + echo "Publishing version ${VERSION} to NPM" + npm publish --workspace=packages/safe-chain --access public --provenance + +# --------------------------------------------------------------------------- +# Workflow — triggered on every tag push (mirrors GitHub's on.push.tags: ["*"]) +# --------------------------------------------------------------------------- +# IMPORTANT: In CircleCI, tag filters must be repeated on every job in the +# workflow, otherwise those jobs are skipped for tag-triggered pipelines. + +workflows: + release: + jobs: + - set-version: + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-macos-x64: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-macos-arm64: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-linux-x64: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-linux-arm64: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-linuxstatic-x64: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-linuxstatic-arm64: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-win: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + # publish-binaries and publish-npm both fan in from all build jobs and + # run in parallel, matching the original GitHub Actions structure. + - publish-binaries: + requires: + - build-macos-x64 + - build-macos-arm64 + - build-linux-x64 + - build-linux-arm64 + - build-linuxstatic-x64 + - build-linuxstatic-arm64 + - build-win + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - publish-npm: + requires: + - build-macos-x64 + - build-macos-arm64 + - build-linux-x64 + - build-linux-arm64 + - build-linuxstatic-x64 + - build-linuxstatic-arm64 + - build-win + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 08f714a..6552e17 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -1,11 +1,8 @@ name: Create Release +# Workflow disabled — release pipeline moved to CircleCI (.circleci/config.yml) on: - push: - tags: - - "*" - release: - types: [published] + workflow_dispatch: permissions: id-token: write @@ -14,19 +11,30 @@ permissions: jobs: set-version: name: Set version number - if: github.event_name == 'push' - runs-on: open-source-releaser + runs-on: standard-runner-no-rights-public-ip outputs: version: ${{ steps.get_version.outputs.tag }} + is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }} steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Set version number id: get_version run: | version="${{ github.ref_name }}" echo "tag=$version" >> $GITHUB_OUTPUT + - name: Check if pre-release + id: check_prerelease + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + IS_PRERELEASE=$(gh release view ${{ steps.get_version.outputs.tag }} --json isPrerelease --jq '.isPrerelease') + echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT + echo "Release ${{ steps.get_version.outputs.tag }} is pre-release: $IS_PRERELEASE" + create-binaries: - if: github.event_name == 'push' needs: set-version uses: ./.github/workflows/create-artifact.yml with: @@ -34,9 +42,9 @@ jobs: publish-binaries: name: Publish to GitHub release - if: github.event_name == 'push' needs: [set-version, create-binaries] - runs-on: open-source-releaser + runs-on: standard-runner-no-rights-public-ip + steps: - name: Checkout code uses: actions/checkout@v3 @@ -60,43 +68,12 @@ jobs: mv binaries/safe-chain-win-x64/safe-chain.exe release-artifacts/safe-chain-win-x64.exe mv binaries/safe-chain-win-arm64/safe-chain.exe release-artifacts/safe-chain-win-arm64.exe - - name: Move install scripts and hard-code version and checksums + - name: Move install scripts and hard-code version env: VERSION: ${{ needs.set-version.outputs.version }} run: | - SHA_MACOS_X64=$(sha256sum release-artifacts/safe-chain-macos-x64 | awk '{print $1}') - SHA_MACOS_ARM64=$(sha256sum release-artifacts/safe-chain-macos-arm64 | awk '{print $1}') - SHA_LINUX_X64=$(sha256sum release-artifacts/safe-chain-linux-x64 | awk '{print $1}') - SHA_LINUX_ARM64=$(sha256sum release-artifacts/safe-chain-linux-arm64 | awk '{print $1}') - SHA_LINUXSTATIC_X64=$(sha256sum release-artifacts/safe-chain-linuxstatic-x64 | awk '{print $1}') - SHA_LINUXSTATIC_ARM64=$(sha256sum release-artifacts/safe-chain-linuxstatic-arm64 | awk '{print $1}') - SHA_WIN_X64=$(sha256sum release-artifacts/safe-chain-win-x64.exe | awk '{print $1}') - SHA_WIN_ARM64=$(sha256sum release-artifacts/safe-chain-win-arm64.exe | awk '{print $1}') - - sed \ - -e "s/\$(fetch_latest_version)/${VERSION}/" \ - -e "s|^SHA256_MACOS_X64=\"\"|SHA256_MACOS_X64=\"${SHA_MACOS_X64}\"|" \ - -e "s|^SHA256_MACOS_ARM64=\"\"|SHA256_MACOS_ARM64=\"${SHA_MACOS_ARM64}\"|" \ - -e "s|^SHA256_LINUX_X64=\"\"|SHA256_LINUX_X64=\"${SHA_LINUX_X64}\"|" \ - -e "s|^SHA256_LINUX_ARM64=\"\"|SHA256_LINUX_ARM64=\"${SHA_LINUX_ARM64}\"|" \ - -e "s|^SHA256_LINUXSTATIC_X64=\"\"|SHA256_LINUXSTATIC_X64=\"${SHA_LINUXSTATIC_X64}\"|" \ - -e "s|^SHA256_LINUXSTATIC_ARM64=\"\"|SHA256_LINUXSTATIC_ARM64=\"${SHA_LINUXSTATIC_ARM64}\"|" \ - -e "s|^SHA256_WIN_X64=\"\"|SHA256_WIN_X64=\"${SHA_WIN_X64}\"|" \ - -e "s|^SHA256_WIN_ARM64=\"\"|SHA256_WIN_ARM64=\"${SHA_WIN_ARM64}\"|" \ - install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh - - sed \ - -e "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" \ - -e "s|^\$SHA256_MACOS_X64 = \"\"|\$SHA256_MACOS_X64 = \"${SHA_MACOS_X64}\"|" \ - -e "s|^\$SHA256_MACOS_ARM64 = \"\"|\$SHA256_MACOS_ARM64 = \"${SHA_MACOS_ARM64}\"|" \ - -e "s|^\$SHA256_LINUX_X64 = \"\"|\$SHA256_LINUX_X64 = \"${SHA_LINUX_X64}\"|" \ - -e "s|^\$SHA256_LINUX_ARM64 = \"\"|\$SHA256_LINUX_ARM64 = \"${SHA_LINUX_ARM64}\"|" \ - -e "s|^\$SHA256_LINUXSTATIC_X64 = \"\"|\$SHA256_LINUXSTATIC_X64 = \"${SHA_LINUXSTATIC_X64}\"|" \ - -e "s|^\$SHA256_LINUXSTATIC_ARM64 = \"\"|\$SHA256_LINUXSTATIC_ARM64 = \"${SHA_LINUXSTATIC_ARM64}\"|" \ - -e "s|^\$SHA256_WIN_X64 = \"\"|\$SHA256_WIN_X64 = \"${SHA_WIN_X64}\"|" \ - -e "s|^\$SHA256_WIN_ARM64 = \"\"|\$SHA256_WIN_ARM64 = \"${SHA_WIN_ARM64}\"|" \ - install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1 - + sed "s/\$(fetch_latest_version)/${VERSION}/" install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh + sed "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1 cp install-scripts/uninstall-safe-chain.sh release-artifacts/uninstall-safe-chain.sh cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1 cp install-scripts/install-endpoint-mac.sh release-artifacts/install-endpoint-mac.sh @@ -104,15 +81,11 @@ jobs: cp install-scripts/uninstall-endpoint-mac.sh release-artifacts/uninstall-endpoint-mac.sh cp install-scripts/uninstall-endpoint-windows.ps1 release-artifacts/uninstall-endpoint-windows.ps1 - - name: Create draft release and upload assets + - name: Upload binaries to existing GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.set-version.outputs.version }} run: | - if ! gh release view "$VERSION" &>/dev/null; then - gh release create "$VERSION" --draft --title "$VERSION" --generate-notes - fi - gh release upload "$VERSION" --clobber \ + gh release upload ${{ needs.set-version.outputs.version }} \ release-artifacts/safe-chain-macos-x64 \ release-artifacts/safe-chain-macos-arm64 \ release-artifacts/safe-chain-linux-x64 \ @@ -132,7 +105,8 @@ jobs: publish-npm: name: Publish to npm - if: github.event_name == 'release' + needs: [set-version, create-binaries] + if: needs.set-version.outputs.is_prerelease != 'true' runs-on: ubuntu-latest steps: @@ -144,12 +118,14 @@ 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 - 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 ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain - name: Install dependencies run: npm ci @@ -162,15 +138,8 @@ 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 ${{ needs.set-version.outputs.version }} to NPM" + npm publish --workspace=packages/safe-chain --access public --provenance 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 index da2a1bd..4fee730 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -80,7 +80,6 @@ jobs: 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 diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index 744f52c..e6ef9df 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -77,7 +77,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 +87,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 diff --git a/README.md b/README.md index cb8f34b..a391cdd 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,6 @@ - ✅ **Blocks packages newer than 48 hours** without breaking your build - ✅ **Tokenless, free, no build data shared** -## Need protection beyond npm & PyPI? - -[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection?utm_source=github.com&utm_medium=referral&utm_campaign=safechain) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more. - -Get centralized policy management, request-and-approval workflows, and visibility across every developer workstation in your org. Powered by the same Aikido Intel feed. Deploy it manually or manage it through your MDM tool (Jamf, Fleet, or Iru). - ---- - Aikido Safe Chain supports the following package managers: - 📦 **npm** @@ -25,17 +17,13 @@ Aikido Safe Chain supports the following package managers: - 📦 **yarn** - 📦 **pnpm** - 📦 **pnpx** -- 📦 **rush** -- 📦 **rushx** - 📦 **bun** - 📦 **bunx** - 📦 **pip** - 📦 **pip3** - 📦 **uv** - 📦 **poetry** -- 📦 **uvx** - 📦 **pipx** -- 📦 **pdm** # Usage @@ -78,7 +66,7 @@ You can find all available versions on the [releases page](https://github.com/Ai ### Verify the installation 1. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, 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. + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. 2. **Verify the installation** by running the verification command: @@ -109,7 +97,7 @@ You can find all available versions on the [releases page](https://github.com/Ai - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. -When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx` and `pdm` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. +When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. You can check the installed version by running: @@ -121,26 +109,17 @@ safe-chain --version ### Malware Blocking -The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, uv, uvx, poetry, pipx or pdm commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. +The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, uv, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. -### Minimum package age +### Minimum package age (npm only) -Safe Chain applies minimum package age checks to supported ecosystems. +For npm packages, Safe Chain temporarily suppresses packages published within the last 48 hours (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. -Current enforcement differs by ecosystem: - -- npm-based package managers: - - during normal package resolution, Safe Chain suppresses versions that are newer than the configured minimum age from the package metadata returned by the registry - - for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages -- Python package managers: - - during package resolution, Safe Chain suppresses too-young files and releases from PyPI metadata responses - - for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages - -By default, the minimum package age is 48 hours. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. +⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3, poetry, pipx). ### Shell Integration -The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx, pdm). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: +The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (pip, uv, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: - ✅ **Bash** - ✅ **Zsh** @@ -204,17 +183,7 @@ You can set the logging level through multiple sources (in order of priority): ## Minimum Package Age -You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 48 hours old before they can be installed. - -For npm-based package managers, this check currently has two enforcement modes: - -- Safe Chain suppresses too-young versions from package metadata during normal dependency resolution. -- Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list. - -For Python package managers, this check currently has two enforcement modes: - -- Safe Chain suppresses too-young files and releases from PyPI metadata during dependency resolution. -- Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list. +You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 48 hours old before they can be installed through npm-based package managers. ### Configuration Options @@ -246,16 +215,13 @@ You can set the minimum package age through multiple sources (in order of priori Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Use `@scope/*` to trust all packages from an organization: ```shell -export SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*" +export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*" ``` ```json { "npm": { "minimumPackageAgeExclusions": ["@aikidosec/*"] - }, - "pip": { - "minimumPackageAgeExclusions": ["requests"] } } ``` @@ -293,65 +259,6 @@ You can set custom registries through environment variable or config file. Both } ``` -## PYPI Configuration File - -If you rely on a `pip.conf` file for pip configuration you must point pip at it explicitly via the `PIP_CONFIG_FILE` environment variable so Safe Chain can merge it. - -Safe Chain runs pip behind its MITM proxy and writes a temporary pip configuration file to inject its certificate and proxy settings. When `PIP_CONFIG_FILE` is set, Safe Chain merges its settings into a copy of your file (your original file is never modified) so your `index-url`, credentials, and other options are preserved. When `PIP_CONFIG_FILE` is not set, pip's user-level config (e.g. `~/.config/pip/pip.conf`) might be overridden by Safe Chain's temporary file and your settings will not be picked up. - -## Malware List Base URL - -Configure Safe Chain to fetch malware databases and new packages lists from a custom mirror URL. This allows you to host your own copy of the Aikido malware database. - -### Configuration Options - -You can set the malware list base URL through multiple sources (in order of priority): - -1. **CLI Argument** (highest priority): - - ```shell - npm install express --safe-chain-malware-list-base-url=https://your-mirror.com - ``` - -2. **Environment Variable**: - - ```shell - export SAFE_CHAIN_MALWARE_LIST_BASE_URL=https://your-mirror.com - npm install express - ``` - -3. **Config File** (`~/.safe-chain/config.json`): - - ```json - { - "malwareListBaseUrl": "https://your-mirror.com" - } - ``` - -The base URL should point to a server that mirrors the structure of `https://malware-list.aikido.dev/`, including the following paths: -- `/malware_predictions.json` (JavaScript ecosystem malware database) -- `/malware_pypi.json` (Python ecosystem malware database) -- `/releases/npm.json` (JavaScript new packages list) -- `/releases/pypi.json` (Python new packages list) - -## Custom Install Directory - -By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by passing an explicit install directory to the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools. - -When set, all Safe Chain data (binary, shims, scripts, config) is placed under the custom directory instead of `~/.safe-chain`. - -### Unix/Linux/macOS - -```shell -curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --install-dir /usr/local/.safe-chain -``` - -### Windows - -```powershell -iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -InstallDir 'C:\ProgramData\safe-chain'" -``` - # Usage in CI/CD You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. @@ -442,7 +349,6 @@ pipeline { 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}" } @@ -480,7 +386,7 @@ steps: 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 + - export PATH=~/.safe-chain/shims:$PATH - npm ci ``` @@ -498,7 +404,7 @@ To add safe-chain in GitLab pipelines, you need to install it in the image runni # 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) + # Add safe-chain to PATH ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}" ``` @@ -551,16 +457,4 @@ 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) +Having issues? See the [Troubleshooting Guide](https://help.aikido.dev/code-scanning/aikido-malware-scanning/safe-chain-troubleshooting) for help with common problems. 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/shell-integration.md b/docs/shell-integration.md index d6cc0e0..6b08fac 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`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. ## Supported Shells @@ -28,7 +28,7 @@ This command: - Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`) - Detects all supported shells on your system -- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` +- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` - Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name ❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly. @@ -78,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts: This means the shell functions are working but the Aikido commands aren't installed or available in your PATH: - Make sure Aikido Safe Chain is properly installed on your system -- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-rush`, `aikido-rushx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist +- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-poetry` and `aikido-pipx` commands exist - Check that these commands are in your system's PATH ### Manual Verification @@ -121,7 +121,7 @@ npm() { } ``` -Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. +Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4672849..456fe58 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4,38 +4,49 @@ This guide helps you diagnose and resolve common issues with Aikido Safe Chain. ## Verification & Diagnostics -**Check Installation** +### Check Installation ```bash # Check version safe-chain --version ``` -**Verify Shell Integration** +### Verify Shell Integration Run the verification command for your package manager: ```bash npm safe-chain-verify pnpm safe-chain-verify +pip safe-chain-verify +uv safe-chain-verify + +# Any other supported package manager: {packagemanager} safe-chain-verify ``` -``` Expected output: `OK: Safe-chain works!` -``` -**Test Malware Blocking** +### Test Malware Blocking Verify that malware detection is working: -``` + +**For JavaScript/Node.js:** + +```bash npm install safe-chain-test ``` +**For Python:** + +```bash +pip3 install safe-chain-pi-test +``` + 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. +**If the test package installs successfully instead of being blocked**, see [Malware Not Being Blocked](#malware-not-being-blocked) below. -## Logging Options +### Logging Options Use logging flags or environment variables to get more information: @@ -63,39 +74,41 @@ Safe-chain blocks malicious packages by intercepting network requests to package When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy. -**Resolution Steps** +**Resolution Steps:** -1) Clear your package manager's cache +1. **Clear your package manager's cache:** -```bash -# For npm -npm cache clean --force + ```bash + # For npm + npm cache clean --force -# For pnpm -pnpm store prune + # For pnpm + pnpm store prune -# For yarn (classic) -yarn cache clean + # For yarn (classic) + yarn cache clean -# For yarn (berry/v2+) -yarn cache clean --all + # For yarn (berry/v2+) + yarn cache clean --all -# For bun -bun pm cache rm -``` + # For bun + bun pm cache rm + ``` -2) Clean local installation artifacts: + > **⚠️ Warning:** Cache clearing is safe but will remove all cached packages. Subsequent installations will need to re-download packages. In CI/CD environments or monorepos, this may affect build times. -```bash -# Remove node_modules if you want a completely fresh install -rm -rf node_modules -``` +2. **Clean local installation artifacts:** -3) Re-test malware blocking: + ```bash + # Remove node_modules if you want a completely fresh install + rm -rf node_modules + ``` -```bash -npm install safe-chain-test # Should be blocked -``` +3. **Re-test malware blocking:** + + ```bash + npm install safe-chain-test # Should be blocked + ``` ### Shell Aliases Not Working After Installation @@ -115,10 +128,10 @@ Should show: `npm is a function` Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`: -* Bash: `~/.bashrc` -* Zsh: `~/.zshrc` -* Fish: `~/.config/fish/config.fish` -* PowerShell: `$PROFILE` +- Bash: `~/.bashrc` +- Zsh: `~/.zshrc` +- Fish: `~/.config/fish/config.fish` +- PowerShell: `$PROFILE` ### "Command Not Found: safe-chain" @@ -149,39 +162,37 @@ FullyQualifiedErrorId : UnauthorizedAccess **Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile. -**Resolution** +**Resolution:** -1) Set the execution policy to allow local scripts +1. **Set the execution policy to allow local scripts:** -Open PowerShell as Administrator and run: + Open PowerShell as Administrator and run: -```powershell -Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -``` + ```powershell + Set-ExecutionPolicy -ExecutionPolicy RemoteSigned + ``` -This allows: + This allows: + - Local scripts (like safe-chain's) to run without signing + - Downloaded scripts to run only if signed by a trusted publisher -* 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. -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. +> **Note:** `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability. ### Shell Aliases Persist After Uninstallation **Symptom:** safe-chain commands still active after running uninstall script -**Steps** +**Steps:** 1. Run `safe-chain teardown` (if binary still exists) 2. Restart your terminal 3. If still present, manually edit shell config files: - * Bash: `~/.bashrc` - * Zsh: `~/.zshrc` - * Fish: `~/.config/fish/config.fish` - * PowerShell: `$PROFILE` + - Bash: `~/.bashrc` + - Zsh: `~/.zshrc` + - Fish: `~/.config/fish/config.fish` + - PowerShell: `$PROFILE` 4. Remove lines that source scripts from `~/.safe-chain/scripts/` 5. Restart terminal again @@ -206,10 +217,10 @@ 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 +- 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. +If `which` shows an npm installation, see [Check for Conflicting Installations](#check-for-conflicting-installations). ### Check Shell Integration @@ -248,23 +259,23 @@ for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do done ``` -### Manual Cleanup +## Manual Cleanup > **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails. -#### Remove npm Global Installation +### Remove npm Global Installation ```bash npm uninstall -g @aikidosec/safe-chain ``` -#### Remove Volta Installation +### Remove Volta Installation ```bash volta uninstall @aikidosec/safe-chain ``` -#### Remove nvm Installations (All Versions) +### Remove nvm Installations (All Versions) ```bash # Automated approach @@ -277,22 +288,34 @@ nvm use npm uninstall -g @aikidosec/safe-chain ``` -#### Clean Shell Configuration Files +### Clean Shell Configuration Files Manually remove safe-chain entries from: -* Bash: `~/.bashrc` -* Zsh: `~/.zshrc` -* Fish: `~/.config/fish/config.fish` -* PowerShell: `$PROFILE` +- Bash: `~/.bashrc` +- Zsh: `~/.zshrc` +- Fish: `~/.config/fish/config.fish` +- PowerShell: `$PROFILE` Look for and remove: -* Lines sourcing from `~/.safe-chain/scripts/` -* Any safe-chain related function definitions +- Lines sourcing from `~/.safe-chain/scripts/` +- Any safe-chain related function definitions -#### Remove Installation Directory +### Remove Installation Directory ```bash rm -rf ~/.safe-chain ``` + +### Report Issues + +If you encounter problems: + +1. Visit [GitHub Issues](https://github.com/AikidoSec/safe-chain/issues) +2. Include: + - Operating system and version + - Shell type and version + - `safe-chain --version` output + - Output from verification commands + - Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument) diff --git a/install-scripts/install-endpoint-mac.sh b/install-scripts/install-endpoint-mac.sh old mode 100755 new mode 100644 index 4a78f52..684a8a8 --- a/install-scripts/install-endpoint-mac.sh +++ b/install-scripts/install-endpoint-mac.sh @@ -1,14 +1,14 @@ #!/bin/sh -# Downloads and installs Aikido Endpoint Protection on macOS +# Downloads and installs SafeChain Ultimate endpoint 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.6/EndpointProtection.pkg" -DOWNLOAD_SHA256="345b26168b3090de5268c48d923cdf115cc617c39c37d44cc40fb9150409a6ba" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.5/SafeChainUltimate.pkg" +DOWNLOAD_SHA256="abc2b0e6c6a4ca33cd893eeb16744f9f2da90013fb1abac301f5c00c2ad8bc30" TOKEN_FILE="/tmp/aikido_endpoint_token.txt" # Colors for output @@ -111,10 +111,10 @@ main() { esac # 2. Download and verify checksum - PKG_FILE=$(mktemp /tmp/AikidoEndpoint.XXXXXX.pkg) + PKG_FILE=$(mktemp /tmp/SafeChainUltimate.XXXXXX.pkg) trap cleanup EXIT - info "Downloading Aikido Endpoint Protection..." + info "Downloading SafeChain Ultimate..." download "$INSTALL_URL" "$PKG_FILE" info "Verifying checksum..." @@ -124,10 +124,10 @@ main() { printf "%s" "$TOKEN" > "$TOKEN_FILE" # 4. Install the package - info "Installing Aikido Endpoint Protection..." + info "Installing SafeChain Ultimate..." installer -pkg "$PKG_FILE" -target / - info "Aikido Endpoint Protection installed successfully!" + info "SafeChain Ultimate installed successfully!" } main "$@" diff --git a/install-scripts/install-endpoint-windows.ps1 b/install-scripts/install-endpoint-windows.ps1 index a025a50..f99d1ff 100644 --- a/install-scripts/install-endpoint-windows.ps1 +++ b/install-scripts/install-endpoint-windows.ps1 @@ -1,4 +1,4 @@ -# Downloads and installs Aikido Endpoint Protection on Windows +# Downloads and installs SafeChain Ultimate endpoint on Windows # # Usage: iex "& { $(iwr '' -UseBasicParsing) } -token " @@ -7,8 +7,8 @@ param( ) # Configuration -$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.6/EndpointProtection.msi" -$DownloadSha256 = "70382b65036c6a4f0fc64e221ab3e74b06ec23bce54f93616a1e59abaac5442d" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.5/SafeChainUltimate.msi" +$DownloadSha256 = "c4d1be7bb2128473b8e955244dc186b5d3f091f668b43cdd3d810cff9d38193c" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 @@ -53,9 +53,9 @@ function Install-Endpoint { } # 2. Download the .msi - $msiFile = Join-Path $env:TEMP "AikidoEndpoint-$([System.Guid]::NewGuid().ToString('N')).msi" + $msiFile = Join-Path $env:TEMP "SafeChainUltimate-$([System.Guid]::NewGuid().ToString('N')).msi" - Write-Info "Downloading Aikido Endpoint Protection..." + Write-Info "Downloading SafeChain Ultimate..." try { $ProgressPreference = 'SilentlyContinue' Invoke-WebRequest -Uri $InstallUrl -OutFile $msiFile -UseBasicParsing @@ -75,13 +75,13 @@ function Install-Endpoint { Write-Info "Checksum verified successfully." # 3. Install the package with token passed as MSI property - Write-Info "Installing Aikido Endpoint Protection..." + Write-Info "Installing SafeChain Ultimate..." $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!" + Write-Info "SafeChain Ultimate installed successfully!" } finally { # Cleanup diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 53ce15f..ffe2505 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -4,68 +4,13 @@ param( [switch]$ci, - [switch]$includepython, - [string]$InstallDir + [switch]$includepython ) -# Validates and normalizes the requested install directory. -# Rejects non-absolute, root, PATH-like, and traversal-containing paths. -function Test-InstallDir { - param([string]$Dir) - - if ([string]::IsNullOrWhiteSpace($Dir)) { - return @{ Ok = $true; Normalized = $null } - } - - if (-not [System.IO.Path]::IsPathRooted($Dir)) { - return @{ Ok = $false; Reason = "-InstallDir must be an absolute path, got: $Dir" } - } - - if ($Dir.Contains([System.IO.Path]::PathSeparator)) { - return @{ Ok = $false; Reason = "-InstallDir must not contain the PATH separator ($([System.IO.Path]::PathSeparator))" } - } - - $inputSegments = $Dir.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries) - if ($inputSegments -contains "..") { - return @{ Ok = $false; Reason = "-InstallDir must not contain path traversal segments" } - } - - $normalized = [System.IO.Path]::GetFullPath($Dir) - $root = [System.IO.Path]::GetPathRoot($normalized) - if ($normalized.TrimEnd('\', '/') -eq $root.TrimEnd('\', '/')) { - return @{ Ok = $false; Reason = "-InstallDir cannot be a root or drive-root directory" } - } - - return @{ Ok = $true; Normalized = $normalized } -} - $Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set -$SafeChainBase = if ($InstallDir) { $InstallDir } else { Join-Path $HOME ".safe-chain" } - -$installDirValidation = Test-InstallDir -Dir $SafeChainBase -if (-not $installDirValidation.Ok) { - Write-Host "[ERROR] $($installDirValidation.Reason)" -ForegroundColor Red - exit 1 -} - -$SafeChainBase = $installDirValidation.Normalized -$InstallDir = Join-Path $SafeChainBase "bin" +$InstallDir = Join-Path $env:USERPROFILE ".safe-chain\bin" $RepoUrl = "https://github.com/AikidoSec/safe-chain" -# SHA256 checksums for release binaries. -# Empty in source; populated by the release pipeline. -# When empty (running from main), checksum verification is skipped. -# Non-Windows hashes are unused today (PS script is Windows-only) but baked in -# for future cross-platform support. -$SHA256_MACOS_X64 = "" -$SHA256_MACOS_ARM64 = "" -$SHA256_LINUX_X64 = "" -$SHA256_LINUX_ARM64 = "" -$SHA256_LINUXSTATIC_X64 = "" -$SHA256_LINUXSTATIC_ARM64 = "" -$SHA256_WIN_X64 = "" -$SHA256_WIN_ARM64 = "" - # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 @@ -153,91 +98,6 @@ function Get-Architecture { } } -# Emits the deprecation warning for SAFE_CHAIN_VERSION and prints the version-pinned install command. -# Returns immediately when no version was provided through the environment. -function Write-VersionDeprecationWarning { - if ([string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { - return - } - - Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated." - Write-Warn "" - Write-Warn "Please use direct download URLs for version pinning instead:" - Write-Warn "" - if ($ci) { - Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`"" - } else { - Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)" - } - Write-Warn "" -} - -# Builds the Windows release binary filename for the detected architecture. -# Centralizes binary name generation for the download step. -function Get-BinaryName { - param([string]$Architecture) - - return "safe-chain-win-$Architecture.exe" -} - -# Returns the expected SHA256 for the given OS+arch, or empty if not baked in. -function Get-ExpectedSha256 { - param([string]$Os, [string]$Architecture) - switch ("$Os-$Architecture") { - "macos-x64" { return $SHA256_MACOS_X64 } - "macos-arm64" { return $SHA256_MACOS_ARM64 } - "linux-x64" { return $SHA256_LINUX_X64 } - "linux-arm64" { return $SHA256_LINUX_ARM64 } - "linuxstatic-x64" { return $SHA256_LINUXSTATIC_X64 } - "linuxstatic-arm64" { return $SHA256_LINUXSTATIC_ARM64 } - "win-x64" { return $SHA256_WIN_X64 } - "win-arm64" { return $SHA256_WIN_ARM64 } - default { return "" } - } -} - -function Test-Checksum { - param([string]$File, [string]$Expected) - - if ([string]::IsNullOrWhiteSpace($Expected)) { return } - - $actual = (Get-FileHash -Path $File -Algorithm SHA256).Hash.ToLowerInvariant() - $expectedLower = $Expected.ToLowerInvariant() - - if ($actual -ne $expectedLower) { - Remove-Item -Path $File -Force -ErrorAction SilentlyContinue - Write-Error-Custom "Checksum verification failed. Expected: $expectedLower, Got: $actual" - } - - Write-Info "Checksum verified." -} - -# Runs safe-chain setup or setup-ci after the binary is installed. -# Temporarily appends the install directory to PATH and downgrades setup failures to warnings. -function Invoke-SafeChainSetup { - param( - [string]$BinaryPath, - [string]$InstallDirectory - ) - - $setupCmd = if ($ci) { "setup-ci" } else { "setup" } - - Write-Info "Running safe-chain $setupCmd..." - try { - $env:Path = "$env:Path;$InstallDirectory" - & $BinaryPath $setupCmd - - if ($LASTEXITCODE -ne 0) { - Write-Warn "safe-chain was installed but setup encountered issues." - Write-Warn "You can run 'safe-chain $setupCmd' manually later." - } - } - catch { - Write-Warn "safe-chain was installed but setup encountered issues: $_" - Write-Warn "You can run 'safe-chain $setupCmd' manually later." - } -} - # Check and uninstall npm global package if present function Remove-NpmInstallation { # Check if npm is available @@ -289,7 +149,19 @@ function Remove-VoltaInstallation { # Main installation function Install-SafeChain { - Write-VersionDeprecationWarning + # Show deprecation warning if SAFE_CHAIN_VERSION is set + if (-not [string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { + 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 "" + } # Fetch latest version if VERSION is not set if ([string]::IsNullOrWhiteSpace($Version)) { @@ -320,7 +192,7 @@ function Install-SafeChain { # Detect platform $arch = Get-Architecture - $binaryName = Get-BinaryName -Architecture $arch + $binaryName = "safe-chain-win-$arch.exe" Write-Info "Detected architecture: $arch" @@ -351,9 +223,6 @@ function Install-SafeChain { Write-Error-Custom "Failed to download from $downloadUrl : $_" } - $expectedSha = Get-ExpectedSha256 -Os "win" -Architecture $arch - Test-Checksum -File $tempFile -Expected $expectedSha - # Rename to final location $finalFile = Join-Path $InstallDir "safe-chain.exe" try { @@ -369,7 +238,31 @@ function Install-SafeChain { Write-Info "Binary installed to: $finalFile" - Invoke-SafeChainSetup -BinaryPath $finalFile -InstallDirectory $InstallDir + # Build setup command based on parameters + $setupCmd = if ($ci) { "setup-ci" } else { "setup" } + $setupArgs = @() + + # Execute safe-chain setup + Write-Info "Running safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })..." + try { + $env:Path = "$env:Path;$InstallDir" + + if ($setupArgs) { + & $finalFile $setupCmd $setupArgs + } + else { + & $finalFile $setupCmd + } + + if ($LASTEXITCODE -ne 0) { + Write-Warn "safe-chain was installed but setup encountered issues." + Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later." + } + } + catch { + Write-Warn "safe-chain was installed but setup encountered issues: $_" + Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later." + } } # Run installation diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 5f73c53..182cdad 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -6,67 +6,11 @@ set -e # Exit on error -# Validates a user-provided install dir and exits on unsafe values. -# Rejects relative paths, root paths, PATH separators, and traversal segments. -validate_install_dir() { - dir="$1" - - if [ -z "$dir" ]; then - return 0 - fi - - case "$dir" in - /*) ;; - *) - printf '[ERROR] --install-dir must be an absolute path, got: %s\n' "$dir" >&2 - exit 1 - ;; - esac - - case "$dir" in - *:*) - printf '[ERROR] --install-dir must not contain the PATH separator (:)\n' >&2 - exit 1 - ;; - esac - - if [ "$dir" = "/" ]; then - printf '[ERROR] --install-dir cannot be a root or drive-root directory\n' >&2 - exit 1 - fi - - old_ifs=$IFS - IFS='/' - set -- $dir - IFS=$old_ifs - - for segment in "$@"; do - if [ "$segment" = ".." ]; then - printf '[ERROR] --install-dir must not contain path traversal segments\n' >&2 - exit 1 - fi - done -} - # Configuration VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set -SAFE_CHAIN_BASE="${HOME}/.safe-chain" - -INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" +INSTALL_DIR="${HOME}/.safe-chain/bin" REPO_URL="https://github.com/AikidoSec/safe-chain" -# SHA256 checksums for release binaries. -# Empty in source; populated by the release pipeline via sed. -# When empty (running from main), checksum verification is skipped. -SHA256_MACOS_X64="" -SHA256_MACOS_ARM64="" -SHA256_LINUX_X64="" -SHA256_LINUX_ARM64="" -SHA256_LINUXSTATIC_X64="" -SHA256_LINUXSTATIC_ARM64="" -SHA256_WIN_X64="" -SHA256_WIN_ARM64="" - # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -168,57 +112,6 @@ fetch_latest_version() { echo "$latest_version" } -# Returns the expected SHA256 for the detected platform, or empty if the -# release pipeline has not baked one in (i.e. running the source from main). -get_expected_sha256() { - os="$1"; arch="$2" - case "${os}-${arch}" in - macos-x64) echo "$SHA256_MACOS_X64" ;; - macos-arm64) echo "$SHA256_MACOS_ARM64" ;; - linux-x64) echo "$SHA256_LINUX_X64" ;; - linux-arm64) echo "$SHA256_LINUX_ARM64" ;; - linuxstatic-x64) echo "$SHA256_LINUXSTATIC_X64" ;; - linuxstatic-arm64) echo "$SHA256_LINUXSTATIC_ARM64" ;; - win-x64) echo "$SHA256_WIN_X64" ;; - win-arm64) echo "$SHA256_WIN_ARM64" ;; - *) echo "" ;; - esac -} - -compute_sha256() { - file="$1" - if command_exists sha256sum; then - sha256sum "$file" | awk '{print $1}' - elif command_exists shasum; then - shasum -a 256 "$file" | awk '{print $1}' - else - echo "" - fi -} - -# Verifies the downloaded binary against the expected hash baked in by the release pipeline. -# No-op when no expected hash is set (running the script from main). -verify_checksum() { - file="$1"; expected="$2" - - if [ -z "$expected" ]; then - return - fi - - actual=$(compute_sha256 "$file") - if [ -z "$actual" ]; then - rm -f "$file" - error "Cannot verify checksum: neither sha256sum nor shasum is available. Install one and re-run." - fi - - if [ "$actual" != "$expected" ]; then - rm -f "$file" - error "Checksum verification failed. Expected: $expected, Got: $actual" - fi - - info "Checksum verified." -} - # Download file download() { url="$1" @@ -233,75 +126,6 @@ download() { fi } -# Prints the deprecation warning for SAFE_CHAIN_VERSION and the replacement install command. -# Returns immediately when no version was pinned through the environment. -warn_deprecated_version_env() { - if [ -z "$SAFE_CHAIN_VERSION" ]; then - return - fi - - warn "SAFE_CHAIN_VERSION environment variable is deprecated." - warn "" - warn "Please use direct download URLs for version pinning instead:" - warn "" - if [ "$USE_CI_SETUP" = "true" ]; then - warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci" - else - warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh" - fi - warn "" -} - -# Ensures VERSION is populated before installation continues. -# Fetches the latest release only when no explicit version was provided. -ensure_version() { - if [ -n "$VERSION" ]; then - return - fi - - info "Fetching latest release version..." - VERSION=$(fetch_latest_version) -} - -# Constructs platform-specific binary filename to match GitHub release asset naming convention. -get_binary_name() { - os="$1" - arch="$2" - - if [ "$os" = "win" ]; then - printf 'safe-chain-%s-%s.exe\n' "$os" "$arch" - else - printf 'safe-chain-%s-%s\n' "$os" "$arch" - fi -} - -# Returns the final installation path for the downloaded safe-chain binary. -# Uses INSTALL_DIR and the platform-specific executable name. -get_final_binary_path() { - os="$1" - - if [ "$os" = "win" ]; then - printf '%s/safe-chain.exe\n' "$INSTALL_DIR" - else - printf '%s/safe-chain\n' "$INSTALL_DIR" - fi -} - -run_setup_command() { - final_file="$1" - - setup_cmd="setup" - if [ "$USE_CI_SETUP" = "true" ]; then - setup_cmd="setup-ci" - fi - - info "Running safe-chain $setup_cmd..." - if ! "$final_file" "$setup_cmd"; then - warn "safe-chain was installed but setup encountered issues." - warn "You can run 'safe-chain $setup_cmd' manually later." - fi -} - # Check and uninstall npm global package if present remove_npm_installation() { if ! command_exists npm; then @@ -405,39 +229,19 @@ remove_nvm_installation() { # Parse command-line arguments parse_arguments() { - while [ $# -gt 0 ]; do - case "$1" in + for arg in "$@"; do + case "$arg" in --ci) USE_CI_SETUP=true ;; - --install-dir) - shift - if [ $# -eq 0 ]; then - error "Missing value for --install-dir" - fi - if [ -z "$1" ]; then - error "--install-dir must not be empty" - fi - SAFE_CHAIN_BASE="$1" - ;; - --install-dir=*) - SAFE_CHAIN_BASE="${1#--install-dir=}" - if [ -z "$SAFE_CHAIN_BASE" ]; then - error "--install-dir must not be empty" - fi - ;; --include-python) warn "--include-python is deprecated and ignored. Python ecosystem is now included by default." ;; *) - error "Unknown argument: $1" + error "Unknown argument: $arg" ;; esac - shift done - - validate_install_dir "${SAFE_CHAIN_BASE}" - INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" } # Main installation @@ -448,9 +252,25 @@ main() { # Parse command-line arguments parse_arguments "$@" - warn_deprecated_version_env + # Show deprecation warning if SAFE_CHAIN_VERSION is set + if [ -n "$SAFE_CHAIN_VERSION" ]; then + 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 "" + fi - ensure_version + # Fetch latest version if VERSION is not set + if [ -z "$VERSION" ]; then + info "Fetching latest release version..." + VERSION=$(fetch_latest_version) + fi # Check if the requested version is already installed if is_version_installed "$VERSION"; then @@ -474,7 +294,11 @@ main() { # Detect platform OS=$(detect_os) ARCH=$(detect_arch) - BINARY_NAME=$(get_binary_name "$OS" "$ARCH") + if [ "$OS" = "win" ]; then + BINARY_NAME="safe-chain-${OS}-${ARCH}.exe" + else + BINARY_NAME="safe-chain-${OS}-${ARCH}" + fi info "Detected platform: ${OS}-${ARCH}" @@ -491,11 +315,12 @@ main() { info "Downloading from: $DOWNLOAD_URL" download "$DOWNLOAD_URL" "$TEMP_FILE" - EXPECTED_SHA256=$(get_expected_sha256 "$OS" "$ARCH") - verify_checksum "$TEMP_FILE" "$EXPECTED_SHA256" - # Rename and make executable - FINAL_FILE=$(get_final_binary_path "$OS") + if [ "$OS" = "win" ]; then + FINAL_FILE="${INSTALL_DIR}/safe-chain.exe" + else + FINAL_FILE="${INSTALL_DIR}/safe-chain" + fi 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" @@ -503,7 +328,20 @@ main() { info "Binary installed to: $FINAL_FILE" - run_setup_command "$FINAL_FILE" + # Build setup command based on arguments + SETUP_CMD="setup" + SETUP_ARGS="" + + if [ "$USE_CI_SETUP" = "true" ]; then + SETUP_CMD="setup-ci" + fi + + # Execute safe-chain setup + info "Running safe-chain $SETUP_CMD $SETUP_ARGS..." + if ! "$FINAL_FILE" $SETUP_CMD $SETUP_ARGS; then + warn "safe-chain was installed but setup encountered issues." + warn "You can run 'safe-chain $SETUP_CMD $SETUP_ARGS' manually later." + fi } main "$@" diff --git a/install-scripts/uninstall-endpoint-mac.sh b/install-scripts/uninstall-endpoint-mac.sh old mode 100755 new mode 100644 index bd3b0e7..b1ba6e4 --- a/install-scripts/uninstall-endpoint-mac.sh +++ b/install-scripts/uninstall-endpoint-mac.sh @@ -1,13 +1,13 @@ #!/bin/sh -# Uninstalls Aikido Endpoint Protection on macOS +# Uninstalls SafeChain Ultimate endpoint on macOS # # Usage: curl -fsSL | sudo sh set -e # Exit on error # Configuration -UNINSTALL_SCRIPT="/Applications/Aikido Endpoint Protection.app/Contents/Resources/scripts/uninstall" +UNINSTALL_SCRIPT="/Library/Application Support/AikidoSecurity/SafeChainUltimate/scripts/uninstall" # Colors for output RED='\033[0;31m' @@ -38,13 +38,13 @@ main() { # 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)." + error "SafeChain Ultimate does not appear to be installed (uninstall script not found)." fi - info "Uninstalling Aikido Endpoint Protection..." + info "Uninstalling SafeChain Ultimate..." "$UNINSTALL_SCRIPT" - info "Aikido Endpoint Protection uninstalled successfully!" + info "SafeChain Ultimate uninstalled successfully!" } main "$@" diff --git a/install-scripts/uninstall-endpoint-windows.ps1 b/install-scripts/uninstall-endpoint-windows.ps1 index 90741c7..5de5bfe 100644 --- a/install-scripts/uninstall-endpoint-windows.ps1 +++ b/install-scripts/uninstall-endpoint-windows.ps1 @@ -1,9 +1,9 @@ -# Uninstalls Aikido Endpoint Protection endpoint on Windows +# Uninstalls SafeChain Ultimate endpoint on Windows # # Usage: iex (iwr '' -UseBasicParsing) # Configuration -$AppName = "Aikido Endpoint Protection" +$AppName = "SafeChain Ultimate" # Helper functions function Write-Info { @@ -32,22 +32,22 @@ function Uninstall-Endpoint { } # Find the installed product - Write-Info "Looking for Aikido Endpoint Protection installation..." + Write-Info "Looking for SafeChain Ultimate 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." + Write-Error-Custom "SafeChain Ultimate does not appear to be installed." } $productCode = $app.IdentifyingNumber - Write-Info "Uninstalling Aikido Endpoint Protection..." + Write-Info "Uninstalling SafeChain Ultimate..." $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!" + Write-Info "SafeChain Ultimate uninstalled successfully!" } # Run uninstallation diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index 6e24d5d..3292cdd 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -4,6 +4,8 @@ # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } +$DotSafeChain = Join-Path $HomeDir ".safe-chain" +$InstallDir = Join-Path $DotSafeChain "bin" # Helper functions function Write-Info { @@ -22,146 +24,6 @@ function Write-Error-Custom { exit 1 } -# Derives the safe-chain base install directory from a resolved binary path. -# Rejects wrapper scripts and paths that do not match the packaged bin layout. -function Get-InstallDirFromBinaryPath { - param([string]$BinaryPath) - - if ([string]::IsNullOrWhiteSpace($BinaryPath)) { - return $null - } - - try { - $resolvedPath = (Resolve-Path -LiteralPath $BinaryPath -ErrorAction Stop).Path - } - catch { - $resolvedPath = [System.IO.Path]::GetFullPath($BinaryPath) - } - - $fileName = [System.IO.Path]::GetFileName($resolvedPath) - if (($fileName -ne "safe-chain") -and ($fileName -ne "safe-chain.exe")) { - return $null - } - - if ($resolvedPath -match '\.(js|cjs|mjs|cmd|ps1)$') { - return $null - } - - $binDir = Split-Path -Parent $resolvedPath - if ((Split-Path -Leaf $binDir) -ne "bin") { - return $null - } - - return (Split-Path -Parent $binDir) -} - -# Returns the first safe-chain command found on PATH, if any. -# Used as the starting point for install-dir discovery. -function Get-SafeChainCommand { - return Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1 -} - -# Returns the safe-chain command path only when it points to a valid packaged binary install. -# Prevents teardown from invoking arbitrary wrappers or scripts from PATH. -function Get-ValidatedSafeChainCommandPath { - $command = Get-SafeChainCommand - if (-not $command -or [string]::IsNullOrWhiteSpace($command.Path)) { - return $null - } - - $installDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path - if (-not $installDir) { - return $null - } - - return $command.Path -} - -# Invokes the validated safe-chain binary with get-install-dir and returns the reported base directory. -# Safely returns $null when the command is unavailable or the lookup fails. -function Get-ReportedInstallDir { - $safeChainPath = Get-ValidatedSafeChainCommandPath - if (-not $safeChainPath) { - return $null - } - - try { - $reportedInstallDir = & $safeChainPath get-install-dir 2>$null | Select-Object -First 1 - if ($reportedInstallDir) { - $reportedInstallDir = $reportedInstallDir.Trim() - } - if ($reportedInstallDir) { - return $reportedInstallDir - } - } - catch { - return $null - } - - return $null -} - -# Determines the safe-chain base install directory for uninstall. -# Prefers the binary-reported location, then derives it from PATH, then falls back to the default home-dir layout. -function Get-SafeChainInstallDir { - $reportedInstallDir = Get-ReportedInstallDir - if ($reportedInstallDir) { - return $reportedInstallDir - } - - $command = Get-SafeChainCommand - if ($command -and $command.Path) { - $discoveredInstallDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path - if ($discoveredInstallDir) { - return $discoveredInstallDir - } - } - - return (Join-Path $HomeDir ".safe-chain") -} - -# Finds the installed safe-chain binary inside the resolved install directory. -# Falls back to a validated safe-chain command when the expected file is missing. -function Find-SafeChainBinary { - param([string]$DotSafeChain) - - $safeChainExe = Join-Path $DotSafeChain "bin/safe-chain.exe" - $safeChainBin = Join-Path $DotSafeChain "bin/safe-chain" - - if (Test-Path $safeChainExe) { - return $safeChainExe - } - - if (Test-Path $safeChainBin) { - return $safeChainBin - } - - return Get-ValidatedSafeChainCommandPath -} - -# Runs safe-chain teardown before removing the installation directory. -# Converts teardown failures into warnings so uninstall can still complete. -function Invoke-SafeChainTeardown { - param([string]$SafeChainPath) - - if (-not $SafeChainPath) { - Write-Warn "safe-chain command not found. Proceeding with uninstallation." - return - } - - Write-Info "Running safe-chain teardown..." - try { - & $SafeChainPath teardown - if ($LASTEXITCODE -ne 0) { - Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..." - } - } - catch { - Write-Warn "safe-chain teardown encountered issues: $_" - Write-Warn "Continuing with uninstallation..." - } -} - # Check and uninstall npm global package if present function Remove-NpmInstallation { # Check if npm is available @@ -214,9 +76,49 @@ function Remove-VoltaInstallation { # Main uninstallation function Uninstall-SafeChain { Write-Info "Uninstalling safe-chain..." - $DotSafeChain = Get-SafeChainInstallDir - $safeChainPath = Find-SafeChainBinary -DotSafeChain $DotSafeChain - Invoke-SafeChainTeardown -SafeChainPath $safeChainPath + + # Run teardown if safe-chain is available + # Check for both safe-chain.exe (Windows) and safe-chain (Unix) since PowerShell Core runs on all platforms + $safeChainExe = Join-Path $InstallDir "safe-chain.exe" + $safeChainBin = Join-Path $InstallDir "safe-chain" + + $safeChainPath = $null + if (Test-Path $safeChainExe) { + $safeChainPath = $safeChainExe + } + elseif (Test-Path $safeChainBin) { + $safeChainPath = $safeChainBin + } + + if ($safeChainPath) { + Write-Info "Running safe-chain teardown..." + try { + & $safeChainPath teardown + if ($LASTEXITCODE -ne 0) { + Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..." + } + } + catch { + Write-Warn "safe-chain teardown encountered issues: $_" + Write-Warn "Continuing with uninstallation..." + } + } + elseif (Get-Command safe-chain -ErrorAction SilentlyContinue) { + Write-Info "Running safe-chain teardown..." + try { + safe-chain teardown + if ($LASTEXITCODE -ne 0) { + Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..." + } + } + catch { + Write-Warn "safe-chain teardown encountered issues: $_" + Write-Warn "Continuing with uninstallation..." + } + } + else { + Write-Warn "safe-chain command not found. Proceeding with uninstallation." + } # Remove npm and Volta installations Remove-NpmInstallation diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index d215405..dff6f31 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -7,6 +7,7 @@ set -e # Exit on error # Configuration +DOT_SAFE_CHAIN="${HOME}/.safe-chain" # Colors for output RED='\033[0;31m' @@ -33,159 +34,6 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } -# Resolves a path to its canonical filesystem location when possible. -# Follows symlinks so binary validation can inspect the real installed path. -resolve_path() { - target="$1" - - while [ -L "$target" ]; do - link_target=$(readlink "$target" 2>/dev/null || echo "") - if [ -z "$link_target" ]; then - break - fi - - case "$link_target" in - /*) target="$link_target" ;; - *) - target="$(dirname "$target")/$link_target" - ;; - esac - done - - target_dir=$(dirname "$target") - target_name=$(basename "$target") - - if cd "$target_dir" 2>/dev/null; then - printf '%s/%s\n' "$(pwd -P)" "$target_name" - else - printf '%s\n' "$target" - fi -} - -# Derives the safe-chain base install directory from a packaged binary path. -# Rejects wrapper scripts and paths that do not match the expected bin layout. -derive_install_dir_from_binary() { - binary_path="$1" - - if [ -z "$binary_path" ]; then - return 1 - fi - - resolved_path=$(resolve_path "$binary_path") - binary_name=$(basename "$resolved_path") - case "$binary_name" in - safe-chain|safe-chain.exe) ;; - *) return 1 ;; - esac - - case "$resolved_path" in - *.js|*.cjs|*.mjs|*.cmd|*.ps1) return 1 ;; - esac - - binary_dir=$(dirname "$resolved_path") - if [ "$(basename "$binary_dir")" != "bin" ]; then - return 1 - fi - - dirname "$binary_dir" -} - -# Determines the installed safe-chain base directory for uninstall. -# Prefers the binary-reported location, then infers it from PATH, then falls back to ~/.safe-chain. -get_install_dir() { - reported_install_dir=$(get_reported_install_dir || true) - if [ -n "$reported_install_dir" ]; then - printf '%s\n' "$reported_install_dir" - return 0 - fi - - command_path=$(get_safe_chain_command_path || true) - install_dir=$(derive_install_dir_from_binary "$command_path" || true) - if [ -n "$install_dir" ]; then - printf '%s\n' "$install_dir" - return 0 - fi - - printf '%s\n' "${HOME}/.safe-chain" -} - -# Returns the current safe-chain command path from PATH. -# Fails when safe-chain is not currently resolvable. -get_safe_chain_command_path() { - if ! command_exists safe-chain; then - return 1 - fi - - command -v safe-chain -} - -# Returns the safe-chain command path only when it resolves to a valid packaged binary install. -# Prevents the uninstaller from invoking arbitrary PATH entries. -get_validated_safe_chain_command_path() { - command_path=$(get_safe_chain_command_path || true) - if [ -z "$command_path" ]; then - return 1 - fi - - install_dir=$(derive_install_dir_from_binary "$command_path" || true) - if [ -z "$install_dir" ]; then - return 1 - fi - - printf '%s\n' "$command_path" -} - -# Asks the validated safe-chain binary for its install directory via get-install-dir. -# Returns nothing if the command is unavailable or the lookup fails. -get_reported_install_dir() { - safe_chain_path=$(get_validated_safe_chain_command_path || true) - if [ -z "$safe_chain_path" ]; then - return 1 - fi - - install_dir=$("$safe_chain_path" get-install-dir 2>/dev/null || true) - if [ -n "$install_dir" ]; then - printf '%s\n' "$install_dir" - return 0 - fi - - return 1 -} - -# Locates the installed safe-chain binary to use for teardown. -# Checks the discovered install dir first, then falls back to a validated PATH entry. -find_installed_safe_chain_binary() { - dot_safe_chain="$1" - - safe_chain_location="$dot_safe_chain/bin/safe-chain" - if [ -x "$safe_chain_location" ]; then - printf '%s\n' "$safe_chain_location" - return 0 - fi - - command_path=$(get_validated_safe_chain_command_path || true) - if [ -n "$command_path" ]; then - printf '%s\n' "$command_path" - return 0 - fi - - return 1 -} - -# Runs safe-chain teardown before removing files. -# Continues with uninstall even if teardown is unavailable or fails. -run_safe_chain_teardown() { - safe_chain_command="$1" - - if [ -z "$safe_chain_command" ]; then - warn "safe-chain command not found. Proceeding with uninstallation." - return - fi - - info "Running safe-chain teardown..." - "$safe_chain_command" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..." -} - # Check and uninstall npm global package if present remove_npm_installation() { if ! command_exists npm; then @@ -291,9 +139,17 @@ remove_nvm_installation() { # Main uninstallation main() { - DOT_SAFE_CHAIN=$(get_install_dir) - SAFE_CHAIN_COMMAND=$(find_installed_safe_chain_binary "$DOT_SAFE_CHAIN" || true) - run_safe_chain_teardown "$SAFE_CHAIN_COMMAND" + SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain" + + if [ -x "$SAFE_CHAIN_LOCATION" ]; then + info "Running safe-chain teardown..." + "$SAFE_CHAIN_LOCATION" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..." + elif command_exists safe-chain; then + info "Running safe-chain teardown..." + safe-chain teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..." + else + warn "safe-chain command not found. Proceeding with uninstallation." + fi # Check for existing safe-chain installation through nvm, volta, or npm remove_npm_installation diff --git a/npm-shrinkwrap.json b/package-lock.json similarity index 76% rename from npm-shrinkwrap.json rename to package-lock.json index 310e28f..ea8c410 100644 --- a/npm-shrinkwrap.json +++ b/package-lock.json @@ -555,6 +555,102 @@ "node": "20 || >=22" } }, + "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.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?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.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/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/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -748,6 +844,26 @@ "win32" ] }, + "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/@types/archiver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz", + "integrity": "sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/ini": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", @@ -822,6 +938,16 @@ "@types/node": "*" } }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/retry": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", @@ -919,6 +1045,18 @@ "node": ">= 6" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -932,7 +1070,6 @@ "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" @@ -942,7 +1079,6 @@ "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" @@ -954,6 +1090,243 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "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.2.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "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/archiver-utils/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/archiver-utils/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/archiver-utils/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/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/archiver-utils/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "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.2.1" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/archiver/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -964,7 +1337,6 @@ "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": "*" @@ -975,11 +1347,16 @@ } } }, + "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/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": "*" @@ -1076,7 +1453,6 @@ "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", @@ -1127,6 +1503,15 @@ "dev": true, "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==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1152,6 +1537,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/cacache": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", @@ -1233,7 +1627,6 @@ "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" @@ -1246,7 +1639,6 @@ "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": { @@ -1261,13 +1653,205 @@ "node": ">= 0.8" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "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.2.1" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/compress-commons/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/compress-commons/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "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/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "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.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/crc32-stream/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/crc32-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "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/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -1354,11 +1938,16 @@ "readable-stream": "^2.0.2" } }, + "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==", - "dev": true, "license": "MIT" }, "node_modules/encoding": { @@ -1484,11 +2073,28 @@ "node": ">=6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "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" @@ -1508,7 +2114,6 @@ "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": { @@ -1529,6 +2134,22 @@ } } }, + "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/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -1686,7 +2307,6 @@ "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": { @@ -1789,7 +2409,6 @@ "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", @@ -1819,7 +2438,6 @@ "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": { @@ -1877,19 +2495,50 @@ "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/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "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/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/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1925,6 +2574,24 @@ ], "license": "MIT" }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.2.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", @@ -2281,6 +2948,15 @@ "nan": "^2.17.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.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", @@ -2381,6 +3057,21 @@ "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/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", @@ -2512,11 +3203,19 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.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": { @@ -2580,7 +3279,6 @@ "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", @@ -2592,6 +3290,27 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2636,7 +3355,6 @@ "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": { @@ -2658,6 +3376,39 @@ "node": ">=10" } }, + "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/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/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2769,7 +3520,6 @@ "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", @@ -2781,7 +3531,6 @@ "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" @@ -2791,7 +3540,21 @@ "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/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", @@ -2806,7 +3569,19 @@ "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-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" @@ -2874,7 +3649,6 @@ "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", @@ -2896,7 +3670,6 @@ "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" @@ -3010,7 +3783,6 @@ "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": { @@ -3040,6 +3812,21 @@ "webidl-conversions": "^3.0.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/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -3058,6 +3845,24 @@ "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/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3110,11 +3915,95 @@ "node": ">=10" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "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.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/zip-stream/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/zip-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "packages/safe-chain": { "name": "@aikidosec/safe-chain", "version": "1.0.0", "license": "AGPL-3.0-or-later", "dependencies": { + "archiver": "^7.0.1", "certifi": "14.5.15", "chalk": "5.4.1", "https-proxy-agent": "7.0.6", @@ -3129,7 +4018,6 @@ "aikido-bunx": "bin/aikido-bunx.js", "aikido-npm": "bin/aikido-npm.js", "aikido-npx": "bin/aikido-npx.js", - "aikido-pdm": "bin/aikido-pdm.js", "aikido-pip": "bin/aikido-pip.js", "aikido-pip3": "bin/aikido-pip3.js", "aikido-pipx": "bin/aikido-pipx.js", @@ -3138,14 +4026,12 @@ "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/archiver": "^7.0.0", "@types/ini": "^4.1.1", "@types/make-fetch-happen": "^10.0.4", "@types/node": "^18.19.130", 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-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-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/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 6ff33a0..8d942e4 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -1,11 +1,5 @@ #!/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"; @@ -21,8 +15,7 @@ import { main } from "../src/main.js"; import path from "path"; import { fileURLToPath } from "url"; import fs from "fs"; -import { knownAikidoTools, getPackageManagerList } from "../src/shell-integration/helpers.js"; -import { getInstalledSafeChainDir } from "../src/installLocation.js"; +import { knownAikidoTools } from "../src/shell-integration/helpers.js"; /** @type {string} */ // This checks the current file's dirname in a way that's compatible with: @@ -74,17 +67,6 @@ if (tool) { 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()}`); @@ -106,7 +88,7 @@ function writeHelp() { ui.writeInformation( `Available commands: ${chalk.cyan("setup")}, ${chalk.cyan( "teardown", - )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("get-install-dir")}, ${chalk.cyan("help")}, ${chalk.cyan( + )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan( "--version", )}`, ); @@ -114,7 +96,7 @@ function writeHelp() { ui.writeInformation( `- ${chalk.cyan( "safe-chain setup", - )}: This will setup your shell to wrap safe-chain around ${getPackageManagerList()}.`, + )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`, ); ui.writeInformation( `- ${chalk.cyan( @@ -126,11 +108,6 @@ function writeHelp() { "safe-chain setup-ci", )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`, ); - ui.writeInformation( - `- ${chalk.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", diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index 72f9bac..d4f3501 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -13,19 +13,15 @@ "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,8 +36,9 @@ "keywords": [], "author": "Aikido Security", "license": "AGPL-3.0-or-later", - "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [rush](https://rushjs.io/), [rushx](https://rushjs.io/pages/commands/rushx/), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), [pip](https://pip.pypa.io/), and [pdm](https://pdm-project.org/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, uv, uvx, pip/pip3, or pdm from downloading or running the malware.", + "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, or pip/pip3 from downloading or running the malware.", "dependencies": { + "archiver": "^7.0.1", "certifi": "14.5.15", "chalk": "5.4.1", "https-proxy-agent": "7.0.6", @@ -52,6 +49,7 @@ "semver": "7.7.2" }, "devDependencies": { + "@types/archiver": "^7.0.0", "@types/ini": "^4.1.1", "@types/make-fetch-happen": "^10.0.4", "@types/node": "^18.19.130", diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index 25babb9..abb2135 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -3,22 +3,14 @@ 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 malwareDatabaseUrls = { + [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json", + [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json", }; -const newPackagesListPaths = { - [ECOSYSTEM_JS]: "releases/npm.json", - [ECOSYSTEM_PY]: "releases/pypi.json", -}; - -const DEFAULT_FETCH_RETRY_ATTEMPTS = 4; - /** * @typedef {Object} MalwarePackage * @property {string} package_name @@ -26,26 +18,18 @@ const DEFAULT_FETCH_RETRY_ATTEMPTS = 4; * @property {string} reason */ -/** - * @typedef {Object} NewPackageEntry - * @property {string} [source] - * @property {string} package_name - * @property {string} version - * @property {number} released_on - Unix timestamp (seconds) - * @property {number} scraped_on - Unix timestamp (seconds) - */ - /** * @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>} */ export async function fetchMalwareDatabase() { + const numberOfAttempts = 4; + return retry(async () => { const ecosystem = getEcoSystem(); - const baseUrl = getMalwareListBaseUrl(); - const path = malwareDatabasePaths[ - /** @type {keyof typeof malwareDatabasePaths} */ (ecosystem) - ]; - const malwareDatabaseUrl = `${baseUrl}/${path}`; + const malwareDatabaseUrl = + malwareDatabaseUrls[ + /** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem) + ]; const response = await fetch(malwareDatabaseUrl); if (!response.ok) { throw new Error( @@ -62,20 +46,21 @@ export async function fetchMalwareDatabase() { } catch (/** @type {any} */ error) { throw new Error(`Error parsing malware database: ${error.message}`); } - }, DEFAULT_FETCH_RETRY_ATTEMPTS); + }, numberOfAttempts); } /** * @returns {Promise} */ export async function fetchMalwareDatabaseVersion() { + const numberOfAttempts = 4; + return retry(async () => { const ecosystem = getEcoSystem(); - const baseUrl = getMalwareListBaseUrl(); - const path = malwareDatabasePaths[ - /** @type {keyof typeof malwareDatabasePaths} */ (ecosystem) - ]; - const malwareDatabaseUrl = `${baseUrl}/${path}`; + const malwareDatabaseUrl = + malwareDatabaseUrls[ + /** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem) + ]; const response = await fetch(malwareDatabaseUrl, { method: "HEAD", }); @@ -86,67 +71,7 @@ export async function fetchMalwareDatabaseVersion() { ); } return response.headers.get("etag") || undefined; - }, DEFAULT_FETCH_RETRY_ATTEMPTS); -} - -/** - * @returns {Promise<{newPackagesList: NewPackageEntry[], version: string | undefined}>} - */ -export async function fetchNewPackagesList() { - return retry(async () => { - const ecosystem = getEcoSystem(); - const baseUrl = getMalwareListBaseUrl(); - const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)]; - - if (!path) { - return { newPackagesList: [], version: undefined }; - } - - const url = `${baseUrl}/${path}`; - - const response = await fetch(url); - if (!response.ok) { - throw new Error( - `Error fetching ${ecosystem} new packages list: ${response.statusText}` - ); - } - - try { - const newPackagesList = await response.json(); - return { - newPackagesList, - version: response.headers.get("etag") || undefined, - }; - } catch (/** @type {any} */ error) { - throw new Error(`Error parsing new packages list: ${error.message}`); - } - }, DEFAULT_FETCH_RETRY_ATTEMPTS); -} - -/** - * @returns {Promise} - */ -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); + }, numberOfAttempts); } /** @@ -166,7 +91,7 @@ async function retry(func, attempts) { return await func(); } catch (error) { ui.writeVerbose( - "An error occurred while trying to download Aikido data", + "An error occurred while trying to download the Aikido Malware database", error ); lastError = error; diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js index f41b9d2..2e7cecb 100644 --- a/packages/safe-chain/src/api/aikido.spec.js +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -3,7 +3,6 @@ import assert from "node:assert"; describe("aikido API", async () => { const mockFetch = mock.fn(); - let ecosystem = "js"; mock.module("make-fetch-happen", { defaultExport: mockFetch, @@ -19,23 +18,17 @@ describe("aikido API", async () => { mock.module("../config/settings.js", { namedExports: { - getEcoSystem: () => ecosystem, + getEcoSystem: () => "js", ECOSYSTEM_JS: "js", ECOSYSTEM_PY: "py", - getMalwareListBaseUrl: () => "https://malware-list.aikido.dev", }, }); - const { - fetchMalwareDatabase, - fetchMalwareDatabaseVersion, - fetchNewPackagesList, - fetchNewPackagesListVersion, - } = await import("./aikido.js"); + const { fetchMalwareDatabase, fetchMalwareDatabaseVersion } = + await import("./aikido.js"); beforeEach(() => { mockFetch.mock.resetCalls(); - ecosystem = "js"; }); describe("fetchMalwareDatabase", () => { @@ -137,95 +130,4 @@ describe("aikido API", async () => { 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/config/cliArguments.js b/packages/safe-chain/src/config/cliArguments.js index 918761c..25013fb 100644 --- a/packages/safe-chain/src/config/cliArguments.js +++ b/packages/safe-chain/src/config/cliArguments.js @@ -1,13 +1,12 @@ import { ui } from "../environment/userInteraction.js"; /** - * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, malwareListBaseUrl: string | undefined}} + * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined}} */ const state = { loggingLevel: undefined, skipMinimumPackageAge: undefined, minimumPackageAgeHours: undefined, - malwareListBaseUrl: undefined, }; const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-"; @@ -21,7 +20,6 @@ export function initializeCliArguments(args) { state.loggingLevel = undefined; state.skipMinimumPackageAge = undefined; state.minimumPackageAgeHours = undefined; - state.malwareListBaseUrl = undefined; const safeChainArgs = []; const remainingArgs = []; @@ -37,7 +35,6 @@ export function initializeCliArguments(args) { setLoggingLevel(safeChainArgs); setSkipMinimumPackageAge(safeChainArgs); setMinimumPackageAgeHours(safeChainArgs); - setMalwareListBaseUrl(safeChainArgs); checkDeprecatedPythonFlag(args); return remainingArgs; } @@ -112,26 +109,6 @@ export function getMinimumPackageAgeHours() { return state.minimumPackageAgeHours; } -/** - * @param {string[]} args - * @returns {void} - */ -function setMalwareListBaseUrl(args) { - const argName = SAFE_CHAIN_ARG_PREFIX + "malware-list-base-url="; - - const value = getLastArgEqualsValue(args, argName); - if (value) { - state.malwareListBaseUrl = value; - } -} - -/** - * @returns {string | undefined} - */ -export function getMalwareListBaseUrl() { - return state.malwareListBaseUrl; -} - /** * @param {string[]} args * @param {string} flagName diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index d340130..bc4dc94 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -3,7 +3,6 @@ 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 @@ -11,7 +10,6 @@ import { getSafeChainBaseDir } from "./safeChainDir.js"; * 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 * @@ -86,18 +84,6 @@ export function getMinimumPackageAgeHours() { return undefined; } -/** - * Gets the malware list base URL from config file only - * @returns {string | undefined} - */ -export function getMalwareListBaseUrl() { - const config = readConfigFile(); - if (config.malwareListBaseUrl && typeof config.malwareListBaseUrl === "string") { - return config.malwareListBaseUrl; - } - return undefined; -} - /** * Gets the custom npm registries from the config file (format parsing only, no validation) * @returns {string[]} @@ -143,21 +129,18 @@ export function getPipCustomRegistries() { } /** - * Gets the minimum package age exclusions from the config file for the current ecosystem + * Gets the minimum package age exclusions from the config file * @returns {string[]} */ -export function getMinimumPackageAgeExclusions() { +export function getNpmMinimumPackageAgeExclusions() { const config = readConfigFile(); - const ecosystem = getEcoSystem(); - const registryConfig = ecosystem === "py" ? config.pip : config.npm; - if (!config || !registryConfig) { + if (!config || !config.npm) { return []; } - const typedRegistryConfig = - /** @type {SafeChainRegistryConfiguration} */ (registryConfig); - const exclusions = typedRegistryConfig.minimumPackageAgeExclusions; + const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm); + const exclusions = npmConfig.minimumPackageAgeExclusions; if (!Array.isArray(exclusions)) { return []; @@ -228,7 +211,6 @@ function readConfigFile() { const emptyConfig = { scanTimeout: undefined, minimumPackageAgeHours: undefined, - malwareListBaseUrl: undefined, npm: { customRegistries: undefined, }, @@ -266,24 +248,6 @@ function getDatabaseVersionPath() { return path.join(aikidoDir, `version_${ecosystem}.txt`); } -/** - * @returns {string} - */ -export function getNewPackagesListPath() { - const safeChainDir = getSafeChainDirectory(); - const ecosystem = getEcoSystem(); - return path.join(safeChainDir, `newPackagesList_${ecosystem}.json`); -} - -/** - * @returns {string} - */ -export function getNewPackagesListVersionPath() { - const safeChainDir = getSafeChainDirectory(); - const ecosystem = getEcoSystem(); - return path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`); -} - /** * @returns {string} */ @@ -304,8 +268,9 @@ function getConfigFilePath() { /** * @returns {string} */ -export function getSafeChainDirectory() { - const safeChainDir = getSafeChainBaseDir(); +function getSafeChainDirectory() { + const homeDir = os.homedir(); + const safeChainDir = path.join(homeDir, ".safe-chain"); if (!fs.existsSync(safeChainDir)) { fs.mkdirSync(safeChainDir, { recursive: true }); diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index 932eff7..8a44841 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -41,17 +41,6 @@ export function getLoggingLevel() { * 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; +export function getNpmMinimumPackageAgeExclusions() { + return process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS; } 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..7919d87 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -1,7 +1,6 @@ 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"; @@ -189,59 +188,13 @@ function parseExclusionsFromEnv(envValue) { * Gets the minimum package age exclusions from both environment variable and config file (merged) * @returns {string[]} */ -export function getMinimumPackageAgeExclusions() { +export function getNpmMinimumPackageAgeExclusions() { const envExclusions = parseExclusionsFromEnv( - environmentVariables.getMinimumPackageAgeExclusions() + environmentVariables.getNpmMinimumPackageAgeExclusions() ); - const configExclusions = configFile.getMinimumPackageAgeExclusions(); + const configExclusions = configFile.getNpmMinimumPackageAgeExclusions(); // 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(/\/+$/, ""); -} diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js index 48108c4..8db5b83 100644 --- a/packages/safe-chain/src/config/settings.spec.js +++ b/packages/safe-chain/src/config/settings.spec.js @@ -14,11 +14,7 @@ mock.module("fs", { const { getNpmCustomRegistries, getPipCustomRegistries, - getMinimumPackageAgeExclusions, - getMalwareListBaseUrl, - setEcoSystem, - ECOSYSTEM_JS, - ECOSYSTEM_PY, + getNpmMinimumPackageAgeExclusions, getLoggingLevel, LOGGING_SILENT, LOGGING_NORMAL, @@ -371,18 +367,13 @@ describe("getLoggingLevel", () => { }); }); -describe("getMinimumPackageAgeExclusions", () => { +describe("getNpmMinimumPackageAgeExclusions", () => { let originalEnv; - let originalLegacyEnv; - const envVarName = "SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS"; - const legacyEnvVarName = "SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS"; + const envVarName = "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(() => { @@ -391,18 +382,13 @@ describe("getMinimumPackageAgeExclusions", () => { } 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(); + const exclusions = getNpmMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, []); }); @@ -414,7 +400,7 @@ describe("getMinimumPackageAgeExclusions", () => { }, }); - const exclusions = getMinimumPackageAgeExclusions(); + const exclusions = getNpmMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["react", "@aikidosec/safe-chain"]); }); @@ -423,7 +409,7 @@ describe("getMinimumPackageAgeExclusions", () => { process.env[envVarName] = "lodash,express,@types/node"; configFileContent = undefined; - const exclusions = getMinimumPackageAgeExclusions(); + const exclusions = getNpmMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["lodash", "express", "@types/node"]); }); @@ -436,7 +422,7 @@ describe("getMinimumPackageAgeExclusions", () => { }, }); - const exclusions = getMinimumPackageAgeExclusions(); + const exclusions = getNpmMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["lodash", "react"]); }); @@ -449,7 +435,7 @@ describe("getMinimumPackageAgeExclusions", () => { }, }); - const exclusions = getMinimumPackageAgeExclusions(); + const exclusions = getNpmMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["lodash", "react", "express"]); }); @@ -458,7 +444,7 @@ describe("getMinimumPackageAgeExclusions", () => { process.env[envVarName] = " lodash , react "; configFileContent = undefined; - const exclusions = getMinimumPackageAgeExclusions(); + const exclusions = getNpmMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["lodash", "react"]); }); @@ -470,7 +456,7 @@ describe("getMinimumPackageAgeExclusions", () => { }, }); - const exclusions = getMinimumPackageAgeExclusions(); + const exclusions = getNpmMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["@babel/core", "@types/react"]); }); @@ -479,7 +465,7 @@ describe("getMinimumPackageAgeExclusions", () => { process.env[envVarName] = "lodash,,react,"; configFileContent = undefined; - const exclusions = getMinimumPackageAgeExclusions(); + const exclusions = getNpmMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["lodash", "react"]); }); @@ -488,7 +474,7 @@ describe("getMinimumPackageAgeExclusions", () => { process.env[envVarName] = ""; configFileContent = undefined; - const exclusions = getMinimumPackageAgeExclusions(); + const exclusions = getNpmMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, []); }); @@ -497,7 +483,7 @@ describe("getMinimumPackageAgeExclusions", () => { process.env[envVarName] = " , , "; configFileContent = undefined; - const exclusions = getMinimumPackageAgeExclusions(); + const exclusions = getNpmMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, []); }); @@ -509,139 +495,8 @@ describe("getMinimumPackageAgeExclusions", () => { }, }); - const exclusions = getMinimumPackageAgeExclusions(); + const exclusions = getNpmMinimumPackageAgeExclusions(); 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/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/installation/downloadAgent.js b/packages/safe-chain/src/installation/downloadAgent.js new file mode 100644 index 0000000..297908a --- /dev/null +++ b/packages/safe-chain/src/installation/downloadAgent.js @@ -0,0 +1,125 @@ +import { createWriteStream, createReadStream } from "fs"; +import { createHash } from "crypto"; +import { pipeline } from "stream/promises"; +import fetch from "make-fetch-happen"; + +const ULTIMATE_VERSION = "v1.0.0"; + +export const DOWNLOAD_URLS = { + win32: { + x64: { + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-amd64.msi`, + checksum: + "sha256:c6a36f9b8e55ab6b7e8742cbabc4469d85809237c0f5e6c21af20b36c416ee1d", + }, + arm64: { + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-arm64.msi`, + checksum: + "sha256:46acd1af6a9938ea194c8ee8b34ca9b47c8de22e088a0791f3c0751dd6239c90", + }, + }, + darwin: { + x64: { + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-amd64.pkg`, + checksum: + "sha256:bb1829e8ca422e885baf37bef08dcbe7df7a30f248e2e89c4071564f7d4f3396", + }, + arm64: { + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-arm64.pkg`, + checksum: + "sha256:7fe4a785709911cc366d8224b4c290677573b8c4833bd9054768299e55c5f0ed", + }, + }, +}; + +/** + * Builds the download URL for the SafeChain Agent installer. + * @param {string} fileName + */ +export function getAgentDownloadUrl(fileName) { + return `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/${fileName}`; +} + +/** + * Downloads a file from a URL to a local path. + * @param {string} url + * @param {string} destPath + */ +export async function downloadFile(url, destPath) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Download failed: ${response.statusText}`); + } + await pipeline(response.body, createWriteStream(destPath)); +} + +/** + * Returns the current agent version. + */ +export function getAgentVersion() { + return ULTIMATE_VERSION; +} + +/** + * Returns download info (url, checksum) for the current OS and architecture. + * @returns {{ url: string, checksum: string } | null} + */ +export function getDownloadInfoForCurrentPlatform() { + const platform = process.platform; + const arch = process.arch; + + if (!Object.hasOwn(DOWNLOAD_URLS, platform)) { + return null; + } + const platformUrls = + DOWNLOAD_URLS[/** @type {keyof typeof DOWNLOAD_URLS} */ (platform)]; + + if (!Object.hasOwn(platformUrls, arch)) { + return null; + } + + return platformUrls[/** @type {keyof typeof platformUrls} */ (arch)]; +} + +/** + * Verifies the checksum of a file. + * @param {string} filePath + * @param {string} expectedChecksum - Format: "algorithm:hash" (e.g., "sha256:abc123...") + * @returns {Promise} + */ +export async function verifyChecksum(filePath, expectedChecksum) { + const [algorithm, expected] = expectedChecksum.split(":"); + + const hash = createHash(algorithm); + + if (filePath.includes("..")) throw new Error("Invalid file path"); + const stream = createReadStream(filePath); + + for await (const chunk of stream) { + hash.update(chunk); + } + + const actual = hash.digest("hex"); + return actual === expected; +} + +/** + * Downloads the SafeChain agent for the current OS/arch and verifies its checksum. + * @param {string} fileName - Destination file path + * @returns {Promise} The file path if successful, null if no download URL for current platform + */ +export async function downloadAgentToFile(fileName) { + const info = getDownloadInfoForCurrentPlatform(); + if (!info) { + return null; + } + + await downloadFile(info.url, fileName); + + const isValid = await verifyChecksum(fileName, info.checksum); + if (!isValid) { + throw new Error("Checksum verification failed"); + } + + return fileName; +} diff --git a/packages/safe-chain/src/installation/downloadAgent.spec.js b/packages/safe-chain/src/installation/downloadAgent.spec.js new file mode 100644 index 0000000..17aecb9 --- /dev/null +++ b/packages/safe-chain/src/installation/downloadAgent.spec.js @@ -0,0 +1,45 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { unlinkSync } from "node:fs"; +import { + DOWNLOAD_URLS, + downloadFile, + verifyChecksum, +} from "./downloadAgent.js"; + +describe("downloadAgent checksums", { timeout: 120_000 }, () => { + const downloadedFiles = []; + + after(() => { + for (const file of downloadedFiles) { + try { + unlinkSync(file); + } catch { + // ignore cleanup errors + } + } + }); + + for (const [platform, architectures] of Object.entries(DOWNLOAD_URLS)) { + for (const [arch, { url, checksum }] of Object.entries(architectures)) { + it(`${platform}/${arch} checksum matches`, async () => { + const destPath = join( + tmpdir(), + `safe-chain-test-${platform}-${arch}-${Date.now()}` + ); + downloadedFiles.push(destPath); + + await downloadFile(url, destPath); + + const isValid = await verifyChecksum(destPath, checksum); + assert.strictEqual( + isValid, + true, + `Checksum mismatch for ${platform}/${arch} (${url})` + ); + }); + } + } +}); diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js new file mode 100644 index 0000000..22ce1a8 --- /dev/null +++ b/packages/safe-chain/src/installation/installOnMacOS.js @@ -0,0 +1,155 @@ +import { tmpdir } from "os"; +import { unlinkSync } from "fs"; +import { join } from "path"; +import { execSync, spawnSync } from "child_process"; +import { ui } from "../environment/userInteraction.js"; +import { printVerboseAndSafeSpawn } from "../utils/safeSpawn.js"; +import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; +import chalk from "chalk"; + +const MACOS_PKG_IDENTIFIER = "com.aikidosecurity.safechainultimate"; + +/** + * Checks if root privileges are available and displays error message if not. + * @param {string} command - The sudo command to show in the error message + * @returns {boolean} True if running as root, false otherwise. + */ +function requireRootPrivileges(command) { + if (isRunningAsRoot()) { + return true; + } + + ui.writeError("Root privileges required."); + ui.writeInformation("Please run this command with sudo:"); + ui.writeInformation(` ${command}`); + return false; +} + +function isRunningAsRoot() { + const rootUserUid = 0; + return process.getuid?.() === rootUserUid; +} + +export async function installOnMacOS() { + if (!requireRootPrivileges("sudo safe-chain ultimate")) { + return; + } + + const pkgPath = join(tmpdir(), `SafeChainUltimate-${Date.now()}.pkg`); + + ui.emptyLine(); + ui.writeInformation(`📥 Downloading SafeChain Ultimate ${getAgentVersion()}`); + ui.writeVerbose(`Destination: ${pkgPath}`); + + const result = await downloadAgentToFile(pkgPath); + if (!result) { + ui.writeError("No download available for this platform/architecture."); + return; + } + + try { + ui.writeInformation("⚙️ Installing SafeChain Ultimate..."); + await runPkgInstaller(pkgPath); + + ui.emptyLine(); + ui.writeInformation( + "✅ SafeChain Ultimate installed and started successfully!", + ); + ui.emptyLine(); + ui.writeInformation( + chalk.cyan("🔐 ") + + chalk.bold("ACTION REQUIRED: ") + + "macOS will show a popup to install our certificate.", + ); + ui.writeInformation( + " " + + chalk.bold("Please accept the certificate") + + " to complete the installation.", + ); + ui.emptyLine(); + } finally { + ui.writeVerbose(`Cleaning up temporary file: ${pkgPath}`); + cleanup(pkgPath); + } +} + +const MACOS_UNINSTALL_SCRIPT = + "/Library/Application\\ Support/AikidoSecurity/SafeChainUltimate/scripts/uninstall"; + +export async function uninstallOnMacOS() { + if (!requireRootPrivileges("sudo safe-chain ultimate uninstall")) { + return; + } + + ui.emptyLine(); + + if (!isPackageInstalled()) { + ui.writeInformation("SafeChain Ultimate is not installed."); + return; + } + + ui.writeInformation("🗑️ Uninstalling SafeChain Ultimate..."); + ui.writeVerbose(`Running: ${MACOS_UNINSTALL_SCRIPT}`); + + const result = spawnSync(MACOS_UNINSTALL_SCRIPT, { + stdio: "inherit", + shell: true, + }); + + if (result.status !== 0) { + ui.writeError( + `Uninstall script failed (exit code: ${result.status}). Please try again or remove manually.`, + ); + return; + } + + ui.emptyLine(); + ui.writeInformation("✅ SafeChain Ultimate has been uninstalled."); + ui.emptyLine(); +} + +function isPackageInstalled() { + try { + const output = execSync(`pkgutil --pkg-info ${MACOS_PKG_IDENTIFIER}`, { + encoding: "utf8", + stdio: "pipe", + }); + return output.includes(MACOS_PKG_IDENTIFIER); + } catch { + return false; + } +} + +/** + * @param {string} pkgPath + */ +async function runPkgInstaller(pkgPath) { + // Uses installer to install the package (https://ss64.com/mac/installer.html) + // Options: + // -pkg (required): The package to be installed. + // -target (required): The target volume is specified with the -target parameter. + // --> "-target /" installs to the current boot volume. + + const result = await printVerboseAndSafeSpawn( + "installer", + ["-pkg", pkgPath, "-target", "/"], + { + stdio: "inherit", + }, + ); + + if (result.status !== 0) { + throw new Error(`PKG installer failed (exit code: ${result.status})`); + } +} + +/** + * @param {string} pkgPath + */ +function cleanup(pkgPath) { + try { + unlinkSync(pkgPath); + } catch { + ui.writeVerbose("Failed to clean up temporary installer file."); + } +} diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js new file mode 100644 index 0000000..4cee911 --- /dev/null +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -0,0 +1,203 @@ +import { tmpdir } from "os"; +import { unlinkSync } from "fs"; +import { join } from "path"; +import { execSync } from "child_process"; +import { ui } from "../environment/userInteraction.js"; +import { printVerboseAndSafeSpawn, safeSpawn } from "../utils/safeSpawn.js"; +import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; + +const WINDOWS_SERVICE_NAME = "SafeChainUltimate"; +const WINDOWS_APP_NAME = "SafeChain Ultimate"; + +export async function uninstallOnWindows() { + if (!(await requireAdminPrivileges())) { + return; + } + + ui.emptyLine(); + + const productCode = getInstalledProductCode(); + if (!productCode) { + ui.writeInformation("SafeChain Ultimate is not installed."); + return; + } + + await stopServiceIfRunning(); + + ui.writeInformation("🗑️ Uninstalling SafeChain Ultimate..."); + await uninstallByProductCode(productCode); + + ui.emptyLine(); + ui.writeInformation("✅ SafeChain Ultimate has been uninstalled."); + ui.emptyLine(); +} + +export async function installOnWindows() { + if (!(await requireAdminPrivileges())) { + return; + } + + const msiPath = join(tmpdir(), `SafeChainUltimate-${Date.now()}.msi`); + + ui.emptyLine(); + ui.writeInformation(`📥 Downloading SafeChain Ultimate ${getAgentVersion()}`); + ui.writeVerbose(`Destination: ${msiPath}`); + + const result = await downloadAgentToFile(msiPath); + if (!result) { + ui.writeError("No download available for this platform/architecture."); + return; + } + + try { + ui.emptyLine(); + await stopServiceIfRunning(); + await uninstallIfInstalled(); + + ui.writeInformation("⚙️ Installing SafeChain Ultimate..."); + await runMsiInstaller(msiPath); + + ui.emptyLine(); + ui.writeInformation( + "✅ SafeChain Ultimate installed and started successfully!", + ); + ui.emptyLine(); + } finally { + ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); + cleanup(msiPath); + } +} + +/** + * Checks if admin privileges are available and displays error message if not. + * @returns {Promise} True if running as admin, false otherwise. + */ +async function requireAdminPrivileges() { + if (await isRunningAsAdmin()) { + return true; + } + + ui.writeError("Administrator privileges required."); + ui.writeInformation( + "Please run this command in an elevated terminal (Run as Administrator).", + ); + return false; +} + +async function isRunningAsAdmin() { + // Uses Windows Security API to check if current process has admin privileges. + // Returns "True" or "False" as a string. + const result = await safeSpawn( + "powershell", + [ + "-Command", + "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)", + ], + { stdio: "pipe" }, + ); + + return result.status === 0 && result.stdout.trim() === "True"; +} + +/** + * Returns the MSI product code for SafeChain Ultimate, or null if not installed. + * @returns {string | null} + */ +function getInstalledProductCode() { + // Query Win32_Product via WMI to find the installed SafeChain Agent. + // If found, outputs the product GUID (e.g., "{12345678-1234-...}") needed for msiexec uninstall. + ui.writeVerbose(`Finding product code with PowerShell`); + + let productCode; + try { + productCode = execSync( + `powershell -Command "$app = Get-WmiObject -Class Win32_Product -Filter \\"Name='${WINDOWS_APP_NAME}'\\"; if ($app) { Write-Output $app.IdentifyingNumber }"`, + { encoding: "utf8" }, + ).trim(); + } catch { + return null; + } + return productCode || null; +} + +/** + * @param {string} productCode + */ +async function uninstallByProductCode(productCode) { + ui.writeVerbose(`Found product code: ${productCode}`); + + // Use msiexec to run the msi installer quitely (https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msiexec) + // Options: + // - /x: Uninstalls the package. + // - /qn: Specifies there's no UI during the installation process. + // - /norestart: Stops the device from restarting after the installation completes. + const uninstallResult = await printVerboseAndSafeSpawn( + "msiexec", + ["/x", productCode, "/qn", "/norestart"], + { stdio: "inherit" }, + ); + + if (uninstallResult.status !== 0) { + throw new Error(`Uninstall failed (exit code: ${uninstallResult.status})`); + } +} + +async function uninstallIfInstalled() { + const productCode = getInstalledProductCode(); + if (!productCode) { + ui.writeVerbose("No existing installation found (fresh install)."); + return; + } + + ui.writeInformation("🗑️ Removing previous installation..."); + await uninstallByProductCode(productCode); +} + +/** + * @param {string} msiPath + */ +async function runMsiInstaller(msiPath) { + // Use msiexec to run the msi installer quitely (https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msiexec) + // Options: + // - /i: Specifies normal installation + // - /qn: Specifies there's no UI during the installation process. + + const result = await printVerboseAndSafeSpawn( + "msiexec", + ["/i", msiPath, "/qn"], + { + stdio: "inherit", + }, + ); + + if (result.status !== 0) { + throw new Error(`MSI installer failed (exit code: ${result.status})`); + } +} + +async function stopServiceIfRunning() { + ui.writeInformation("⏹️ Stopping running service..."); + + const result = await printVerboseAndSafeSpawn( + "net", + ["stop", WINDOWS_SERVICE_NAME], + { + stdio: "pipe", + }, + ); + + if (result.status !== 0) { + ui.writeVerbose("Service not running (will start after installation)."); + } +} + +/** + * @param {string} msiPath + */ +function cleanup(msiPath) { + try { + unlinkSync(msiPath); + } catch { + ui.writeVerbose("Failed to clean up temporary installer file."); + } +} diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js new file mode 100644 index 0000000..257c953 --- /dev/null +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -0,0 +1,35 @@ +import { platform } from "os"; +import { ui } from "../environment/userInteraction.js"; +import { initializeCliArguments } from "../config/cliArguments.js"; +import { installOnWindows, uninstallOnWindows } from "./installOnWindows.js"; +import { installOnMacOS, uninstallOnMacOS } from "./installOnMacOS.js"; + +export async function uninstallUltimate() { + initializeCliArguments(process.argv); + + const operatingSystem = platform(); + + if (operatingSystem === "win32") { + await uninstallOnWindows(); + } else if (operatingSystem === "darwin") { + await uninstallOnMacOS(); + } else { + ui.writeInformation( + `Uninstall is not yet supported on ${operatingSystem}.`, + ); + } +} + +export async function installUltimate() { + const operatingSystem = platform(); + + if (operatingSystem === "win32") { + await installOnWindows(); + } else if (operatingSystem === "darwin") { + await installOnMacOS(); + } else { + ui.writeInformation( + `${operatingSystem} is not supported yet by SafeChain's ultimate version.`, + ); + } +} diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 74f8a25..0b37eba 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -64,11 +64,7 @@ export async function main(args) { // Write all buffered logs ui.writeBufferedLogsAndStopBuffering(); - if (proxy.hasBlockedMaliciousPackages()) { - return 1; - } - - if (proxy.hasBlockedMinimumAgeRequests()) { + if (!proxy.verifyNoMaliciousPackages()) { return 1; } @@ -85,7 +81,7 @@ export async function main(args) { ui.writeInformation( `${chalk.yellow( "ℹ", - )} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age.`, + )} Safe-chain: Some package versions were suppressed due to minimum age requirement.`, ); ui.writeInformation( ` To disable this check, use: ${chalk.cyan( diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index bf91d88..af297dc 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -13,10 +13,6 @@ import { createPipPackageManager } from "./pip/createPackageManager.js"; import { createUvPackageManager } from "./uv/createUvPackageManager.js"; import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js"; import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js"; -import { createPdmPackageManager } from "./pdm/createPdmPackageManager.js"; -import { createRushPackageManager } from "./rush/createRushPackageManager.js"; -import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js"; -import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js"; /** * @type {{packageManagerName: PackageManager | null}} @@ -64,18 +60,10 @@ export function initializePackageManager(packageManagerName, context) { state.packageManagerName = createPipPackageManager(context); } else if (packageManagerName === "uv") { state.packageManagerName = createUvPackageManager(); - } else if (packageManagerName === "uvx") { - state.packageManagerName = createUvxPackageManager(); } else if (packageManagerName === "poetry") { state.packageManagerName = createPoetryPackageManager(); } else if (packageManagerName === "pipx") { state.packageManagerName = createPipXPackageManager(); - } else if (packageManagerName === "pdm") { - state.packageManagerName = createPdmPackageManager(); - } else if (packageManagerName === "rush") { - state.packageManagerName = createRushPackageManager(); - } else if (packageManagerName === "rushx") { - state.packageManagerName = createRushxPackageManager(); } else { throw new Error("Unsupported package manager: " + packageManagerName); } 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/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/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/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js index 3918177..3c8790c 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.js +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -1,8 +1,9 @@ import forge from "node-forge"; import path from "path"; import fs from "fs"; -import { getCertsDir } from "../config/safeChainDir.js"; +import os from "os"; +const certFolder = path.join(os.homedir(), ".safe-chain", "certs"); const ca = loadCa(); const certCache = new Map(); @@ -19,7 +20,7 @@ function createKeyIdentifier(publicKey) { } export function getCaCertPath() { - return path.join(getCertsDir(), "ca-cert.pem"); + return path.join(certFolder, "ca-cert.pem"); } /** @@ -111,7 +112,6 @@ export function generateCertForHost(hostname) { } function loadCa() { - const certFolder = getCertsDir(); const keyPath = path.join(certFolder, "ca-key.pem"); const certPath = path.join(certFolder, "ca-cert.pem"); 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/http-utils.js b/packages/safe-chain/src/registryProxy/http-utils.js index 8e2f8e2..e14a977 100644 --- a/packages/safe-chain/src/registryProxy/http-utils.js +++ b/packages/safe-chain/src/registryProxy/http-utils.js @@ -15,66 +15,3 @@ export function getHeaderValueAsString(headers, headerName) { return header; } - -/** - * Returns a copy of headers without the provided header names, matched - * either exactly or case-insensitively. - * - * @param {NodeJS.Dict | 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 index 869af81..79b5200 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +++ b/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js @@ -4,7 +4,7 @@ import { getEcoSystem, } from "../../config/settings.js"; import { npmInterceptorForUrl } from "./npm/npmInterceptor.js"; -import { pipInterceptorForUrl } from "./pip/pipInterceptor.js"; +import { pipInterceptorForUrl } from "./pipInterceptor.js"; /** * @param {string} url diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js index fbfc131..7a844e9 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -10,7 +10,6 @@ import { EventEmitter } from "events"; * @typedef {Object} RequestInterceptionContext * @property {string} targetUrl * @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware - * @property {(packageName: string, version: string, message: string) => void} blockMinimumAgeRequest * @property {(modificationFunc: (headers: NodeJS.Dict) => NodeJS.Dict) => void} modifyRequestHeaders * @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict | undefined) => Buffer) => void} modifyBody * @property {() => RequestInterceptionHandler} build @@ -27,12 +26,6 @@ import { EventEmitter } from "events"; * @property {string} version * @property {string} targetUrl * @property {number} timestamp - * - * @typedef {Object} MinimumAgeRequestBlockedEvent - * @property {string} packageName - * @property {string} version - * @property {string} targetUrl - * @property {number} timestamp */ /** @@ -88,7 +81,10 @@ function createRequestContext(targetUrl, eventEmitter) { * @param {string | undefined} version */ function blockMalwareSetup(packageName, version) { - blockResponse = createBlockResponse("Forbidden - blocked by safe-chain"); + blockResponse = { + statusCode: 403, + message: "Forbidden - blocked by safe-chain", + }; // Emit the malwareBlocked event eventEmitter.emit("malwareBlocked", { @@ -99,34 +95,6 @@ function createRequestContext(targetUrl, eventEmitter) { }); } - /** - * @param {string} message - */ - function blockMinimumAgeRequestSetup( - /** @type {string} */ packageName, - /** @type {string} */ version, - /** @type {string} */ message - ) { - blockResponse = createBlockResponse(message); - eventEmitter.emit("minimumAgeRequestBlocked", { - packageName, - version, - targetUrl, - timestamp: Date.now(), - }); - } - - /** - * @param {string} message - * @returns {{statusCode: number, message: string}} - */ - function createBlockResponse(message) { - return { - statusCode: 403, - message, - }; - } - /** @returns {RequestInterceptionHandler} */ function build() { /** @@ -171,7 +139,6 @@ function createRequestContext(targetUrl, eventEmitter) { return { targetUrl, blockMalware: blockMalwareSetup, - blockMinimumAgeRequest: blockMinimumAgeRequestSetup, modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func), modifyBody: (func) => modifyBodyFuncs.push(func), build, 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 index 26b3b70..14e3ba7 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -1,7 +1,10 @@ -import { getMinimumPackageAgeHours } from "../../../config/settings.js"; +import { getMinimumPackageAgeHours, getNpmMinimumPackageAgeExclusions } from "../../../config/settings.js"; import { ui } from "../../../environment/userInteraction.js"; -import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js"; -import { recordSuppressedVersion } from "../suppressedVersionsState.js"; +import { getHeaderValueAsString } from "../../http-utils.js"; + +const state = { + hasSuppressedVersions: false, +}; /** * @param {NodeJS.Dict} headers @@ -62,6 +65,16 @@ export function modifyNpmInfoResponse(body, headers) { return body; } + // Check if this package is excluded from minimum age filtering + const packageName = bodyJson.name; + const exclusions = getNpmMinimumPackageAgeExclusions(); + if (packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern))) { + ui.writeVerbose( + `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).` + ); + return body; + } + const cutOff = new Date( new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 ); @@ -79,7 +92,15 @@ export function modifyNpmInfoResponse(body, headers) { const timestampValue = new Date(timestamp); if (timestampValue > cutOff) { deleteVersionFromJson(bodyJson, version); - clearCachingHeaders(headers); + if (headers) { + // When modifying the response, the etag and last-modified headers + // no longer match the content so they needs to be removed before sending the response. + delete headers["etag"]; + delete headers["last-modified"]; + // Removing the cache-control header will prevent the package manager from caching + // the modified response. + delete headers["cache-control"]; + } } } @@ -103,7 +124,7 @@ export function modifyNpmInfoResponse(body, headers) { * @param {string} version */ function deleteVersionFromJson(json, version) { - recordSuppressedVersion(); + state.hasSuppressedVersions = true; const packageName = typeof json?.name === "string" ? json.name : "(unknown)"; @@ -161,20 +182,22 @@ function getMostRecentTag(tagList) { } /** - * @param {Buffer} body - * @param {NodeJS.Dict | undefined} headers - * @returns {string | undefined} + * @returns {boolean} */ -export function getPackageNameFromMetadataResponse(body, headers) { - try { - const contentType = getHeaderValueAsString(headers, "content-type"); - if (!contentType?.toLowerCase().includes("application/json")) { - return undefined; - } - - const bodyJson = JSON.parse(body.toString("utf8")); - return typeof bodyJson.name === "string" ? bodyJson.name : undefined; - } catch { - return undefined; - } +export function getHasSuppressedVersions() { + return state.hasSuppressedVersions; +} + +/** + * Checks if a package name matches an exclusion pattern. + * Supports trailing wildcard (*) for prefix matching. + * @param {string} packageName + * @param {string} pattern + * @returns {boolean} + */ +function matchesExclusionPattern(packageName, pattern) { + if (pattern.endsWith("/*")) { + return packageName.startsWith(pattern.slice(0, -1)); + } + return packageName === pattern; } diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index 8caae84..3d3b8b4 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -5,16 +5,11 @@ import { 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", @@ -48,54 +43,14 @@ function buildNpmInterceptor(registry) { reqContext.targetUrl, registry ); - const minimumAgeChecksEnabled = !skipMinimumPackageAge(); if (await isMalwarePackage(packageName, version)) { reqContext.blockMalware(packageName, version); - return; } - if (minimumAgeChecksEnabled && isPackageInfoUrl(reqContext.targetUrl)) { + if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) { reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders); - reqContext.modifyBody(modifyNpmInfoResponseUnlessExcluded); - return; - } - - // For tarball requests the metadata check above is skipped, so we check the - // new packages list as a fallback (covers e.g. frozen-lockfile installs). - if ( - minimumAgeChecksEnabled && - packageName && - version && - !isExcludedFromMinimumPackageAge(packageName) - ) { - const newPackagesDatabase = await openNewPackagesDatabase(); - - if (newPackagesDatabase.isNewlyReleasedPackage(packageName, version)) { - reqContext.blockMinimumAgeRequest( - packageName, - version, - `Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})` - ); - } + reqContext.modifyBody(modifyNpmInfoResponse); } }); } - -/** - * @param {Buffer} body - * @param {NodeJS.Dict | 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 index cdd38ef..834a2ad 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -5,25 +5,13 @@ 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}`), - }), + getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting, }, }); @@ -371,67 +359,6 @@ describe("npmInterceptor minimum package age", async () => { assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0"); }); - it("Should suppress too-young versions on metadata requests without directly blocking the request", async () => { - minimumPackageAgeSettings = 5; - skipMinimumPackageAgeSetting = false; - const packageUrl = "https://registry.npmjs.org/lodash"; - - const interceptor = npmInterceptorForUrl(packageUrl); - const requestHandler = await interceptor.handleRequest(packageUrl); - - assert.equal(requestHandler.blockResponse, undefined); - assert.equal(requestHandler.modifiesResponse(), true); - }); - - it("Should directly block tarball requests when the new packages list marks them as too young", async () => { - minimumPackageAgeSettings = 5; - skipMinimumPackageAgeSetting = false; - newlyReleasedPackages = new Set(["lodash@4.17.21"]); - const packageUrl = - "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123"; - - const interceptor = npmInterceptorForUrl(packageUrl); - const requestHandler = await interceptor.handleRequest(packageUrl); - - assert.ok(requestHandler.blockResponse); - assert.equal(requestHandler.modifiesResponse(), false); - assert.equal(requestHandler.blockResponse.statusCode, 403); - assert.equal( - requestHandler.blockResponse.message, - "Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)" - ); - }); - - it("Should not block tarball requests when skipMinimumPackageAge is enabled", async () => { - minimumPackageAgeSettings = 5; - skipMinimumPackageAgeSetting = true; - minimumPackageAgeExclusionsSetting = []; - newlyReleasedPackages = new Set(["lodash@4.17.21"]); - const packageUrl = - "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"; - - const interceptor = npmInterceptorForUrl(packageUrl); - const requestHandler = await interceptor.handleRequest(packageUrl); - - assert.equal(requestHandler.blockResponse, undefined); - assert.equal(requestHandler.modifiesResponse(), false); - }); - - it("Should not block tarball requests when the package is excluded from minimum age", async () => { - minimumPackageAgeSettings = 5; - skipMinimumPackageAgeSetting = false; - minimumPackageAgeExclusionsSetting = ["lodash"]; - newlyReleasedPackages = new Set(["lodash@4.17.21"]); - const packageUrl = - "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"; - - const interceptor = npmInterceptorForUrl(packageUrl); - const requestHandler = await interceptor.handleRequest(packageUrl); - - assert.equal(requestHandler.blockResponse, undefined); - assert.equal(requestHandler.modifiesResponse(), false); - }); - it("Should not filter packages when package is in exclusion list", async () => { minimumPackageAgeSettings = 5; skipMinimumPackageAgeSetting = false; @@ -613,7 +540,6 @@ describe("npmInterceptor minimum package age", async () => { minimumPackageAgeSettings = 5; skipMinimumPackageAgeSetting = false; minimumPackageAgeExclusionsSetting = []; // Reset to empty - newlyReleasedPackages = new Set(); const packageUrl = "https://registry.npmjs.org/lodash"; 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 index 769b6e1..e1b7c79 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -1,11 +1,9 @@ -import { describe, it, mock, beforeEach } from "node:test"; +import { describe, it, mock } from "node:test"; import assert from "node:assert"; let lastPackage; let malwareResponse = false; let customRegistries = []; -let newlyReleasedPackages = new Set(); -let skipMinimumPackageAgeSetting = false; mock.module("../../../scanning/audit/index.js", { namedExports: { @@ -28,30 +26,14 @@ mock.module("../../../config/settings.js", { setEcoSystem: () => {}, getMinimumPackageAgeHours: () => 24, getNpmCustomRegistries: () => customRegistries, - getMinimumPackageAgeExclusions: () => [], - skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, - }, -}); -mock.module("../../../scanning/newPackagesListCache.js", { - namedExports: { - openNewPackagesDatabase: async () => ({ - isNewlyReleasedPackage: (name, version) => - newlyReleasedPackages.has(`${name}@${version}`), - }), + getNpmMinimumPackageAgeExclusions: () => [], + skipMinimumPackageAge: () => false, }, }); describe("npmInterceptor", async () => { const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); - beforeEach(() => { - lastPackage = undefined; - malwareResponse = false; - customRegistries = []; - newlyReleasedPackages = new Set(); - skipMinimumPackageAgeSetting = false; - }); - const parserCases = [ // Regular packages { @@ -127,10 +109,6 @@ describe("npmInterceptor", async () => { url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz", expected: { packageName: "@babel/core", version: "7.21.4" }, }, - { - url: "https://registry.yarnpkg.com/@music-i18n%2fverovio/-/verovio-1.4.1.tgz", - expected: { packageName: "@music-i18n/verovio", version: "1.4.1" }, - }, // URL to get package info, not tarball { url: "https://registry.npmjs.org/lodash", @@ -200,36 +178,6 @@ describe("npmInterceptor", async () => { "Block response should have correct status message" ); }); - - it("should block direct tarball downloads for newly released packages", async () => { - const url = - "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123"; - malwareResponse = false; - skipMinimumPackageAgeSetting = false; - newlyReleasedPackages = new Set(["lodash@4.17.21"]); - - const interceptor = npmInterceptorForUrl(url); - const result = await interceptor.handleRequest(url); - - assert.ok(result.blockResponse); - assert.equal(result.blockResponse.statusCode, 403); - assert.equal( - result.blockResponse.message, - "Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)" - ); - }); - - it("should not block direct tarball downloads when minimum age checks are skipped", async () => { - const url = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"; - malwareResponse = false; - skipMinimumPackageAgeSetting = true; - newlyReleasedPackages = new Set(["lodash@4.17.21"]); - - const interceptor = npmInterceptorForUrl(url); - const result = await interceptor.handleRequest(url); - - assert.equal(result.blockResponse, undefined); - }); }); describe("npmInterceptor with custom registries", async () => { diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js index 13cb99a..fa256d4 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js @@ -5,29 +5,12 @@ */ export function parseNpmPackageUrl(url, registry) { let packageName, version; - let parsedUrl; - - try { - parsedUrl = new URL(url); - } catch { + if (!registry || !url.endsWith(".tgz")) { return { packageName, version }; } - const pathname = parsedUrl.pathname; - - if (!registry || !pathname.endsWith(".tgz")) { - return { packageName, version }; - } - - const registryPrefix = `${registry}/`; - const urlAfterProtocol = `${parsedUrl.host}${pathname}`; - if (!urlAfterProtocol.startsWith(registryPrefix)) { - return { packageName, version }; - } - - const afterRegistry = decodeURIComponent( - urlAfterProtocol.substring(registryPrefix.length) - ); + const registryIndex = url.indexOf(registry); + const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash const separatorIndex = afterRegistry.indexOf("/-/"); if (separatorIndex === -1) { 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.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/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/pipInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js new file mode 100644 index 0000000..e781e30 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js @@ -0,0 +1,132 @@ +import { getPipCustomRegistries } from "../../config/settings.js"; +import { isMalwarePackage } from "../../scanning/audit/index.js"; +import { interceptRequests } from "./interceptorBuilder.js"; + +const knownPipRegistries = [ + "files.pythonhosted.org", + "pypi.org", + "pypi.python.org", + "pythonhosted.org", +]; + +/** + * @param {string} url + * @returns {import("./interceptorBuilder.js").Interceptor | undefined} + */ +export function pipInterceptorForUrl(url) { + const 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(async (reqContext) => { + const { packageName, version } = parsePipPackageFromUrl( + reqContext.targetUrl, + registry + ); + + // Normalize underscores to hyphens for DB matching, as PyPI allows underscores in distribution names. + // Per python, packages that differ only by hyphen vs underscore are considered the same. + const hyphenName = packageName?.includes("_") ? packageName.replace(/_/g, "-") : packageName; + + const isMalicious = + await isMalwarePackage(packageName, version) + || await isMalwarePackage(hyphenName, version); + + if (isMalicious) { + reqContext.blockMalware(packageName, version); + } + }); +} + +/** + * @param {string} url + * @param {string} registry + * @returns {{packageName: string | undefined, version: string | undefined}} + */ +function parsePipPackageFromUrl(url, registry) { + let packageName, version; + + // Basic validation + if (!registry || typeof url !== "string") { + return { packageName, version }; + } + + // Quick sanity check on the URL + parse + let urlObj; + try { + urlObj = new URL(url); + } catch { + return { packageName, version }; + } + + // Get the last path segment (filename) and decode it (strip query & fragment automatically) + const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop(); + if (!lastSegment) { + return { packageName, version }; + } + + const filename = decodeURIComponent(lastSegment); + + // Parse Python package downloads from PyPI/pythonhosted.org + // Example wheel: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1-py3-none-any.whl + // Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz + + // Wheel (.whl) and Poetry's preflight metadata (.whl.metadata) + // Examples: + // foo_bar-2.0.0-py3-none-any.whl + // foo_bar-2.0.0-py3-none-any.whl.metadata + const wheelExtRe = /\.whl(?:\.metadata)?$/; + const wheelExtMatch = filename.match(wheelExtRe); + if (wheelExtMatch) { + const base = filename.replace(wheelExtRe, ""); + const firstDash = base.indexOf("-"); + if (firstDash > 0) { + const dist = base.slice(0, firstDash); // may contain underscores + const rest = base.slice(firstDash + 1); // version + the rest of tags + const secondDash = rest.indexOf("-"); + const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest; + packageName = dist; + version = rawVersion; + // Reject "latest" as it's a placeholder, not a real version + // When version is "latest", this signals the URL doesn't contain actual version info + // Returning undefined allows the request (see registryProxy.js isAllowedUrl) + if (version === "latest" || !packageName || !version) { + return { packageName: undefined, version: undefined }; + } + return { packageName, version }; + } + } + + // Source dist (sdist) and potential metadata sidecars (e.g., .tar.gz.metadata) + const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i; + const sdistExtMatch = filename.match(sdistExtWithMetadataRe); + if (sdistExtMatch) { + const base = filename.replace(sdistExtWithMetadataRe, ""); + const lastDash = base.lastIndexOf("-"); + if (lastDash > 0 && lastDash < base.length - 1) { + packageName = base.slice(0, lastDash); + version = base.slice(lastDash + 1); + // Reject "latest" as it's a placeholder, not a real version + // When version is "latest", this signals the URL doesn't contain actual version info + // Returning undefined allows the request (see registryProxy.js isAllowedUrl) + if (version === "latest" || !packageName || !version) { + return { packageName: undefined, version: undefined }; + } + return { packageName, version }; + } + } + // Unknown file type or invalid + return { packageName: undefined, version: undefined }; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js similarity index 65% rename from packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js rename to packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js index 5904f05..fc9c91e 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js @@ -2,36 +2,20 @@ import { describe, it, mock } from "node:test"; import assert from "node:assert"; describe("pipInterceptor custom registries", async () => { - let scannedPackages; + let lastPackage; let malwareResponse = false; let customRegistries = []; - mock.module("../../../config/settings.js", { + 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", { + mock.module("../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { - scannedPackages.push({ packageName, version }); + lastPackage = { packageName, version }; return malwareResponse; }, }, @@ -46,45 +30,42 @@ describe("pipInterceptor custom registries", async () => { const interceptor = pipInterceptorForUrl(url); - assert.ok(interceptor); + assert.ok( + interceptor, + "Interceptor should be created for custom registry" + ); }); 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); + assert.ok(interceptor, "Interceptor should be created"); await interceptor.handleRequest(url); - assert.ok( - scannedPackages.some( - ({ packageName, version }) => - packageName === "foobar" && version === "1.2.3" - ) - ); + assert.deepEqual(lastPackage, { + 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); + assert.ok(interceptor, "Interceptor should be created"); await interceptor.handleRequest(url); - assert.ok( - scannedPackages.some( - ({ packageName, version }) => - packageName === "foo-bar" && version === "2.0.0" - ) - ); + assert.deepEqual(lastPackage, { + packageName: "foo-bar", + version: "2.0.0", + }); }); it("should handle multiple custom registries", async () => { @@ -101,12 +82,14 @@ describe("pipInterceptor custom registries", async () => { const interceptor1 = pipInterceptorForUrl(url1); const interceptor2 = pipInterceptorForUrl(url2); - assert.ok(interceptor1); - assert.ok(interceptor2); + assert.ok(interceptor1, "Interceptor should be created for first registry"); + assert.ok( + interceptor2, + "Interceptor should be created for second registry" + ); }); it("should block malicious package from custom registry", async () => { - scannedPackages = []; customRegistries = ["my-custom-registry.example.com"]; malwareResponse = true; @@ -114,19 +97,26 @@ describe("pipInterceptor custom registries", async () => { "https://my-custom-registry.example.com/packages/malicious_package-1.0.0.tar.gz"; const interceptor = pipInterceptorForUrl(url); - assert.ok(interceptor); + assert.ok(interceptor, "Interceptor should be created"); 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"); + 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" + ); malwareResponse = false; }); it("should still work with known registries when custom registries are set", async () => { - scannedPackages = []; customRegistries = ["my-custom-registry.example.com"]; const url = @@ -134,16 +124,17 @@ describe("pipInterceptor custom registries", async () => { const interceptor = pipInterceptorForUrl(url); - assert.ok(interceptor); + assert.ok( + interceptor, + "Interceptor should be created for known registry even with custom registries set" + ); await interceptor.handleRequest(url); - assert.ok( - scannedPackages.some( - ({ packageName, version }) => - packageName === "foobar" && version === "1.2.3" - ) - ); + assert.deepEqual(lastPackage, { + packageName: "foobar", + version: "1.2.3", + }); }); it("should not create interceptor for unknown registry when custom registries are set", () => { @@ -152,7 +143,11 @@ describe("pipInterceptor custom registries", async () => { const interceptor = pipInterceptorForUrl(url); - assert.equal(interceptor, undefined); + assert.equal( + interceptor, + undefined, + "Interceptor should be undefined for unknown registry" + ); }); it("should handle empty custom registries array", () => { @@ -162,44 +157,43 @@ describe("pipInterceptor custom registries", async () => { const interceptor = pipInterceptorForUrl(url); - assert.equal(interceptor, undefined); + assert.equal( + interceptor, + undefined, + "Interceptor should be undefined when no custom registries are configured" + ); }); 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); + assert.ok(interceptor, "Interceptor should be created"); await interceptor.handleRequest(url); - assert.ok( - scannedPackages.some( - ({ packageName, version }) => - packageName === "foo-bar" && version === "2.0.0" - ) - ); + assert.deepEqual(lastPackage, { + 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); + assert.ok(interceptor, "Interceptor should be created"); await interceptor.handleRequest(url); - assert.ok( - scannedPackages.some( - ({ packageName, version }) => - packageName === "foo-bar" && version === "2.0.0" - ) - ); + assert.deepEqual(lastPackage, { + packageName: "foo-bar", + version: "2.0.0", + }); }); }); + diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js similarity index 74% rename from packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js rename to packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js index f4a54a4..482a800 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js @@ -2,43 +2,22 @@ import { describe, it, mock } from "node:test"; import assert from "node:assert"; describe("pipInterceptor", async () => { - let scannedPackages; + let lastPackage; let malwareResponse = false; - mock.module("../../../scanning/audit/index.js", { + mock.module("../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { - scannedPackages.push({ packageName, version }); + lastPackage = { packageName, version }; return malwareResponse; }, }, }); - mock.module("../../../scanning/newPackagesListCache.js", { - namedExports: { - openNewPackagesDatabase: async () => ({ - isNewlyReleasedPackage: () => false, - }), - }, - }); - - mock.module("../../../config/settings.js", { - namedExports: { - ECOSYSTEM_PY: "py", - getEcoSystem: () => "py", - getLoggingLevel: () => "silent", - getMinimumPackageAgeHours: () => 48, - getMinimumPackageAgeExclusions: () => [], - getPipCustomRegistries: () => [], - LOGGING_SILENT: "silent", - LOGGING_VERBOSE: "verbose", - skipMinimumPackageAge: () => false, - }, - }); - const { pipInterceptorForUrl } = await import("./pipInterceptor.js"); const parserCases = [ + // Valid pip URLs { url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz", expected: { packageName: "foobar", version: "1.2.3" }, @@ -56,6 +35,7 @@ describe("pipInterceptor", async () => { expected: { packageName: "foo-bar", version: "2.0.0" }, }, { + // Poetry preflight metadata alongside wheel (.whl.metadata) url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl.metadata", expected: { packageName: "foo-bar", version: "2.0.0" }, }, @@ -72,6 +52,7 @@ describe("pipInterceptor", async () => { expected: { packageName: "foo-bar", version: "2.0.0b1" }, }, { + // sdist with metadata sidecar (.tar.gz.metadata) url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz.metadata", expected: { packageName: "foo-bar", version: "2.0.0" }, }, @@ -95,6 +76,7 @@ describe("pipInterceptor", async () => { url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl", expected: { packageName: "foo-bar", version: "2.0.0" }, }, + // Invalid pip URLs { url: "https://pypi.org/simple/", expected: { packageName: undefined, version: undefined }, @@ -115,49 +97,49 @@ describe("pipInterceptor", async () => { parserCases.forEach(({ url, expected }, index) => { it(`should parse URL ${index + 1}: ${url}`, async () => { - scannedPackages = []; const interceptor = pipInterceptorForUrl(url); - assert.ok(interceptor, "Interceptor should be created for known pip registry"); + assert.ok( + interceptor, + "Interceptor should be created for known npm registry" + ); await interceptor.handleRequest(url); - if (expected.packageName === undefined) { - assert.deepEqual(scannedPackages, []); - return; - } - - assert.ok( - scannedPackages.some( - ({ packageName, version }) => - packageName === expected.packageName && - version === expected.version - ) - ); + assert.deepEqual(lastPackage, expected); }); }); it("should not create interceptor for unknown registry", () => { const url = "https://example.com/packages/xx/yy/foobar-1.2.3.tar.gz"; + const interceptor = pipInterceptorForUrl(url); - assert.equal(interceptor, undefined); + + assert.equal( + interceptor, + undefined, + "Interceptor should be undefined for unknown registry" + ); }); it("should block malicious package", async () => { - scannedPackages = []; const url = "https://files.pythonhosted.org/packages/xx/yy/malicious_package-1.0.0.tar.gz"; malwareResponse = true; const interceptor = pipInterceptorForUrl(url); + const result = await interceptor.handleRequest(url); - assert.ok(result.blockResponse); - assert.equal(result.blockResponse.statusCode, 403); + assert.ok(result.blockResponse, "Should contain a blockResponse"); + assert.equal( + result.blockResponse.statusCode, + 403, + "Block response should have status code 403" + ); assert.equal( result.blockResponse.message, - "Forbidden - blocked by safe-chain" + "Forbidden - blocked by safe-chain", + "Block response should have correct status message" ); - - malwareResponse = false; }); }); 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/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 4c4e9ec..8268559 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -2,8 +2,7 @@ import https from "https"; import { generateCertForHost } from "./certUtils.js"; import { HttpsProxyAgent } from "https-proxy-agent"; import { ui } from "../environment/userInteraction.js"; -import { gunzipSync } from "zlib"; -import { omitHeaders } from "./http-utils.js"; +import { gunzipSync, gzipSync } from "zlib"; /** * @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor @@ -216,16 +215,11 @@ function createProxyRequest(hostname, port, req, res, requestHandler) { buffer = requestHandler.modifyBody(buffer, headers); - // For rewritten responses, send the final body uncompressed. - // This avoids mismatches between upstream compression metadata and the - // rewritten payload on the wire. - const rewrittenHeaders = omitHeaders( - headers, - ["content-length", "transfer-encoding", "content-encoding"], - { caseInsensitive: true } - ) || {}; - rewrittenHeaders["content-length"] = String(buffer.byteLength); - res.writeHead(statusCode, rewrittenHeaders); + if (proxyRes.headers["content-encoding"] === "gzip") { + buffer = gzipSync(buffer); + } + + res.writeHead(statusCode, headers); res.end(buffer); }); } else { 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/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 694c72c..2de776e 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -6,20 +6,15 @@ import { getCombinedCaBundlePath, cleanupCertBundle } from "./certBundle.js"; import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; -import { getHasSuppressedVersions } from "./interceptors/suppressedVersionsState.js"; +import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; const SERVER_STOP_TIMEOUT_MS = 1000; /** - * @type {{ - * port: number | null, - * blockedRequests: {packageName: string, version: string, url: string}[], - * blockedMinimumAgeRequests: {packageName: string, version: string, url: string}[] - * }} + * @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}} */ const state = { port: null, blockedRequests: [], - blockedMinimumAgeRequests: [], }; export function createSafeChainProxy() { @@ -28,8 +23,7 @@ export function createSafeChainProxy() { return { startServer: () => startServer(server), stopServer: () => stopServer(server), - hasBlockedMaliciousPackages, - hasBlockedMinimumAgeRequests, + verifyNoMaliciousPackages, hasSuppressedVersions: getHasSuppressedVersions, }; } @@ -42,7 +36,7 @@ function getSafeChainProxyEnvironmentVariables() { return {}; } - const proxyUrl = `http://127.0.0.1:${state.port}`; + const proxyUrl = `http://localhost:${state.port}`; const caCertPath = getCombinedCaBundlePath(); return { @@ -95,11 +89,8 @@ function createProxyServer() { */ function startServer(server) { return new Promise((resolve, reject) => { - // Bind to loopback only. Without an explicit host, Node listens on every - // interface, turning the proxy into an unauthenticated forward proxy that - // anyone reachable on the network can use to hit the victim's localhost, - // intranet, or cloud metadata endpoints. Port 0 lets the OS pick a port. - server.listen(0, "127.0.0.1", () => { + // Passing port 0 makes the OS assign an available port + server.listen(0, () => { const address = server.address(); if (address && typeof address === "object") { state.port = address.port; @@ -160,18 +151,6 @@ function handleConnect(req, clientSocket, head) { onMalwareBlocked(event.packageName, event.version, event.targetUrl); } ); - interceptor.on( - "minimumAgeRequestBlocked", - ( - /** @type {import("./interceptors/interceptorBuilder.js").MinimumAgeRequestBlockedEvent} */ event - ) => { - onMinimumAgeRequestBlocked( - event.packageName, - event.version, - event.targetUrl - ); - } - ); mitmConnect(req, clientSocket, interceptor); } else { @@ -191,19 +170,10 @@ function onMalwareBlocked(packageName, version, url) { state.blockedRequests.push({ packageName, version, url }); } -/** - * - * @param {string} packageName - * @param {string} version - * @param {string} url - */ -function onMinimumAgeRequestBlocked(packageName, version, url) { - state.blockedMinimumAgeRequests.push({ packageName, version, url }); -} - -function hasBlockedMaliciousPackages() { +function verifyNoMaliciousPackages() { if (state.blockedRequests.length === 0) { - return false; + // No malicious packages were blocked, so nothing to block + return true; } ui.emptyLine(); @@ -222,37 +192,5 @@ function hasBlockedMaliciousPackages() { ui.writeExitWithoutInstallingMaliciousPackages(); ui.emptyLine(); - return true; -} - -function hasBlockedMinimumAgeRequests() { - if (state.blockedMinimumAgeRequests.length === 0) { - return false; - } - - ui.emptyLine(); - - ui.writeInformation( - `Safe-chain: ${chalk.bold( - `blocked ${state.blockedMinimumAgeRequests.length} direct package download request(s) due to minimum package age` - )}:` - ); - - for (const req of state.blockedMinimumAgeRequests) { - ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`); - } - - ui.writeInformation( - ` To disable this check, use: ${chalk.cyan( - "--safe-chain-skip-minimum-package-age" - )}` - ); - - ui.emptyLine(); - ui.writeError( - "Safe-chain: Exiting without installing packages blocked by the direct download minimum package age check." - ); - ui.emptyLine(); - - return true; + 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/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index 0eccc88..4aba43c 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -15,12 +15,8 @@ import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js"; * @property {function(string, string): boolean} isMalware */ -// Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved -// value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields -// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all -// concurrent callers see it immediately and share a single fetch. -/** @type {Promise | null} */ -let cachedMalwareDatabasePromise = null; +/** @type {MalwareDatabase | null} */ +let cachedMalwareDatabase = null; /** * Normalize package name for comparison. @@ -38,44 +34,45 @@ function normalizePackageName(name) { return name; } -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 === "*"); - } - ); - - 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; - }); +export async function openMalwareDatabase() { + if (cachedMalwareDatabase) { + return cachedMalwareDatabase; } - return cachedMalwareDatabasePromise; + + const malwareDatabase = await getMalwareDatabase(); + + /** + * @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 === "*"); + } + ); + + if (!packageData) { + return MALWARE_STATUS_OK; + } + + return packageData.reason; + } + + // This implicitly 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; } /** 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..18ba52e 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -48,18 +48,6 @@ export const knownAikidoTools = [ ecoSystem: ECOSYSTEM_JS, internalPackageManagerName: "pnpx", }, - { - tool: "rush", - aikidoCommand: "aikido-rush", - ecoSystem: ECOSYSTEM_JS, - internalPackageManagerName: "rush", - }, - { - tool: "rushx", - aikidoCommand: "aikido-rushx", - ecoSystem: ECOSYSTEM_JS, - internalPackageManagerName: "rushx", - }, { tool: "bun", aikidoCommand: "aikido-bun", @@ -78,12 +66,6 @@ export const knownAikidoTools = [ ecoSystem: ECOSYSTEM_PY, internalPackageManagerName: "uv", }, - { - tool: "uvx", - aikidoCommand: "aikido-uvx", - ecoSystem: ECOSYSTEM_PY, - internalPackageManagerName: "uvx", - }, { tool: "pip", aikidoCommand: "aikido-pip", @@ -120,12 +102,6 @@ export const knownAikidoTools = [ ecoSystem: ECOSYSTEM_PY, internalPackageManagerName: "pipx", }, - { - tool: "pdm", - aikidoCommand: "aikido-pdm", - ecoSystem: ECOSYSTEM_PY, - internalPackageManagerName: "pdm", - }, // When adding a new tool here, also update the documentation for the new tool in the README.md ]; @@ -145,6 +121,20 @@ export function getPackageManagerList() { return `${tools.join(", ")}, and ${lastTool} commands`; } +/** + * @returns {string} + */ +export function getShimsDir() { + return path.join(os.homedir(), ".safe-chain", "shims"); +} + +/** + * @returns {string} + */ +export function getScriptsDir() { + return path.join(os.homedir(), ".safe-chain", "scripts"); +} + /** * @param {string} executableName * 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..d6c9efd 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 + # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@" else - # safe-chain is not reachable — warn the user so they know protection is inactive - 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 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..082d553 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,8 +3,7 @@ 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 @@ -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..762bd9b 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -1,14 +1,24 @@ 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 { getPackageManagerList, knownAikidoTools, getShimsDir } from "./helpers.js"; import fs from "fs"; import os from "os"; import path from "path"; +import { fileURLToPath } from "url"; + +/** @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; +} /** * Loops over the detected shells and calls the setup function for each. @@ -21,7 +31,7 @@ export async function setupCi() { ui.emptyLine(); const shimsDir = getShimsDir(); - const binDir = getBinDir(); + const binDir = path.join(os.homedir(), ".safe-chain", "bin"); // Create the shims directory if it doesn't exist if (!fs.existsSync(shimsDir)) { fs.mkdirSync(shimsDir, { recursive: true }); @@ -40,7 +50,12 @@ export async function setupCi() { */ function createUnixShims(shimsDir) { // Read the template file - const templatePath = getPathWrapperTemplatePath(import.meta.url, "unix-wrapper.template.sh"); + const templatePath = path.resolve( + dirname, + "path-wrappers", + "templates", + "unix-wrapper.template.sh" + ); if (!fs.existsSync(templatePath)) { ui.writeError(`Template file not found: ${templatePath}`); @@ -74,7 +89,12 @@ function createUnixShims(shimsDir) { */ function createWindowsShims(shimsDir) { // Read the template file - const templatePath = getPathWrapperTemplatePath(import.meta.url, "windows-wrapper.template.cmd"); + const templatePath = path.resolve( + dirname, + "path-wrappers", + "templates", + "windows-wrapper.template.cmd" + ); if (!fs.existsSync(templatePath)) { ui.writeError(`Windows template file not found: ${templatePath}`); 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..b437157 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" ); @@ -50,15 +50,7 @@ describe("Setup CI shell integration", () => { { tool: "yarn", aikidoCommand: "aikido-yarn" }, ], getPackageManagerList: () => "npm, yarn", - }, - }); - - 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), }, }); @@ -71,6 +63,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 +119,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,10 +142,6 @@ 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"); diff --git a/packages/safe-chain/src/shell-integration/setup.js b/packages/safe-chain/src/shell-integration/setup.js index 04534df..4138db6 100644 --- a/packages/safe-chain/src/shell-integration/setup.js +++ b/packages/safe-chain/src/shell-integration/setup.js @@ -1,10 +1,28 @@ 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 { + knownAikidoTools, + getPackageManagerList, + getScriptsDir, +} from "./helpers.js"; import fs from "fs"; import path from "path"; +import { fileURLToPath } from "url"; + +/** @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; +} /** * Loops over the detected shells and calls the setup function for each. @@ -73,7 +91,9 @@ async function setupShell(shell) { ); } 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 +102,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; @@ -104,7 +118,8 @@ function copyStartupFiles() { fs.mkdirSync(targetDir, { recursive: true }); } - const sourcePath = getStartupScriptSourcePath(import.meta.url, file); + // Use absolute path for source + const sourcePath = path.join(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..996125c 100644 --- a/packages/safe-chain/src/shell-integration/shellDetection.js +++ b/packages/safe-chain/src/shell-integration/shellDetection.js @@ -11,8 +11,6 @@ import { ui } from "../environment/userInteraction.js"; * @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 */ /** 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..13463f6 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,7 +1,4 @@ -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 +set -gx PATH $PATH $HOME/.safe-chain/bin function npx wrapSafeChainCommand "npx" $argv @@ -19,14 +16,6 @@ function pnpx wrapSafeChainCommand "pnpx" $argv end -function rush - wrapSafeChainCommand "rush" $argv -end - -function rushx - wrapSafeChainCommand "rushx" $argv -end - function bun wrapSafeChainCommand "bun" $argv end @@ -62,10 +51,6 @@ function uv wrapSafeChainCommand "uv" $argv end -function uvx - wrapSafeChainCommand "uvx" $argv -end - function poetry wrapSafeChainCommand "poetry" $argv end @@ -84,10 +69,6 @@ function pipx wrapSafeChainCommand "pipx" $argv end -function pdm - wrapSafeChainCommand "pdm" $argv -end - function printSafeChainWarning set original_cmd $argv[1] @@ -124,10 +105,8 @@ function wrapSafeChainCommand 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 + # If the safe-chain command is available, just run it with the provided arguments + safe-chain $original_cmd $cmd_args else # If the safe-chain command is not available, print a warning and run the original command printSafeChainWarning $original_cmd 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..ebaaf3c 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,16 +1,4 @@ -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 +export PATH="$PATH:$HOME/.safe-chain/bin" function npx() { wrapSafeChainCommand "npx" "$@" @@ -28,14 +16,6 @@ function pnpx() { wrapSafeChainCommand "pnpx" "$@" } -function rush() { - wrapSafeChainCommand "rush" "$@" -} - -function rushx() { - wrapSafeChainCommand "rushx" "$@" -} - function bun() { wrapSafeChainCommand "bun" "$@" } @@ -67,10 +47,6 @@ function uv() { wrapSafeChainCommand "uv" "$@" } -function uvx() { - wrapSafeChainCommand "uvx" "$@" -} - function poetry() { wrapSafeChainCommand "poetry" "$@" } @@ -89,10 +65,6 @@ function pipx() { wrapSafeChainCommand "pipx" "$@" } -function pdm() { - wrapSafeChainCommand "pdm" "$@" -} - function printSafeChainWarning() { # \033[43;30m is used to set the background color to yellow and text color to black # \033[0m is used to reset the text formatting @@ -113,10 +85,8 @@ function wrapSafeChainCommand() { fi if command -v safe-chain > /dev/null 2>&1; then - # If the aikido command is available, just run it with the provided arguments. - # 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 the aikido command is available, just run it with the provided arguments + safe-chain "$@" else # If the aikido command is not available, print a warning and run the original command printSafeChainWarning "$original_cmd" 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..f82d0fc 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 @@ -2,8 +2,7 @@ # $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' +$safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin' $env:PATH = "$env:PATH$pathSeparator$safeChainBin" function npx { @@ -22,14 +21,6 @@ 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 } @@ -61,10 +52,6 @@ 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 } @@ -83,10 +70,6 @@ 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) 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..07d89cb 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"; @@ -34,10 +32,10 @@ function teardown(tools) { ); } - // Remove sourcing line to disable safe-chain shell integration + // Removes the line that sources the safe-chain bash initialization script (~/.safe-chain/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 +44,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 ); @@ -97,51 +94,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 }); @@ -171,44 +123,6 @@ 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} */ @@ -217,6 +131,4 @@ export default { 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..0af6ae3 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"; @@ -33,10 +31,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 +46,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 ); @@ -68,22 +66,6 @@ function getStartupFile() { } } -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} */ @@ -92,6 +74,4 @@ export default { 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..96eb219 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -4,9 +4,7 @@ import { 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"; @@ -32,10 +30,10 @@ function teardown(tools) { ); } - // 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; @@ -54,7 +52,7 @@ async function setup() { 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; @@ -73,22 +71,6 @@ function getStartupFile() { } } -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} */ @@ -97,6 +79,4 @@ export default { 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..de2c14b 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 @@ -43,12 +43,6 @@ describe("PowerShell Core shell integration", () => { }, }); - mock.module("../../config/safeChainDir.js", { - namedExports: { - getScriptsDir: () => "/test-home/.safe-chain/scripts", - }, - }); - // Mock child_process execSync mock.module("child_process", { namedExports: { @@ -89,7 +83,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" # Safe-chain PowerShell initialization script', + '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', ), ); }); @@ -99,7 +93,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 +105,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 ")); @@ -186,14 +180,14 @@ describe("PowerShell Core shell integration", () => { await 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"'), ); }); @@ -204,7 +198,7 @@ describe("PowerShell Core shell integration", () => { 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"); 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..2740456 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -4,9 +4,7 @@ import { 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"; @@ -32,10 +30,10 @@ function teardown(tools) { ); } - // 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; @@ -54,7 +52,7 @@ async function setup() { 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; @@ -73,22 +71,6 @@ function getStartupFile() { } } -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} */ @@ -97,6 +79,4 @@ export default { 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..561d0d4 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 @@ -43,12 +43,6 @@ describe("Windows PowerShell shell integration", () => { }, }); - mock.module("../../config/safeChainDir.js", { - namedExports: { - getScriptsDir: () => "/test-home/.safe-chain/scripts", - }, - }); - // Mock child_process execSync mock.module("child_process", { namedExports: { @@ -89,7 +83,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" # Safe-chain PowerShell initialization script', + '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', ), ); }); @@ -99,7 +93,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 +105,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 ")); @@ -186,14 +180,14 @@ describe("Windows PowerShell shell integration", () => { await 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"'), ); }); @@ -204,7 +198,7 @@ describe("Windows PowerShell shell integration", () => { 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"); 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..6086095 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"; @@ -33,10 +31,10 @@ function teardown(tools) { ); } - // Remove sourcing line to complete shell integration cleanup + // Removes the line that sources the safe-chain zsh initialization script (~/.safe-chain/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 +46,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 ); @@ -68,27 +66,9 @@ function getStartupFile() { } } -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..de3fbd7 100644 --- a/packages/safe-chain/src/shell-integration/teardown.js +++ b/packages/safe-chain/src/shell-integration/teardown.js @@ -1,8 +1,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 { knownAikidoTools, getPackageManagerList, getShimsDir, getScriptsDir } from "./helpers.js"; import fs from "fs"; /** @@ -48,14 +47,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(); } } @@ -110,5 +103,4 @@ export async function teardownDirectories() { ); } } - } diff --git a/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js b/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js new file mode 100644 index 0000000..e333615 --- /dev/null +++ b/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js @@ -0,0 +1,111 @@ +import { platform } from 'os'; +import { ui } from "../environment/userInteraction.js"; +import { readFileSync, existsSync } from "node:fs"; +import {randomUUID} from "node:crypto"; +import {createWriteStream} from "fs"; +import archiver from 'archiver'; +import path from "node:path"; + +export async function printUltimateLogs() { + const { proxyLogPath, ultimateLogPath, proxyErrLogPath, ultimateErrLogPath } = getPathsPerPlatform(); + + await printLogs( + "SafeChain Proxy", + proxyLogPath, + proxyErrLogPath + ); + + await printLogs( + "SafeChain Ultimate", + ultimateLogPath, + ultimateErrLogPath + ); +} + +export async function troubleshootingExport() { + const { logDir } = getPathsPerPlatform(); + return new Promise((resolve, reject) => { + if (!existsSync(logDir)) { + ui.writeError(`Log directory not found: ${logDir}`); + reject(new Error(`Log directory not found: ${logDir}`)); + return; + } + + const date = new Date().toISOString().split('T')[0]; + const uuid = randomUUID(); + const zipFileName = `safechain-ultimate-${date}-${uuid}.zip`; + const output = createWriteStream(zipFileName); + const archive = archiver('zip', { zlib: { level: 9 } }); + + output.on('close', () => { + ui.writeInformation(`Logs collected and zipped as: ${path.resolve(zipFileName)}`); + resolve(zipFileName); + }); + + archive.on('error', (err) => { + ui.writeError(`Failed to zip logs: ${err.message}`); + reject(err); + }); + + archive.pipe(output); + archive.directory(logDir, false); + archive.finalize(); + }); +} + + +function getPathsPerPlatform() { + const os = platform(); + if (os === 'win32') { + const logDir = `C:\\ProgramData\\AikidoSecurity\\SafeChainUltimate\\logs`; + return { + logDir, + proxyLogPath: `${logDir}\\SafeChainProxy.log`, + ultimateLogPath: `${logDir}\\SafeChainUltimate.log`, + proxyErrLogPath: `${logDir}\\SafeChainProxy.err`, + ultimateErrLogPath: `${logDir}\\SafeChainUltimate.err`, + }; + } else if (os === 'darwin') { + const logDir = `/Library/Logs/AikidoSecurity/SafeChainUltimate`; + return { + logDir, + proxyLogPath: `${logDir}/safechain-proxy.log`, + ultimateLogPath: `${logDir}/safechain-ultimate.log`, + proxyErrLogPath: `${logDir}/safechain-proxy.error.log`, + ultimateErrLogPath: `${logDir}/safechain-ultimate.error.log`, + }; + } else { + throw new Error('Unsupported platform for log printing.'); + } +} + +/** + * @param {string} appName + * @param {string} logPath + * @param {string} errLogPath + */ +async function printLogs(appName, logPath, errLogPath) { + ui.writeInformation(`=== ${appName} Logs ===`); + try { + if (existsSync(logPath)) { + const logs = readFileSync(logPath, "utf-8"); + ui.writeInformation(logs); + } else { + ui.writeWarning(`${appName} log file not found: ${logPath}`); + } + } catch (error) { + ui.writeError(`Failed to read ${appName} logs: ${error}`); + } + + ui.writeInformation(`=== ${appName} Error Logs ===`); + try { + if (existsSync(errLogPath)) { + const errLogs = readFileSync(errLogPath, "utf-8"); + ui.writeInformation(errLogs); + } else { + ui.writeInformation(`No error log file found for ${appName}.`); + } + } catch (error) { + ui.writeError(`Failed to read ${appName} error logs: ${error}`); + } +} diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index 543b1a3..95a467c 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -58,21 +58,12 @@ 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"); @@ -93,14 +84,10 @@ export class DockerTestContainer { } } - 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, @@ -134,7 +121,7 @@ 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}"`); + console.log("Command timeout reached"); resolve({ allData, output: parseShellOutput(allData), command }); ptyProcess.removeListener("data", handleInput); }, 15000); diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index 290922d..bc7ffc2 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -25,7 +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"] @@ -47,7 +46,6 @@ 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 @@ -79,10 +77,6 @@ RUN apt-get update && apt-get install -y pipx && \ pipx install poetry && \ ln -sf /root/.local/bin/poetry /usr/local/bin/poetry -# Install PDM -RUN pipx install pdm && \ - ln -sf /root/.local/bin/pdm /usr/local/bin/pdm - # Copy and install Safe chain COPY --from=builder /app/*.tgz /pkgs/ RUN npm install -g /pkgs/*.tgz @@ -90,5 +84,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..044b300 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -29,7 +29,7 @@ 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" + "bun i axios --safe-chain-logging=verbose" ); assert.ok( @@ -46,9 +46,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 +65,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 index 9c5102b..4b4ad84 100644 --- a/test/e2e/certbundle.e2e.spec.js +++ b/test/e2e/certbundle.e2e.spec.js @@ -32,7 +32,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { // 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"); + const result = await shell.runCommand("npm install axios"); assert.ok( result.output.includes("added") || result.output.includes("up to date"), @@ -55,7 +55,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { // 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" + "NODE_EXTRA_CA_CERTS=/tmp/valid-certs.pem npm install axios" ); assert.ok( @@ -69,7 +69,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { // 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' + 'export NODE_EXTRA_CA_CERTS="/tmp/nonexistent-certs.pem" && npm install axios' ); // Should still succeed - safe-chain should gracefully handle missing user certs @@ -95,7 +95,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { // 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' + 'export NODE_EXTRA_CA_CERTS="/tmp/invalid-certs.pem" && npm install axios' ); // Should still succeed - safe-chain should skip invalid user certs @@ -116,7 +116,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { // 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' + 'export NODE_EXTRA_CA_CERTS="/tmp/../../../etc/passwd" && npm install axios' ); // Should still succeed - safe-chain should reject path traversal @@ -133,7 +133,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { 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' + 'export NODE_EXTRA_CA_CERTS="/tmp/empty-certs.pem" && npm install axios' ); // Should still succeed - empty file should be ignored gracefully @@ -150,7 +150,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { 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' + 'export NODE_EXTRA_CA_CERTS="/tmp/cert-dir" && npm install axios' ); // Should still succeed - directory should be treated as invalid cert file @@ -169,7 +169,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { ); const result = await shell.runCommand( - 'cd /tmp/cert-test && export NODE_EXTRA_CA_CERTS="./certs.pem" && npm install axios@1.13.0' + 'cd /tmp/cert-test && export NODE_EXTRA_CA_CERTS="./certs.pem" && npm install axios' ); // Should still succeed - relative paths should be resolved properly @@ -186,7 +186,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { 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" + "NODE_EXTRA_CA_CERTS=/tmp/absolute-certs.pem npm install axios" ); assert.ok( @@ -202,7 +202,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { 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" + "NODE_EXTRA_CA_CERTS=/tmp/merge-certs.pem npm install axios lodash" ); assert.ok( @@ -306,7 +306,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { 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" + "NODE_EXTRA_CA_CERTS=/tmp/yarn-certs.pem yarn add axios" ); assert.ok( @@ -322,7 +322,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { 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" + "NODE_EXTRA_CA_CERTS=/tmp/pnpm-certs.pem pnpm add axios" ); assert.ok( @@ -336,7 +336,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { // 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" + "cp /etc/ssl/certs/ca-certificates.crt /tmp/bun-certs.pem && NODE_EXTRA_CA_CERTS=/tmp/bun-certs.pem bun i axios" ); assert.ok( diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js index 1698759..b78b7ab 100644 --- a/test/e2e/npm-ci.e2e.spec.js +++ b/test/e2e/npm-ci.e2e.spec.js @@ -34,7 +34,7 @@ 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" + "npm i axios --safe-chain-logging=verbose" ); assert.ok( diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index 810359e..02bd6ca 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -29,7 +29,7 @@ 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" + "npm i axios --safe-chain-logging=verbose" ); assert.ok( @@ -70,9 +70,8 @@ describe("E2E: npm coverage", () => { var result = await shell.runCommand("npm 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/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-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 index 8044a0f..b06978f 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -128,16 +128,15 @@ describe("E2E: pip coverage", () => { 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" + "pip3 install --break-system-packages safe-chain-pi-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( - result.output.includes("numpy@2.4.4"), + result.output.includes("safe_chain_pi_test@0.0.1"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -147,7 +146,7 @@ describe("E2E: pip coverage", () => { const listResult = await shell.runCommand("pip3 list"); assert.ok( - !listResult.output.includes("numpy"), + !listResult.output.includes("safe-chain-pi-test"), `Malicious package was installed despite safe-chain protection. Output of 'pip3 list' was:\n${listResult.output}` ); }); diff --git a/test/e2e/pipx.e2e.spec.js b/test/e2e/pipx.e2e.spec.js index 332709d..a554aa6 100644 --- a/test/e2e/pipx.e2e.spec.js +++ b/test/e2e/pipx.e2e.spec.js @@ -41,7 +41,7 @@ describe("E2E: pipx coverage", () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "pipx install numpy==2.4.4" + "pipx install safe-chain-pi-test" ); assert.ok( @@ -86,7 +86,7 @@ describe("E2E: pipx coverage", () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "pipx run numpy==2.4.4 --version" + "pipx run safe-chain-pi-test --version" ); assert.ok( @@ -122,7 +122,7 @@ describe("E2E: pipx coverage", () => { await shell.runCommand("pipx install ruff"); const result = await shell.runCommand( - "pipx runpip ruff install numpy==2.4.4" + "pipx runpip ruff install safe-chain-pi-test" ); assert.ok( @@ -185,7 +185,7 @@ describe("E2E: pipx coverage", () => { 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" + "pipx inject ruff safe-chain-pi-test --safe-chain-logging=verbose" ); assert.ok( diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js index a56bb77..29b9d0f 100644 --- a/test/e2e/pnpm-ci.e2e.spec.js +++ b/test/e2e/pnpm-ci.e2e.spec.js @@ -34,7 +34,7 @@ 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" + "pnpm add axios --safe-chain-logging=verbose" ); assert.ok( diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index 6f9dacf..a15250a 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -70,9 +70,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 index 7d77d9c..58b74fd 100644 --- a/test/e2e/poetry.e2e.spec.js +++ b/test/e2e/poetry.e2e.spec.js @@ -70,7 +70,7 @@ describe("E2E: poetry coverage", () => { 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" + "cd /tmp/test-poetry-malware && poetry add safe-chain-pi-test" ); assert.ok( @@ -300,7 +300,7 @@ describe("E2E: poetry coverage", () => { // 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" + "cd /tmp/test-poetry-install-malware && poetry add safe-chain-pi-test 2>&1" ); assert.ok( @@ -324,7 +324,7 @@ describe("E2E: poetry coverage", () => { // 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" + "cd /tmp/test-poetry-update-add && poetry add safe-chain-pi-test 2>&1" ); assert.ok( @@ -345,7 +345,7 @@ describe("E2E: poetry coverage", () => { // 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" + "cd /tmp/test-poetry-req-malware && poetry add safe-chain-pi-test requests 2>&1" ); assert.ok( 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 index 43187d8..15dbf94 100644 --- a/test/e2e/safe-chain-cli-python.e2e.spec.js +++ b/test/e2e/safe-chain-cli-python.e2e.spec.js @@ -97,12 +97,11 @@ describe("E2E: safe-chain CLI python/pip support", () => { await shell.runCommand("pip3 cache purge"); const result = await shell.runCommand( - "safe-chain pip3 install --break-system-packages numpy==2.4.4" + "safe-chain pip3 install --break-system-packages safe-chain-pi-test" ); - assert.match( - result.output, - /blocked [1-9]\d* malicious package downloads/, + assert.ok( + result.output.includes("blocked 1 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..70aac68 100644 --- a/test/e2e/setup-ci.e2e.spec.js +++ b/test/e2e/setup-ci.e2e.spec.js @@ -40,7 +40,7 @@ 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" + "npm i axios --safe-chain-logging=verbose" ); const hasExpectedOutput = result.output.includes("Safe-chain: Scanned"); diff --git a/test/e2e/setup.teardown.e2e.spec.js b/test/e2e/setup.teardown.e2e.spec.js index 0ddfaf4..c6ae337 100644 --- a/test/e2e/setup.teardown.e2e.spec.js +++ b/test/e2e/setup.teardown.e2e.spec.js @@ -30,7 +30,7 @@ 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" + "npm i axios --safe-chain-logging=verbose" ); const hasExpectedOutput = result.output.includes("Safe-chain: Scanned"); @@ -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/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 index 728d4c5..9d5f3b9 100644 --- a/test/e2e/uv.e2e.spec.js +++ b/test/e2e/uv.e2e.spec.js @@ -126,16 +126,15 @@ describe("E2E: uv coverage", () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "uv pip install --system --break-system-packages numpy==2.4.4" + "uv pip install --system --break-system-packages safe-chain-pi-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( - result.output.includes("numpy@2.4.4"), + result.output.includes("safe_chain_pi_test@0.0.1"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -145,7 +144,7 @@ describe("E2E: uv coverage", () => { const listResult = await shell.runCommand("uv pip list --system"); assert.ok( - !listResult.output.includes("numpy"), + !listResult.output.includes("safe-chain-pi-test"), `Malicious package was installed despite safe-chain protection. Output of 'uv pip list' was:\n${listResult.output}` ); }); @@ -414,16 +413,15 @@ describe("E2E: uv coverage", () => { await shell.runCommand("uv init test-project-malware"); const result = await shell.runCommand( - "cd test-project-malware && uv add numpy==2.4.4" + "cd test-project-malware && uv add safe-chain-pi-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( - result.output.includes("numpy@2.4.4"), + result.output.includes("safe_chain_pi_test@0.0.1"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -447,15 +445,14 @@ describe("E2E: uv coverage", () => { 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"); + const result = await shell.runCommand("uv tool install safe-chain-pi-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( - result.output.includes("numpy@2.4.4"), + result.output.includes("safe_chain_pi_test@0.0.1"), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -485,12 +482,11 @@ describe("E2E: uv coverage", () => { await shell.runCommand("echo 'print(\"test\")' > test_script2.py"); const result = await shell.runCommand( - "uv run --with numpy==2.4.4 test_script2.py" + "uv run --with safe-chain-pi-test test_script2.py" ); - 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}` ); }); 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..88b768d 100644 --- a/test/e2e/yarn-ci.e2e.spec.js +++ b/test/e2e/yarn-ci.e2e.spec.js @@ -34,7 +34,7 @@ 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" + "yarn add axios --safe-chain-logging=verbose" ); assert.ok( diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index e70d6fc..726fff2 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -29,7 +29,7 @@ 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" + "yarn add axios --safe-chain-logging=verbose" ); assert.ok( @@ -70,9 +70,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(