diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 987db03..f9ca4da 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -5,8 +5,30 @@ on: tags: - "*" +permissions: + id-token: write + contents: write + jobs: + set-version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.get_version.outputs.tag }} + steps: + - name: Set version number + id: get_version + run: | + version="${{ github.ref_name }}" + echo "tag=$version" >> $GITHUB_OUTPUT + + create-binaries: + needs: set-version + uses: ./.github/workflows/create-artifact.yml + with: + version: ${{ needs.set-version.outputs.version }} + build: + needs: [set-version, create-binaries] runs-on: ubuntu-latest steps: @@ -26,14 +48,8 @@ jobs: npm i -g @aikidosec/safe-chain safe-chain setup-ci - - name: Set version number - id: get_version - run: | - version="${{ github.ref_name }}" - echo "tag=$version" >> $GITHUB_OUTPUT - - name: Set the version in safe-chain package - run: npm --no-git-tag-version version ${{ steps.get_version.outputs.tag }} --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 @@ -49,7 +65,33 @@ jobs: - name: Publish to npm run: | - echo "Publishing version ${{ steps.get_version.outputs.tag }} to NPM" - npm publish --workspace=packages/safe-chain --access public + echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM" + npm publish --workspace=packages/safe-chain --access public --provenance + + - name: Download all binary artifacts + uses: actions/download-artifact@v4 + with: + path: binaries/ + pattern: safe-chain-* + merge-multiple: false + + - name: Rename binaries to include platform and architecture + run: | + mv binaries/safe-chain-macos-x64/safe-chain binaries/safe-chain-macos-x64/safe-chain-macos-x64 + mv binaries/safe-chain-macos-arm64/safe-chain binaries/safe-chain-macos-arm64/safe-chain-macos-arm64 + mv binaries/safe-chain-linux-x64/safe-chain binaries/safe-chain-linux-x64/safe-chain-linux-x64 + mv binaries/safe-chain-linux-arm64/safe-chain binaries/safe-chain-linux-arm64/safe-chain-linux-arm64 + mv binaries/safe-chain-win-x64/safe-chain.exe binaries/safe-chain-win-x64/safe-chain-win-x64.exe + mv binaries/safe-chain-win-arm64/safe-chain.exe binaries/safe-chain-win-arm64/safe-chain-win-arm64.exe + + - name: Upload binaries to existing GitHub Release env: - NPM_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload ${{ needs.set-version.outputs.version }} \ + binaries/safe-chain-macos-x64/* \ + binaries/safe-chain-macos-arm64/* \ + binaries/safe-chain-linux-x64/* \ + binaries/safe-chain-linux-arm64/* \ + binaries/safe-chain-win-x64/* \ + binaries/safe-chain-win-arm64/* diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml new file mode 100644 index 0000000..ad43a9d --- /dev/null +++ b/.github/workflows/create-artifact.yml @@ -0,0 +1,82 @@ +name: Create binaries + +on: + pull_request: + workflow_call: + inputs: + version: + description: 'Version to set in package.json' + required: false + type: string + +jobs: + create-binaries: + name: Create binary for ${{ matrix.os }}-${{ matrix.arch }} + + runs-on: ${{ matrix.runner }} + + strategy: + fail-fast: false + matrix: + include: + - os: macos + arch: x64 + runner: macos-15-intel + target: node20-macos-x64 + extension: "" + - os: macos + arch: arm64 + runner: macos-latest + target: node20-macos-arm64 + extension: "" + - os: linux + arch: x64 + runner: ubuntu-latest + target: node20-linux-x64 + extension: "" + - os: linux + arch: arm64 + runner: ubuntu-24.04-arm + target: node20-linux-arm64 + extension: "" + - os: win + arch: x64 + runner: windows-latest + target: node20-win-x64 + extension: ".exe" + - os: win + arch: arm64 + runner: windows-11-arm + target: node20-win-arm64 + extension: ".exe" + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: "20.x" + + - name: Setup safe-chain + run: | + npm i -g @aikidosec/safe-chain + safe-chain setup-ci + + - name: Set the version in safe-chain package + if: inputs.version != '' + run: npm --no-git-tag-version version ${{ inputs.version }} --workspace=packages/safe-chain + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Create binary + run: | + node build.js ${{ matrix.target }} + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: safe-chain-${{ matrix.os }}-${{ matrix.arch }} + path: dist/* diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index bc6e5a2..7b7f061 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -33,9 +33,12 @@ jobs: - name: Run unit tests run: npm test - - name: Run ESLint + - name: Run linting run: npm run lint + - name: Type check + run: npm run typecheck --workspace=packages/safe-chain + - name: Create package tarball run: npm pack --workspace=packages/safe-chain diff --git a/.gitignore b/.gitignore index acae695..920883f 100644 --- a/.gitignore +++ b/.gitignore @@ -143,4 +143,11 @@ vite.config.ts.timestamp-* # AI Claude.md .claude -.reference \ No newline at end of file +.reference + +# Build files +build/ +dist/ + +# Jetbrains IDEs +.idea/** diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..b9c483c --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,30 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [ + "node", + "promise", + "eslint", + "unicorn", + "oxc", + "import" + ], + "env": { + "browser": false, + "node": true + }, + "rules": { + "eslint/no-console": "error", + "eslint/no-empty": "error", + "eslint/no-undef": "error" + }, + "overrides": [ + { + "files": [ + "*.spec.js" + ], + "rules": { + "eslint/no-console": "off" + } + } + ] +} diff --git a/README.md b/README.md index fd2cdff..6cbb445 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,109 @@ +![Aikido Safe Chain](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/banner.svg) + # Aikido Safe Chain -The Aikido Safe Chain **prevents developers from installing malware** on their workstations through npm, npx, yarn, pnpm, pnpx, bun, and bunx. It's **free** to use and does not require any token. +[![NPM Version](https://img.shields.io/npm/v/%40aikidosec%2Fsafe-chain?style=flat-square)](https://www.npmjs.com/package/@aikidosec/safe-chain) +[![NPM Downloads](https://img.shields.io/npm/dw/%40aikidosec%2Fsafe-chain?style=flat-square)](https://www.npmjs.com/package/@aikidosec/safe-chain) -The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), and [bunx](https://bun.sh/docs/cli/bunx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, or bunx from downloading or running the malware. +- ✅ **Block malware on developer laptops and CI/CD** +- ✅ **Supports npm and PyPI** more package managers coming +- ✅ **Blocks packages newer than 24 hours** without breaking your build +- ✅ **Tokenless, free, no build data shared** -![demo](./docs/safe-package-manager-demo.png) +Aikido Safe Chain supports the following package managers: -Aikido Safe Chain works on Node.js version 18 and above and supports the following package managers: - -- ✅ **npm** -- ✅ **npx** -- ✅ **yarn** -- ✅ **pnpm** -- ✅ **pnpx** -- ✅ **bun** -- ✅ **bunx** +- 📦 **npm** +- 📦 **npx** +- 📦 **yarn** +- 📦 **pnpm** +- 📦 **pnpx** +- 📦 **bun** +- 📦 **bunx** +- 📦 **pip** (beta) +- 📦 **pip3** (beta) +- 📦 **uv** (beta) # Usage ## Installation -Installing the Aikido Safe Chain is easy. You just need 3 simple steps: +Installing the Aikido Safe Chain is easy with our one-line installer. + +> ⚠️ **Already installed via npm?** See the [migration guide](https://github.com/AikidoSec/safe-chain/blob/main/docs/npm-to-binary-migration.md) to switch to the binary version. + +### Unix/Linux/macOS + +**Default installation (JavaScript packages only):** + +```shell +curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh +``` + +**Include Python support (pip/pip3/uv):** + +```shell +curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --include-python +``` + +### Windows (PowerShell) + +**Default installation (JavaScript packages only):** + +```powershell +iex (iwr "https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1" -UseBasicParsing) +``` + +**Include Python support (pip/pip3/uv):** + +```powershell +iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -includepython" +``` + +### Verify the installation + +1. **❗Restart your terminal** to start using the Aikido Safe Chain. + + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available. + +2. **Verify the installation** by running one of the following commands: + + For JavaScript/Node.js: -1. **Install the Aikido Safe Chain package globally** using npm: - ```shell - npm install -g @aikidosec/safe-chain - ``` -2. **Setup the shell integration** by running: - ```shell - safe-chain setup - ``` -3. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, and bunx are loaded correctly. If you do not restart your terminal, the aliases will not be available. -4. **Verify the installation** by running: ```shell npm install safe-chain-test ``` - - The output should show that Aikido Safe Chain is blocking the installation of this package as it is flagged as malware. -When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, or `bunx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. If any malware is detected, it will prompt you to exit the command. + For Python (if you enabled Python support): + + ```shell + pip3 install safe-chain-pi-test + ``` + + - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. + +When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `uv`, `pip`, or `pip3` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. + +You can check the installed version by running: + +```shell +safe-chain --version +``` ## How it works -The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry. When you run npm, npx, yarn, pnpm, pnpx, bun, or bunx 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. +### Malware Blocking -The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, and bunx commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: +The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, `pip`, or `pip3` commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. + +### Minimum package age (npm only) + +For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. + +⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3). + +### Shell Integration + +The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (uv, pip). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: - ✅ **Bash** - ✅ **Zsh** @@ -52,7 +111,7 @@ The Aikido Safe Chain integrates with your shell to provide a seamless experienc - ✅ **PowerShell** - ✅ **PowerShell Core** -More information about the shell integration can be found in the [shell integration documentation](docs/shell-integration.md). +More information about the shell integration can be found in the [shell integration documentation](https://github.com/AikidoSec/safe-chain/blob/main/docs/shell-integration.md). ## Uninstallation @@ -70,34 +129,90 @@ To uninstall the Aikido Safe Chain, you can run the following command: # Configuration -## Malware Action +## Logging -You can control how Aikido Safe Chain responds when malware is detected using the `--safe-chain-malware-action` flag: +You can control the output from Aikido Safe Chain using the `--safe-chain-logging` flag: -- `--safe-chain-malware-action=block` (**default**) - Automatically blocks installation and exits with an error when malware is detected -- `--safe-chain-malware-action=prompt` - Prompts the user to decide whether to continue despite the malware detection +- `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit. -Example usage: + Example usage: -```shell -npm install suspicious-package --safe-chain-malware-action=prompt -``` + ```shell + npm install express --safe-chain-logging=silent + ``` + +- `--safe-chain-logging=verbose` - Enables detailed diagnostic output from Aikido Safe Chain. Useful for troubleshooting issues or understanding what Safe Chain is doing behind the scenes. + + Example usage: + + ```shell + npm install express --safe-chain-logging=verbose + ``` + +## Minimum Package Age + +You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 24 hours old before they can be installed through npm-based package managers. + +### Configuration Options + +You can set the minimum package age through multiple sources (in order of priority): + +1. **CLI Argument** (highest priority): + + ```shell + npm install express --safe-chain-minimum-package-age-hours=48 + ``` + +2. **Environment Variable**: + + ```shell + export SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS=48 + npm install express + ``` + +3. **Config File** (`~/.aikido/config.json`): + + ```json + { + "minimumPackageAgeHours": 48 + } + ``` # 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. -For optimal protection in CI/CD environments, we recommend using **npm >= 10.4.0** as it provides full dependency tree scanning. Other package managers currently offer limited scanning of install command arguments only. +## Installation for CI/CD -## Setup +Use the `--ci` flag to automatically configure Aikido Safe Chain for CI/CD environments. This sets up executable shims in the PATH instead of shell aliases. -To use Aikido Safe Chain in CI/CD environments, run the following command after installing the package: +### Unix/Linux/macOS (GitHub Actions, Azure Pipelines, etc.) + +**JavaScript only:** ```shell -safe-chain setup-ci +curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci ``` -This automatically configures your CI environment to use Aikido Safe Chain for all package manager commands. +**With Python support:** + +```shell +curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci --include-python +``` + +### Windows (Azure Pipelines, etc.) + +**JavaScript only:** + +```powershell +iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -ci" +``` + +**With Python support:** + +```powershell +iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -ci -includepython" +``` ## Supported Platforms @@ -113,16 +228,15 @@ This automatically configures your CI environment to use Aikido Safe Chain for a node-version: "22" cache: "npm" -- name: Setup safe-chain - run: | - npm i -g @aikidosec/safe-chain - safe-chain setup-ci +- name: Install safe-chain + run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci --include-python - name: Install dependencies - run: | - npm ci + run: npm ci ``` +> **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv) support. + ## Azure DevOps Example ```yaml @@ -131,14 +245,13 @@ This automatically configures your CI environment to use Aikido Safe Chain for a versionSpec: "22.x" displayName: "Install Node.js" -- script: | - npm i -g @aikidosec/safe-chain - safe-chain setup-ci - displayName: "Install safe chain" +- script: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci --include-python + displayName: "Install safe-chain" -- script: | - npm ci - displayName: "npm install and build" +- script: npm ci + displayName: "Install dependencies" ``` +> **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv) support. + After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. diff --git a/build.js b/build.js new file mode 100644 index 0000000..81619f4 --- /dev/null +++ b/build.js @@ -0,0 +1,139 @@ +import { build } from "esbuild"; +import { mkdir, cp, rm, readFile, writeFile, stat } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; + +const target = process.argv[2]; +if (!target) { + // eslint-disable-next-line no-console + console.error("Usage: node build.js "); + // eslint-disable-next-line no-console + console.error("Example: node build.js node22-macos-arm64"); + process.exit(1); +} + +(async function main() { + const startBuildTime = performance.now(); + + await clearOutputFolder(); + console.log("- Cleared output folder ✅") + + // Esbuild creates a single safe-chain.cjs with all dependencies included + await bundleSafeChain(); + console.log("- Bundled safe-chain into safe-chain.cjs (es-build) ✅") + + // Copy assets that need to be included in the binary + // - All shell scripts that are used to setup safe-chain + // - Certifi because it contains static root certs for Python + // - Package.json for its metadata (package name, version, ...) + await copyShellScripts(); + await copyCertifi(); + await copyAndModifyPackageJson(); + console.log("- Copied auxiliary resources (shell, package.json,...) ✅") + + // Creates a single binary with safe-chain.cjs and the copied assets + await buildSafeChainBinary(target); + console.log(`- Built safe-chain binary for ${target} (pkg) ✅`) + + + const totalBuildTime = (performance.now() - startBuildTime)/1000; + const totalSizeInMb = (await stat("./dist/safe-chain" + (process.platform === "win32" ? ".exe" : ""))).size / (1024*1024); + console.log(`🏁 Finished build in ${totalBuildTime.toFixed(2)}s, total build size: ${totalSizeInMb.toFixed(2)}MB`); +})(); + +async function clearOutputFolder() { + await rm("./build", { recursive: true, force: true }); + await mkdir("./build"); +} + +async function bundleSafeChain() { + await build({ + entryPoints: ["./packages/safe-chain/bin/safe-chain.js"], + bundle: true, + platform: "node", + target: "node24", + outfile: "./build/bin/safe-chain.cjs", + external: ["certifi"], + }); + + let bundledContent = await readFile("./build/bin/safe-chain.cjs", "utf-8"); + + await writeFile("./build/bin/safe-chain.cjs", bundledContent); +} + +async function copyShellScripts() { + await mkdir("./build/bin/startup-scripts", { recursive: true }); + await cp( + "./packages/safe-chain/src/shell-integration/startup-scripts/", + "./build/bin/startup-scripts", + { recursive: true } + ); + await mkdir("./build/bin/path-wrappers", { recursive: true }); + await cp( + "./packages/safe-chain/src/shell-integration/path-wrappers/", + "./build/bin/path-wrappers", + { recursive: true } + ); +} + +async function copyCertifi() { + await mkdir("./build/node_modules/certifi", { recursive: true }); + await cp("./node_modules/certifi/", "./build/node_modules/certifi", { + recursive: true, + }); +} +async function copyAndModifyPackageJson() { + const packageJsonContent = await readFile( + "./packages/safe-chain/package.json", + "utf-8" + ); + const packageJson = JSON.parse(packageJsonContent); + + delete packageJson.main; + delete packageJson.scripts; + delete packageJson.exports; + delete packageJson.dependencies; + delete packageJson.devDependencies; + + packageJson.bin = { + "safe-chain": "bin/safe-chain.cjs", + }; + packageJson.type = "commonjs"; + packageJson.pkg = { + outputPath: "dist", + assets: [ + "node_modules/certifi/**/*", + "bin/startup-scripts/**/*", + "bin/path-wrappers/**/*", + ], + }; + + await writeFile("./build/package.json", JSON.stringify(packageJson, null, 2)); + + return packageJson; +} + +function buildSafeChainBinary(target) { + return new Promise((promiseResolve, reject) => { + // Use .cmd on Windows, resolve to absolute path for cross-platform compatibility + const pkgBin = process.platform === "win32" + ? resolve("node_modules/.bin/pkg.cmd") + : resolve("node_modules/.bin/pkg"); + + let pkgArgs = []; + + pkgArgs = pkgArgs.concat(["./build/package.json", "-t", target]); + const pkg = spawn(pkgBin, pkgArgs, { + stdio: "inherit", + shell: true, + }); + + pkg.on("close", (code) => { + if (code !== 0) { + reject(new Error(`pkg process exited with code ${code}`)); + } else { + promiseResolve(); + } + }); + }); +} diff --git a/docs/banner.svg b/docs/banner.svg new file mode 100644 index 0000000..ce9a00c --- /dev/null +++ b/docs/banner.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/npm-to-binary-migration.md b/docs/npm-to-binary-migration.md new file mode 100644 index 0000000..c29a044 --- /dev/null +++ b/docs/npm-to-binary-migration.md @@ -0,0 +1,89 @@ +# Migrating from npm global tool to binary installation + +If you previously installed safe-chain as an npm global package, you need to migrate to the binary installation. + +Depending on the version manager you're using, the uninstall process differs: + +### Standard npm (no version manager) + +1. **Clean up shell aliases:** + + ```bash + safe-chain teardown + ``` + +2. **Restart your terminal** + +3. **Uninstall the npm package:** + + ```bash + npm uninstall -g @aikidosec/safe-chain + ``` + +4. **Install the binary version** (see [Installation](https://github.com/AikidoSec/safe-chain/blob/main/README.md#installation)) + +### nvm (Node Version Manager) + +**Important:** nvm installs global packages separately for each Node version, so safe-chain must be uninstalled from each version where it was installed. + +1. **Clean up shell aliases:** + + ```bash + safe-chain teardown + ``` + +2. **Restart your terminal** + +3. **Uninstall from all Node versions:** + + **Option A** - Automated script (recommended): + + ```bash + for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do nvm use $version && npm uninstall -g @aikidosec/safe-chain; done + ``` + + **Option B** - Manual per version: + + ```bash + nvm use + npm uninstall -g @aikidosec/safe-chain + ``` + + Repeat for each Node version where safe-chain was installed. + +4. **Install the binary version** (see [Installation](https://github.com/AikidoSec/safe-chain/blob/main/README.md#installation)) + +### Volta + +1. **Clean up shell aliases:** + + ```bash + safe-chain teardown + ``` + +2. **Restart your terminal** + +3. **Uninstall the Volta package:** + + ```bash + volta uninstall @aikidosec/safe-chain + ``` + +4. **Install the binary version** (see [Installation](https://github.com/AikidoSec/safe-chain/blob/main/README.md#installation)) + +## Troubleshooting + +### Shell aliases still present after migration + +1. Run `safe-chain teardown` (if the binary is installed) +2. Manually remove any safe-chain entries from your shell config files: + - Bash: `~/.bashrc` + - Zsh: `~/.zshrc` + - Fish: `~/.config/fish/config.fish` + - PowerShell: `$PROFILE` +3. Restart your terminal +4. Re-run the install script + +### "command not found: safe-chain" after migration + +The binary installation directory (`~/.safe-chain/bin`) may not be in your PATH. Restart your terminal. If the problem persists: re-run the installation of safe-chain. diff --git a/docs/shell-integration.md b/docs/shell-integration.md index 4a6ac99..e7afbe5 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`, `bun`, `bunx`) with Aikido's security scanning functionality. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. +The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. ## Supported Shells @@ -28,7 +28,8 @@ This command: - Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`) - Detects all supported shells on your system -- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, and `bunx` +- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3` +- Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name ❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly. @@ -77,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts: This means the shell functions are working but the Aikido commands aren't installed or available in your PATH: - Make sure Aikido Safe Chain is properly installed on your system -- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, and `aikido-bunx` commands exist +- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, and `aikido-pip3` commands exist - Check that these commands are in your system's PATH ### Manual Verification @@ -120,4 +121,29 @@ npm() { } ``` -Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, and `bunx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. +Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. + +To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions: + +```bash +# Example for Bash/Zsh +python() { + if [[ "$1" == "-m" && "$2" == pip* ]]; then + local mod="$2"; shift 2 + if [[ "$mod" == "pip3" ]]; then aikido-pip3 "$@"; else aikido-pip "$@"; fi + else + command python "$@" + fi +} + +python3() { + if [[ "$1" == "-m" && "$2" == pip* ]]; then + local mod="$2"; shift 2 + if [[ "$mod" == "pip3" ]]; then aikido-pip3 "$@"; else aikido-pip "$@"; fi + else + command python3 "$@" + fi +} +``` + +Limitations: these only apply when invoking `python`/`python3` by name. Absolute paths (e.g., `/usr/bin/python -m pip`) bypass shell functions. diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 3db1b7f..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,26 +0,0 @@ -import js from "@eslint/js"; -import { defineConfig, globalIgnores } from "@eslint/config-helpers"; -import globals from "globals"; -import importPlugin from "eslint-plugin-import"; - -export default defineConfig([ - { - files: ["**/*.{js,mjs,cjs,ts}"], - plugins: { js }, - extends: ["js/recommended"], - }, - { - files: ["**/*.{js,mjs,cjs,ts}"], - languageOptions: { globals: globals.node }, - }, - importPlugin.flatConfigs.recommended, - { - files: ["**/*.{js,mjs,cjs}"], - languageOptions: { - ecmaVersion: "latest", - sourceType: "module", - }, - rules: {}, - }, - globalIgnores(['test/e2e', 'node_modules']), -]); diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 new file mode 100644 index 0000000..081d232 --- /dev/null +++ b/install-scripts/install-safe-chain.ps1 @@ -0,0 +1,217 @@ +# Downloads and installs safe-chain for Windows +# +# Usage with "iex (iwr {url} -UseBasicParsing)" --> See README.md + +param( + [switch]$ci, + [switch]$includepython +) + +$Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set +$InstallDir = Join-Path $env:USERPROFILE ".safe-chain\bin" +$RepoUrl = "https://github.com/AikidoSec/safe-chain" + +# Ensure TLS 1.2 is enabled for downloads +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +# Helper functions +function Write-Info { + param([string]$Message) + Write-Host "[INFO] $Message" -ForegroundColor Green +} + +function Write-Warn { + param([string]$Message) + Write-Host "[WARN] $Message" -ForegroundColor Yellow +} + +function Write-Error-Custom { + param([string]$Message) + Write-Host "[ERROR] $Message" -ForegroundColor Red + exit 1 +} + +# Fetch latest release version tag from GitHub +function Get-LatestVersion { + try { + $response = Invoke-RestMethod -Uri "https://api.github.com/repos/AikidoSec/safe-chain/releases/latest" -UseBasicParsing + $latestVersion = $response.tag_name + + if ([string]::IsNullOrWhiteSpace($latestVersion)) { + Write-Error-Custom "Failed to fetch latest version from GitHub API. Please set SAFE_CHAIN_VERSION environment variable." + } + + return $latestVersion + } + catch { + Write-Error-Custom "Failed to fetch latest version from GitHub API: $($_.Exception.Message). Please set SAFE_CHAIN_VERSION environment variable." + } +} + +# Detect architecture +function Get-Architecture { + $arch = $env:PROCESSOR_ARCHITECTURE + switch ($arch) { + "AMD64" { return "x64" } + "ARM64" { return "arm64" } + default { Write-Error-Custom "Unsupported architecture: $arch" } + } +} + +# Check and uninstall npm global package if present +function Remove-NpmInstallation { + # Check if npm is available + if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + return + } + + # Check if safe-chain is installed as an npm global package + npm list -g @aikidosec/safe-chain 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Info "Detected npm global installation of @aikidosec/safe-chain" + Write-Info "Uninstalling npm version before installing binary version..." + + npm uninstall -g @aikidosec/safe-chain 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Info "Successfully uninstalled npm version" + } + else { + Write-Warn "Failed to uninstall npm version automatically" + Write-Warn "Please run: npm uninstall -g @aikidosec/safe-chain" + } + } +} + +# Check and uninstall Volta-managed package if present +function Remove-VoltaInstallation { + # Check if Volta is available + if (-not (Get-Command volta -ErrorAction SilentlyContinue)) { + return + } + + # Volta manages global packages in its own directory + # Check if safe-chain is installed via Volta + volta list safe-chain 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Info "Detected Volta installation of @aikidosec/safe-chain" + Write-Info "Uninstalling Volta version before installing binary version..." + + volta uninstall @aikidosec/safe-chain 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Info "Successfully uninstalled Volta version" + } + else { + Write-Warn "Failed to uninstall Volta version automatically" + Write-Warn "Please run: volta uninstall @aikidosec/safe-chain" + } + } +} + +# Main installation +function Install-SafeChain { + # Fetch latest version if VERSION is not set + if ([string]::IsNullOrWhiteSpace($Version)) { + Write-Info "Fetching latest release version..." + $Version = Get-LatestVersion + } + + # Build installation message + $installMsg = "Installing safe-chain $Version" + if ($includepython) { + $installMsg += " with python" + } + if ($ci) { + $installMsg += " in ci" + } + + Write-Info $installMsg + + # Check for existing safe-chain installation through npm or volta + Remove-NpmInstallation + Remove-VoltaInstallation + + # Detect platform + $arch = Get-Architecture + $binaryName = "safe-chain-win-$arch.exe" + + Write-Info "Detected architecture: $arch" + + # Create installation directory + if (-not (Test-Path $InstallDir)) { + Write-Info "Creating installation directory: $InstallDir" + try { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + } + catch { + Write-Error-Custom "Failed to create directory $InstallDir : $_" + } + } + + # Download binary + $downloadUrl = "$RepoUrl/releases/download/$Version/$binaryName" + $tempFile = Join-Path $InstallDir $binaryName + + Write-Info "Downloading from: $downloadUrl" + + try { + # Download with progress suppressed for cleaner output + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $downloadUrl -OutFile $tempFile -UseBasicParsing + $ProgressPreference = 'Continue' + } + catch { + Write-Error-Custom "Failed to download from $downloadUrl : $_" + } + + # Rename to final location + $finalFile = Join-Path $InstallDir "safe-chain.exe" + try { + # Remove existing file if present (Move-Item -Force doesn't overwrite) + if (Test-Path $finalFile) { + Remove-Item -Path $finalFile -Force + } + Move-Item -Path $tempFile -Destination $finalFile -Force + } + catch { + Write-Error-Custom "Failed to move binary to $finalFile : $_" + } + + Write-Info "Binary installed to: $finalFile" + + # Build setup command based on parameters + $setupCmd = if ($ci) { "setup-ci" } else { "setup" } + $setupArgs = @() + if ($includepython) { + $setupArgs += "--include-python" + } + + # Execute safe-chain setup + Write-Info "Running safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })..." + 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 +try { + Install-SafeChain +} +catch { + Write-Error-Custom "Installation failed: $_" +} diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh new file mode 100755 index 0000000..2afb583 --- /dev/null +++ b/install-scripts/install-safe-chain.sh @@ -0,0 +1,224 @@ +#!/bin/sh + +# Downloads and installs safe-chain, depending on the operating system and architecture +# +# Usage with "curl -fsSL {url} | sh" --> See README.md + +set -e # Exit on error + +# Configuration +VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set +INSTALL_DIR="${HOME}/.safe-chain/bin" +REPO_URL="https://github.com/AikidoSec/safe-chain" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Helper functions +info() { + printf "${GREEN}[INFO]${NC} %s\n" "$1" +} + +warn() { + printf "${YELLOW}[WARN]${NC} %s\n" "$1" +} + +error() { + printf "${RED}[ERROR]${NC} %s\n" "$1" >&2 + exit 1 +} + +# Detect OS +detect_os() { + case "$(uname -s)" in + Linux*) echo "linux" ;; + Darwin*) echo "macos" ;; + *) error "Unsupported operating system: $(uname -s)" ;; + esac +} + +# Detect architecture +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "x64" ;; + aarch64|arm64) echo "arm64" ;; + *) error "Unsupported architecture: $(uname -m)" ;; + esac +} + +# Check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Fetch latest release version tag from GitHub +fetch_latest_version() { + # Try using GitHub API to get the latest release tag + if command_exists curl; then + latest_version=$(curl -fsSL "https://api.github.com/repos/AikidoSec/safe-chain/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') + elif command_exists wget; then + latest_version=$(wget -qO- "https://api.github.com/repos/AikidoSec/safe-chain/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') + else + error "Neither curl nor wget found. Please install one of them or set SAFE_CHAIN_VERSION environment variable." + fi + + if [ -z "$latest_version" ]; then + error "Failed to fetch latest version from GitHub API. Please set SAFE_CHAIN_VERSION environment variable." + fi + + echo "$latest_version" +} + +# Download file +download() { + url="$1" + dest="$2" + + if command_exists curl; then + curl -fsSL "$url" -o "$dest" || error "Failed to download from $url" + elif command_exists wget; then + wget -q "$url" -O "$dest" || error "Failed to download from $url" + else + error "Neither curl nor wget found. Please install one of them." + fi +} + +# Check and uninstall npm global package if present +remove_npm_installation() { + if ! command_exists npm; then + return + fi + + # Check if safe-chain is installed as an npm global package + if npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then + info "Detected npm global installation of @aikidosec/safe-chain" + info "Uninstalling npm version before installing binary version..." + + if npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then + info "Successfully uninstalled npm version" + else + warn "Failed to uninstall npm version automatically" + warn "Please run: npm uninstall -g @aikidosec/safe-chain" + fi + fi +} + +# Check and uninstall Volta-managed package if present +remove_volta_installation() { + if ! command_exists volta; then + return + fi + + # Volta manages global packages in its own directory + # Check if safe-chain is installed via Volta + if volta list safe-chain >/dev/null 2>&1; then + info "Detected Volta installation of @aikidosec/safe-chain" + info "Uninstalling Volta version before installing binary version..." + + if volta uninstall @aikidosec/safe-chain >/dev/null 2>&1; then + info "Successfully uninstalled Volta version" + else + warn "Failed to uninstall Volta version automatically" + warn "Please run: volta uninstall @aikidosec/safe-chain" + fi + fi +} + +# Parse command-line arguments +parse_arguments() { + for arg in "$@"; do + case "$arg" in + --ci) + USE_CI_SETUP=true + ;; + --include-python) + INCLUDE_PYTHON=true + ;; + *) + error "Unknown argument: $arg" + ;; + esac + done +} + +# Main installation +main() { + # Initialize argument flags + USE_CI_SETUP=false + INCLUDE_PYTHON=false + + # Parse command-line arguments + parse_arguments "$@" + + # Fetch latest version if VERSION is not set + if [ -z "$VERSION" ]; then + info "Fetching latest release version..." + VERSION=$(fetch_latest_version) + fi + + # Build installation message + INSTALL_MSG="Installing safe-chain ${VERSION}" + if [ "$INCLUDE_PYTHON" = "true" ]; then + INSTALL_MSG="${INSTALL_MSG} with python" + fi + if [ "$USE_CI_SETUP" = "true" ]; then + INSTALL_MSG="${INSTALL_MSG} in ci" + fi + + info "$INSTALL_MSG" + + # Check for existing safe-chain installation through npm or volta + remove_npm_installation + remove_volta_installation + + # Detect platform + OS=$(detect_os) + ARCH=$(detect_arch) + BINARY_NAME="safe-chain-${OS}-${ARCH}" + + info "Detected platform: ${OS}-${ARCH}" + + # Create installation directory + if [ ! -d "$INSTALL_DIR" ]; then + info "Creating installation directory: $INSTALL_DIR" + mkdir -p "$INSTALL_DIR" || error "Failed to create directory $INSTALL_DIR" + fi + + # Download binary + DOWNLOAD_URL="${REPO_URL}/releases/download/${VERSION}/${BINARY_NAME}" + TEMP_FILE="${INSTALL_DIR}/${BINARY_NAME}" + + info "Downloading from: $DOWNLOAD_URL" + download "$DOWNLOAD_URL" "$TEMP_FILE" + + # Rename and make executable + FINAL_FILE="${INSTALL_DIR}/safe-chain" + mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE" + chmod +x "$FINAL_FILE" || error "Failed to make binary executable" + + info "Binary installed to: $FINAL_FILE" + + # Build setup command based on arguments + SETUP_CMD="setup" + SETUP_ARGS="" + + if [ "$USE_CI_SETUP" = "true" ]; then + SETUP_CMD="setup-ci" + fi + + if [ "$INCLUDE_PYTHON" = "true" ]; then + SETUP_ARGS="--include-python" + fi + + # Execute safe-chain setup + info "Running safe-chain $SETUP_CMD $SETUP_ARGS..." + if ! "$FINAL_FILE" $SETUP_CMD $SETUP_ARGS; then + 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/package-lock.json b/package-lock.json index 6b74d53..3fb6a1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,316 +12,554 @@ "test/e2e" ], "devDependencies": { - "@eslint/js": "^9.35.0", - "eslint": "^9.35.0", - "eslint-plugin-import": "^2.32.0", - "globals": "^16.1.0", - "typescript-eslint": "^8.32.0" + "@yao-pkg/pkg": "6.10.1", + "esbuild": "^0.27.0", + "oxlint": "^1.22.0" } }, "node_modules/@aikidosec/safe-chain": { "resolved": "packages/safe-chain", "link": true }, - "node_modules/@aikidosec/safe-chain-bun": { - "resolved": "packages/safe-chain-bun", - "link": true - }, "node_modules/@aikidosec/safe-chain-e2e-tests": { "resolved": "test/e2e", "link": true }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", "dev": true, "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=6.0.0" } }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/js": { - "version": "9.35.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", - "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", + "node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": ">=18" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.15.2", - "levn": "^0.4.1" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "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" - }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": "20 || >=22" } }, - "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==", + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "@isaacs/balanced-match": "^4.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": "20 || >=22" } }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -330,499 +568,355 @@ "node": ">=18.0.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=6.0.0" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", + "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", "license": "ISC", "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", + "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", "license": "ISC", "dependencies": { "semver": "^7.3.5" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/redact": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.2.2.tgz", - "integrity": "sha512-7VmYAmk4csGv08QzrDKScdzn11jHPFGyqJW39FyPgPuAp3zIaUmuCo1yxw9aGs+NEJuTGQ9Gwqpt93vtJubucg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.2.21.tgz", - "integrity": "sha512-SihfZ3czKeWz6Z3m5rUDrMlarwOXjnkUg+7tIiSB9VZCFSvWEItMfdAF170eCXxZmEh7A1dw20a3lW37lkmlrA==", + "node_modules/@oxlint/darwin-arm64": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-arm64/-/darwin-arm64-1.22.0.tgz", + "integrity": "sha512-vfgwTA1CowVaU3QXFBjfGjbPsHbdjAiJnWX5FBaq8uXS8tksGgl0ue14MK6fVnXncWK9j69LRnkteGTixxDAfA==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, - "node_modules/@oven/bun-darwin-x64": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.2.21.tgz", - "integrity": "sha512-iXr4y2ap6EmME7/EDoLMxSRKAh9yswKfrHDb9sF+ExHbk1C+XsNGxMY73ckQe2w0SIH6NXz2cRMTORbZ8LNjig==", + "node_modules/@oxlint/darwin-x64": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-x64/-/darwin-x64-1.22.0.tgz", + "integrity": "sha512-70x7Y+e0Ddb2Cf2IZsYGnXZrnB/MZgOTi/VkyXZucbnQcpi2VoaYS4Ve662DaNkzvTxdKOGmyJVMmD/digdJLQ==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, - "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.2.21.tgz", - "integrity": "sha512-3KeslC5z3vpXxluYBqh6EDwojxTSyWJQeYPJFf7y/Z5QJuAN7g33l8jrx072X8P/G8CBzU1lJky14vhhnqWd7A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@oven/bun-linux-aarch64": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.2.21.tgz", - "integrity": "sha512-jpUFKGUpim4h4KOqI1VYYgvifZVrWNQZFrmVPfSqGb0ZzF/p5L2qc9Hy2aUL3Lo+zHMPylwbe0iLKElPYk0xoQ==", + "node_modules/@oxlint/linux-arm64-gnu": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-gnu/-/linux-arm64-gnu-1.22.0.tgz", + "integrity": "sha512-Rv94lOyEV8WEuzhjJSpCW3DbL/tlOVizPxth1v5XAFuQdM5rgpOMs3TsAf/YFUn52/qenwVglyvQZL8oAUYlpg==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "linux" - ], - "peer": true + ] }, - "node_modules/@oven/bun-linux-aarch64-musl": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.2.21.tgz", - "integrity": "sha512-7UoUHKACYDin3iR6kdqUrF1AOCCjTHPTv1xmzlX4rzwNQvFYSAR83AMrY7hkatKGzLYkI8EjXDAvFJpwF+ZxoA==", + "node_modules/@oxlint/linux-arm64-musl": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-musl/-/linux-arm64-musl-1.22.0.tgz", + "integrity": "sha512-Aau6V6Osoyb3SFmRejP3rRhs1qhep4aJTdotFf1RVMVSLJkF7Ir0p+eGZSaIJyylFZuCCxHpud3hWasphmZnzw==", "cpu": [ - "aarch64" + "arm64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "linux" - ], - "peer": true + ] }, - "node_modules/@oven/bun-linux-x64": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.2.21.tgz", - "integrity": "sha512-6RuXFaVU2ve0TVw1vfFo7ix/jh9IX7mMAEhwE2odX8EdX/ea55upiivYQ/EKeXt+Ij3STc2bCeV4vvRoEJAHdg==", + "node_modules/@oxlint/linux-x64-gnu": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-gnu/-/linux-x64-gnu-1.22.0.tgz", + "integrity": "sha512-6eOtv+2gHrKw/hxUkV6hJdvYhzr0Dqzb4oc7sNlWxp64jU6I19tgMwSlmtn02r34YNSn+/NpZ/ECvQrycKUUFQ==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "linux" - ], - "peer": true + ] }, - "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.2.21.tgz", - "integrity": "sha512-oZ5FUMfeghwbQcL9oxajsKjwVI+1GnVvxcJ3z+pifuXaLMZr25NCr5h0q2j+ZxEFL3RtL/Pyj8/HLfzGEIVAVg==", + "node_modules/@oxlint/linux-x64-musl": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-musl/-/linux-x64-musl-1.22.0.tgz", + "integrity": "sha512-c4O7qD7TCEfPE/FFKYvakF2sQoIP0LFZB8F5AQK4K9VYlyT1oENNRCdIiMu6irvLelOzJzkUM0XrvUCL9Kkxrw==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "linux" - ], - "peer": true + ] }, - "node_modules/@oven/bun-linux-x64-musl": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.2.21.tgz", - "integrity": "sha512-ioZjU+2yyLJXaDA8FKoy+tj/fuZKovG9EMp+n9+EG7g3MULbe5nU8gdsS/dET28WzuPlDlSkqF8EUocvg4HajQ==", + "node_modules/@oxlint/win32-arm64": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@oxlint/win32-arm64/-/win32-arm64-1.22.0.tgz", + "integrity": "sha512-6DJwF5A9VoIbSWNexLYubbuteAL23l3YN00wUL7Wt4ZfEZu2f/lWtGB9yC9BfKLXzudq8MvGkrS0szmV0bc1VQ==", "cpu": [ - "x64" + "arm64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@oven/bun-linux-x64-musl-baseline": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.2.21.tgz", - "integrity": "sha512-0NzMg4XdXgujDM2jZogiV6MgACXW0a0NfB+o6fxwmUzdmMBUk1ZMRzypUi4XKjGUe89mYcPJcVFQRRnNwzTK/Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@oven/bun-windows-x64": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.2.21.tgz", - "integrity": "sha512-DZVCXrZGN/B4JnVnieZin1Kxse1wOkf+Fm2hDGpZHzs27ECbw5xPMFIc0r/oCpxTc/InxuvYO9UGoOmvhFaHsQ==", - "cpu": [ - "x64" - ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "win32" - ], - "peer": true + ] }, - "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.2.21.tgz", - "integrity": "sha512-sTnkLdThgsa6X8ib6eb3+zgy+CGJOibK6Th4wV2wmZFi5af6TM+digEi9i+q/X3nabGwPXm0V4vBiVpvcFilsA==", + "node_modules/@oxlint/win32-x64": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@oxlint/win32-x64/-/win32-x64-1.22.0.tgz", + "integrity": "sha512-nf8EZnIUgIrHlP9k26iOFMZZPoJG16KqZBXu5CG5YTAtVcu4CWlee9Q/cOS/rgQNGjLF+WPw8sVA5P3iGlYGQQ==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "win32" - ], - "peer": true + ] }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "node_modules/@types/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", "dev": true, "license": "MIT" }, - "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "node_modules/@types/make-fetch-happen": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@types/make-fetch-happen/-/make-fetch-happen-10.0.4.tgz", + "integrity": "sha512-jKzweQaEMMAi55ehvR1z0JF6aSVQm/h1BXBhPLOJriaeQBctjw5YbpIGs7zAx9dN0Sa2OO5bcXwCkrlgenoPEA==", "dev": true, - "license": "MIT" + "dependencies": { + "@types/node-fetch": "*", + "@types/retry": "*", + "@types/ssri": "*" + } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/@types/node": { + "version": "24.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", + "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "dev": true, - "license": "MIT" + "dependencies": { + "undici-types": "~7.16.0" + } }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", "dev": true, - "license": "MIT" + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.0.tgz", - "integrity": "sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==", + "node_modules/@types/node-forge": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/npm-package-arg": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/npm-package-arg/-/npm-package-arg-6.1.4.tgz", + "integrity": "sha512-vDgdbMy2QXHnAruzlv68pUtXCjmqUk3WrBAsRboRovsOmxbfn/WiYCjmecyKjGztnMps5dWp4Uq2prp+Ilo17Q==", + "dev": true + }, + "node_modules/@types/npm-registry-fetch": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/@types/npm-registry-fetch/-/npm-registry-fetch-8.0.9.tgz", + "integrity": "sha512-7NxvodR5Yrop3pb6+n8jhJNyzwOX0+6F+iagNEoi9u1CGxruYAwZD8pvGc9prIkL0+FdX5Xp0p80J9QPrGUp/g==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/node-fetch": "*", + "@types/npm-package-arg": "*", + "@types/npmlog": "*", + "@types/ssri": "*" + } + }, + "node_modules/@types/npmlog": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@types/npmlog/-/npmlog-7.0.0.tgz", + "integrity": "sha512-hJWbrKFvxKyWwSUXjZMYTINsSOY6IclhvGOZ97M8ac2tmR9hMwmTnYaMdpGhvju9ctWLTPhCS+eLfQNluiEjQQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", + "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", + "dev": true + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true + }, + "node_modules/@types/ssri": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@types/ssri/-/ssri-7.1.5.tgz", + "integrity": "sha512-odD/56S3B51liILSk5aXJlnYt99S6Rt9EFDDqGtJM26rKHApHcwyU/UoYHrzKkdkHMAIquGWCuHtQTbes+FRQw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@yao-pkg/pkg": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-6.10.1.tgz", + "integrity": "sha512-M/eqDg0Iir2nmyZ06Q9ospIPv1Yk7K1du5iLiaYrfMogQcI6bqf82A026MVYngyLH8jZsquZvjNAbvgbW4Uwkw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.32.0", - "@typescript-eslint/type-utils": "8.32.0", - "@typescript-eslint/utils": "8.32.0", - "@typescript-eslint/visitor-keys": "8.32.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "@babel/generator": "^7.23.0", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", + "@yao-pkg/pkg-fetch": "3.5.30", + "into-stream": "^6.0.0", + "minimist": "^1.2.6", + "multistream": "^4.1.0", + "picocolors": "^1.1.0", + "picomatch": "^4.0.2", + "prebuild-install": "^7.1.1", + "resolve": "^1.22.10", + "stream-meter": "^1.0.4", + "tar": "^7.4.3", + "tinyglobby": "^0.2.11", + "unzipper": "^0.12.3" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.0.tgz", - "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.32.0", - "@typescript-eslint/types": "8.32.0", - "@typescript-eslint/typescript-estree": "8.32.0", - "@typescript-eslint/visitor-keys": "8.32.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.0.tgz", - "integrity": "sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.32.0", - "@typescript-eslint/visitor-keys": "8.32.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.0.tgz", - "integrity": "sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "8.32.0", - "@typescript-eslint/utils": "8.32.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.0.tgz", - "integrity": "sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.0.tgz", - "integrity": "sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.32.0", - "@typescript-eslint/visitor-keys": "8.32.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.0.tgz", - "integrity": "sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.32.0", - "@typescript-eslint/types": "8.32.0", - "@typescript-eslint/typescript-estree": "8.32.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.0.tgz", - "integrity": "sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.32.0", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", "bin": { - "acorn": "bin/acorn" + "pkg": "lib-es5/bin.js" }, "engines": { - "node": ">=0.4.0" + "node": ">=18.0.0" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@yao-pkg/pkg-fetch": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.30.tgz", + "integrity": "sha512-OrXQlsR3vE/IvwXSk8R5ETYbcxAFtUPmLkeepbG+ArN82TvlIwcUJ65tEWxLG3Tl89VRbmOupuhkXfmuaO05+Q==", "dev": true, "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.6", + "picocolors": "^1.1.0", + "progress": "^2.0.3", + "semver": "^7.3.5", + "tar-fs": "^3.1.1", + "yargs": "^16.2.0" + }, + "bin": { + "pkg-fetch": "lib-es5/bin.js" + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" } }, "node_modules/agent-base": { @@ -834,27 +928,11 @@ "node": ">= 14" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -864,6 +942,7 @@ "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" @@ -875,263 +954,224 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" }, "engines": { - "node": ">= 0.4" + "bare": ">=1.16.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } } }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "bare-os": "^3.0.1" } }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" + "streamx": "^2.21.0" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } } }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "bare-path": "^3.0.0" } }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "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/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" } }, - "node_modules/bun": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/bun/-/bun-1.2.21.tgz", - "integrity": "sha512-y0lJ02dS90U3PJm+7KAKY8Se95AQvP5Xm77LouUwrpNOHpv59kBG4SK1+9iE1cAhpUaFipq+0EJ56S6MmE3row==", - "cpu": [ - "arm64", - "x64", - "aarch64" - ], - "hasInstallScript": true, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", - "os": [ - "darwin", - "linux", - "win32" - ], - "peer": true, - "bin": { - "bun": "bin/bun.exe", - "bunx": "bin/bunx.exe" + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "1.2.21", - "@oven/bun-darwin-x64": "1.2.21", - "@oven/bun-darwin-x64-baseline": "1.2.21", - "@oven/bun-linux-aarch64": "1.2.21", - "@oven/bun-linux-aarch64-musl": "1.2.21", - "@oven/bun-linux-x64": "1.2.21", - "@oven/bun-linux-x64-baseline": "1.2.21", - "@oven/bun-linux-x64-musl": "1.2.21", - "@oven/bun-linux-x64-musl-baseline": "1.2.21", - "@oven/bun-windows-x64": "1.2.21", - "@oven/bun-windows-x64-baseline": "1.2.21" + "engines": { + "node": ">= 6" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" } }, "node_modules/cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", + "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", "license": "ISC", "dependencies": { - "@npmcli/fs": "^4.0.0", + "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" + "ssri": "^13.0.0", + "unique-filename": "^5.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/call-bind-apply-helpers": { @@ -1139,7 +1179,6 @@ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, - "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -1148,31 +1187,13 @@ "node": ">= 0.4" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, + "node_modules/certifi": { + "version": "14.5.15", + "resolved": "https://registry.npmjs.org/certifi/-/certifi-14.5.15.tgz", + "integrity": "sha512-NeLXuKCqSzwQNjpJ+WaSp5m8ntdTKJ8HnBu+eA7DxHfgzU7F1sjwrJFang+4U38+vmWbiFUpPZMV3uwwnHAisQ==", + "license": "MPL-2.0", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, "node_modules/chalk": { @@ -1190,42 +1211,29 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "license": "MIT", + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" } }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1238,75 +1246,27 @@ "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/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "delayed-stream": "~1.0.0" }, "engines": { - "node": ">= 8" + "node": ">= 0.8" } }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "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", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, "node_modules/debug": { "version": "4.4.0", @@ -1325,60 +1285,49 @@ } } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "dev": true, "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" + "mimic-response": "^3.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4.0.0" } }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, "node_modules/dunder-proto": { @@ -1386,7 +1335,6 @@ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, - "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -1396,16 +1344,21 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/encoding": { @@ -1418,17 +1371,14 @@ "iconv-lite": "^0.6.2" } }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" + "once": "^1.4.0" } }, "node_modules/err-code": { @@ -1437,81 +1387,11 @@ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "license": "MIT" }, - "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" } @@ -1521,7 +1401,6 @@ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" } @@ -1531,7 +1410,6 @@ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dev": true, - "license": "MIT", "dependencies": { "es-errors": "^1.3.0" }, @@ -1544,7 +1422,6 @@ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, - "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -1555,481 +1432,150 @@ "node": ">= 0.4" } }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.35.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", - "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.35.0", - "@eslint/plugin-kit": "^0.3.5", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, "bin": { - "eslint": "bin/eslint.js" + "esbuild": "bin/esbuild" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" } }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } + "node": ">=6" } }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "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", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "bare-events": "^2.7.0" } }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { + "node_modules/expand-template": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "dev": true, - "license": "BSD-2-Clause", + "license": "(MIT OR WTFPL)", "engines": { - "node": ">=0.10.0" + "node": ">=6" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, "engines": { - "node": ">=8.6.0" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, - "license": "ISC", "dependencies": { - "is-glob": "^4.0.1" + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" }, "engines": { "node": ">= 6" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, "license": "MIT" }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "node_modules/fs-extra": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=14.14" } }, "node_modules/fs-minipass": { @@ -2049,52 +1595,18 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, + "license": "ISC", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", - "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "6.* || 8.* || >= 10.*" } }, "node_modules/get-intrinsic": { @@ -2102,7 +1614,6 @@ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -2127,7 +1638,6 @@ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, - "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -2136,108 +1646,35 @@ "node": ">= 0.4" } }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "license": "ISC", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globals": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.1.0.tgz", - "integrity": "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -2245,71 +1682,18 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "MIT" - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "ISC" }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -2322,7 +1706,6 @@ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, - "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" }, @@ -2338,7 +1721,6 @@ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, - "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -2347,15 +1729,15 @@ } }, "node_modules/hosted-git-info": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", - "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", "license": "ISC", "dependencies": { - "lru-cache": "^10.0.1" + "lru-cache": "^11.1.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/http-cache-semantics": { @@ -2390,32 +1772,39 @@ "node": ">= 14" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { - "node": ">= 4" + "node": ">=0.10.0" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" }, "node_modules/imurmurhash": { "version": "0.1.4", @@ -2426,118 +1815,48 @@ "node": ">=0.8.19" } }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/into-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-6.0.0.tgz", + "integrity": "sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" + "from2": "^2.3.0", + "p-is-promise": "^3.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, "engines": { "node": ">= 12" } }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -2554,409 +1873,47 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "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" + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" }, "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "license": "MIT" - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" + "graceful-fs": "^4.1.6" } }, "node_modules/jsonparse": { @@ -2968,107 +1925,35 @@ ], "license": "MIT" }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } }, "node_modules/make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz", + "integrity": "sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==", "license": "ISC", "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", + "@npmcli/agent": "^4.0.0", + "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", + "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", - "proc-log": "^5.0.0", + "proc-log": "^6.0.0", "promise-retry": "^2.0.1", - "ssri": "^12.0.0" + "ssri": "^13.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/math-intrinsics": { @@ -3076,58 +1961,57 @@ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, - "license": "MIT", "engines": { - "node": ">= 8" + "node": ">= 0.6" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, - "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "mime-db": "1.52.0" }, "engines": { - "node": ">=8.6" + "node": ">= 0.6" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": "*" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -3162,9 +2046,9 @@ } }, "node_modules/minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.0.tgz", + "integrity": "sha512-fiCdUALipqgPWrOVTz9fw0XhcazULXOSU6ie40DDbX1F49p1dBrSRBuswndTx1x3vEb/g0FT7vC4c4C2u/mh3A==", "license": "MIT", "dependencies": { "minipass": "^7.0.3", @@ -3172,7 +2056,7 @@ "minizlib": "^3.0.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" }, "optionalDependencies": { "encoding": "^0.1.13" @@ -3202,12 +2086,6 @@ "node": ">=8" } }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/minipass-pipeline": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", @@ -3232,12 +2110,6 @@ "node": ">=8" } }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/minipass-sized": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", @@ -3262,16 +2134,10 @@ "node": ">=8" } }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -3280,20 +2146,12 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", @@ -3301,16 +2159,56 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multistream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/multistream/-/multistream-4.1.0.tgz", + "integrity": "sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "once": "^1.4.0", + "readable-stream": "^3.6.0" + } + }, + "node_modules/multistream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/nan": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", "license": "MIT" }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "dev": true, "license": "MIT" }, @@ -3323,15 +2221,56 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz", + "integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==", "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-pty": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", @@ -3343,281 +2282,97 @@ } }, "node_modules/npm-package-arg": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", - "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", "license": "ISC", "dependencies": { - "hosted-git-info": "^8.0.0", - "proc-log": "^5.0.0", + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" + "validate-npm-package-name": "^7.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm-registry-fetch": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", - "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", "license": "ISC", "dependencies": { - "@npmcli/redact": "^3.0.0", + "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", - "make-fetch-happen": "^14.0.0", + "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", + "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", - "npm-package-arg": "^12.0.0", - "proc-log": "^5.0.0" + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/oxlint": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.22.0.tgz", + "integrity": "sha512-/HYT1Cfanveim9QUM6KlPKJe9y+WPnh3SxIB7z1InWnag9S0nzxLaWEUiW1P4UGzh/No3KvtNmBv2IOiwAl2/w==", + "dev": true, + "bin": { + "oxc_language_server": "bin/oxc_language_server", + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/darwin-arm64": "1.22.0", + "@oxlint/darwin-x64": "1.22.0", + "@oxlint/linux-arm64-gnu": "1.22.0", + "@oxlint/linux-arm64-musl": "1.22.0", + "@oxlint/linux-x64-gnu": "1.22.0", + "@oxlint/linux-x64-musl": "1.22.0", + "@oxlint/win32-arm64": "1.22.0", + "@oxlint/win32-x64": "1.22.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.2.0" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } + } + }, + "node_modules/p-is-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", + "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ora/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "license": "MIT" - }, - "node_modules/ora/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/p-map": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", - "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "license": "MIT", "engines": { "node": ">=18" @@ -3626,44 +2381,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3672,61 +2389,144 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "dev": true, "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, "engines": { - "node": ">= 0.4" + "node": ">=10" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/prebuild-install/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/prebuild-install/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, "engines": { - "node": ">= 0.8.0" + "node": ">= 6" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" } }, "node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" } }, "node_modules/promise-retry": { @@ -3742,89 +2542,74 @@ "node": ">=10" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "end-of-stream": "^1.1.0", + "once": "^1.3.1" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -3838,47 +2623,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -3888,95 +2632,12 @@ "node": ">= 4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -3997,162 +2658,51 @@ "node": ">=10" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { + "node_modules/simple-concat": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" } }, "node_modules/smart-buffer": { @@ -4166,12 +2716,12 @@ } }, "node_modules/socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "license": "MIT", "dependencies": { - "ip-address": "^9.0.5", + "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -4193,54 +2743,55 @@ "node": ">= 14" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" - }, "node_modules/ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", + "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", "license": "ISC", "dependencies": { "minipass": "^7.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "node_modules/stream-meter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz", + "integrity": "sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "readable-stream": "^2.1.4" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" } }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -4251,139 +2802,27 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "node": ">=0.10.0" } }, "node_modules/supports-preserve-symlinks-flag": { @@ -4400,159 +2839,111 @@ } }, "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", - "license": "ISC", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { "node": ">=18" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "dev": true, "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "pump": "^3.0.0", + "tar-stream": "^3.1.5" }, - "engines": { - "node": ">=8.0" + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" } }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" + "node": ">=12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "safe-buffer": "^5.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "*" } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4561,227 +2952,99 @@ "node": ">=14.17" } }, - "node_modules/typescript-eslint": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.32.0.tgz", - "integrity": "sha512-UMq2kxdXCzinFFPsXc9o2ozIpYCCOiEC46MG3yEh5Vipq6BO27otTtEBZA1fQ66DulEUgE97ucQ/3YY66CPg0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.32.0", - "@typescript-eslint/parser": "8.32.0", - "@typescript-eslint/utils": "8.32.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true }, "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", + "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", "license": "ISC", "dependencies": { - "unique-slug": "^5.0.0" + "unique-slug": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", + "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" + "license": "MIT", + "engines": { + "node": ">= 10.0.0" } }, + "node_modules/unzipper": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "^11.2.0", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/validate-npm-package-name": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.0.tgz", - "integrity": "sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.0.tgz", + "integrity": "sha512-bwVk/OK+Qu108aJcMAEiU4yavHUI7aN20TgZNBj9MR2iU1zPUl1Z1Otr7771ExfYTPTvfN8ZJ1pbr5Iklgt4xg==", "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.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/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "dev": true, "license": "MIT", "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" } }, "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -4795,88 +3058,56 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "license": "MIT", + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=10" } }, "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dev": true, "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, "packages/safe-chain": { @@ -4884,35 +3115,56 @@ "version": "1.0.0", "license": "AGPL-3.0-or-later", "dependencies": { - "abbrev": "3.0.1", + "certifi": "14.5.15", "chalk": "5.4.1", "https-proxy-agent": "7.0.6", - "make-fetch-happen": "14.0.3", - "node-forge": "1.3.1", - "npm-registry-fetch": "18.0.2", - "ora": "8.2.0", + "ini": "6.0.0", + "make-fetch-happen": "15.0.3", + "node-forge": "1.3.2", + "npm-registry-fetch": "19.1.1", "semver": "7.7.2" }, "bin": { + "aikido-bun": "bin/aikido-bun.js", + "aikido-bunx": "bin/aikido-bunx.js", "aikido-npm": "bin/aikido-npm.js", "aikido-npx": "bin/aikido-npx.js", + "aikido-pip": "bin/aikido-pip.js", + "aikido-pip3": "bin/aikido-pip3.js", "aikido-pnpm": "bin/aikido-pnpm.js", "aikido-pnpx": "bin/aikido-pnpx.js", + "aikido-python": "bin/aikido-python.js", + "aikido-python3": "bin/aikido-python3.js", + "aikido-uv": "bin/aikido-uv.js", "aikido-yarn": "bin/aikido-yarn.js", "safe-chain": "bin/safe-chain.js" + }, + "devDependencies": { + "@types/ini": "^4.1.1", + "@types/make-fetch-happen": "^10.0.4", + "@types/node": "^18.19.130", + "@types/node-forge": "^1.3.14", + "@types/npm-registry-fetch": "^8.0.9", + "@types/semver": "^7.7.1", + "esbuild": "^0.27.0", + "typescript": "^5.9.3" } }, - "packages/safe-chain-bun": { - "name": "@aikidosec/safe-chain-bun", - "version": "1.0.0", - "license": "AGPL-3.0-or-later", + "packages/safe-chain/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, "dependencies": { - "@aikidosec/safe-chain": "file:../safe-chain" - }, - "peerDependencies": { - "bun": ">=1.2.21" + "undici-types": "~5.26.4" } }, + "packages/safe-chain/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "test/e2e": { "name": "@aikidosec/safe-chain-e2e-tests", "version": "1.0.0", diff --git a/package.json b/package.json index ad71644..2793f9c 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,10 @@ "test/e2e" ], "scripts": { - "test": "npm run test --workspace=packages/safe-chain --workspace=packages/safe-chain-bun", + "test": "npm run test --workspace=packages/safe-chain", "test:e2e": "npm run test --workspace=test/e2e", - "lint": "npm run lint --workspace=packages/safe-chain" + "lint": "npm run lint --workspace=packages/safe-chain", + "typecheck": "npm run typecheck --workspace=packages/safe-chain" }, "repository": { "type": "git", @@ -18,13 +19,8 @@ "author": "Aikido Security", "license": "AGPL-3.0-or-later", "devDependencies": { - "@eslint/js": "^9.35.0", - "eslint": "^9.35.0", - "eslint-plugin-import": "^2.32.0", - "globals": "^16.1.0", - "typescript-eslint": "^8.32.0" - }, - "overrides": { - "brace-expansion@<=2.0.2": "2.0.2" + "oxlint": "^1.22.0", + "esbuild": "^0.27.0", + "@yao-pkg/pkg": "6.10.1" } } diff --git a/packages/safe-chain-bun/package.json b/packages/safe-chain-bun/package.json deleted file mode 100644 index b5a9e3e..0000000 --- a/packages/safe-chain-bun/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@aikidosec/safe-chain-bun", - "version": "1.0.0", - "type": "module", - "main": "src/index.js", - "scripts": { - "test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'" - }, - "exports": { - ".": { - "bun": "./src/index.js", - "default": "./src/index.js" - } - }, - "keywords": ["bun", "security", "scanner", "malware", "aikido"], - "author": "Aikido Security", - "license": "AGPL-3.0-or-later", - "description": "Aikido Security Scanner for Bun package manager - detects malware and security threats during package installation", - "repository": { - "type": "git", - "url": "git+https://github.com/AikidoSec/safe-chain.git", - "directory": "packages/safe-chain-bun" - }, - "dependencies": { - "@aikidosec/safe-chain": "file:../safe-chain" - }, - "peerDependencies": { - "bun": ">=1.2.21" - } -} \ No newline at end of file diff --git a/packages/safe-chain-bun/src/index.js b/packages/safe-chain-bun/src/index.js deleted file mode 100644 index fbd0f65..0000000 --- a/packages/safe-chain-bun/src/index.js +++ /dev/null @@ -1,37 +0,0 @@ -import { auditChanges } from "@aikidosec/safe-chain/scanning"; - -// Bun Security Scanner for Safe-Chain -// This is the entry point for Bun's native security scanner integration - -export const scanner = { - version: "1", // Our scanner is using version 1 of the bun security scanner API. - - async scan({ packages }) { - const advisories = []; - - try { - const changes = packages.map((pkg) => ({ - name: pkg.name, - version: pkg.version, - type: "add", - })); - - const audit = await auditChanges(changes); - - if (!audit.isAllowed) { - for (const change of audit.disallowedChanges) { - advisories.push({ - level: "fatal", // Fatal will block the installation process, this is what we want for packages that contain malware. - package: change.name, - url: null, - description: `Package ${change.name}@${change.version} contains known security threats (${change.reason}). Installation blocked by Safe-Chain.`, - }); - } - } - } catch (error) { - console.warn(`Safe-Chain security scan failed: ${error.message}`); - } - - return advisories; - }, -}; diff --git a/packages/safe-chain-bun/src/index.spec.js b/packages/safe-chain-bun/src/index.spec.js deleted file mode 100644 index 3293b56..0000000 --- a/packages/safe-chain-bun/src/index.spec.js +++ /dev/null @@ -1,140 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it, mock } from "node:test"; - -describe("Bun Scanner", async () => { - const mockAuditChanges = mock.fn(); - - // Mock the scanning module - mock.module("@aikidosec/safe-chain/scanning", { - namedExports: { - auditChanges: mockAuditChanges, - }, - }); - - const { scanner } = await import("./index.js"); - - it("should export scanner object with version", () => { - assert.strictEqual(scanner.version, "1"); - assert.strictEqual(typeof scanner.scan, "function"); - }); - - it("should return empty advisories for clean packages", async () => { - mockAuditChanges.mock.mockImplementation(() => ({ - allowedChanges: [{ name: "express", version: "4.18.2", type: "add" }], - disallowedChanges: [], - isAllowed: true, - })); - - const packages = [{ name: "express", version: "4.18.2" }]; - const result = await scanner.scan({ packages }); - - assert.deepEqual(result, []); - assert.strictEqual(mockAuditChanges.mock.callCount(), 1); - assert.deepEqual(mockAuditChanges.mock.calls[0].arguments[0], [ - { name: "express", version: "4.18.2", type: "add" }, - ]); - }); - - it("should return fatal advisory for malware packages", async () => { - mockAuditChanges.mock.mockImplementation(() => ({ - allowedChanges: [], - disallowedChanges: [ - { - name: "malicious-pkg", - version: "1.0.0", - type: "add", - reason: "MALWARE", - }, - ], - isAllowed: false, - })); - - const packages = [{ name: "malicious-pkg", version: "1.0.0" }]; - const result = await scanner.scan({ packages }); - - assert.strictEqual(result.length, 1); - assert.deepEqual(result[0], { - level: "fatal", - package: "malicious-pkg", - url: null, - description: "Package malicious-pkg@1.0.0 contains known security threats (MALWARE). Installation blocked by Safe-Chain.", - }); - }); - - it("should handle multiple packages with mixed results", async () => { - mockAuditChanges.mock.mockImplementation(() => ({ - allowedChanges: [{ name: "express", version: "4.18.2", type: "add" }], - disallowedChanges: [ - { - name: "malicious-pkg", - version: "1.0.0", - type: "add", - reason: "MALWARE", - }, - { - name: "another-bad-pkg", - version: "2.1.0", - type: "add", - reason: "MALWARE", - }, - ], - isAllowed: false, - })); - - const packages = [ - { name: "express", version: "4.18.2" }, - { name: "malicious-pkg", version: "1.0.0" }, - { name: "another-bad-pkg", version: "2.1.0" }, - ]; - const result = await scanner.scan({ packages }); - - assert.strictEqual(result.length, 2); - assert.strictEqual(result[0].package, "malicious-pkg"); - assert.strictEqual(result[0].level, "fatal"); - assert.strictEqual(result[1].package, "another-bad-pkg"); - assert.strictEqual(result[1].level, "fatal"); - }); - - it("should handle empty package list", async () => { - mockAuditChanges.mock.mockImplementation(() => ({ - allowedChanges: [], - disallowedChanges: [], - isAllowed: true, - })); - - const result = await scanner.scan({ packages: [] }); - - assert.deepEqual(result, []); - assert.deepEqual( - mockAuditChanges.mock.calls[mockAuditChanges.mock.callCount() - 1] - .arguments[0], - [] - ); - }); - - it("should convert Bun package format to safe-chain format correctly", async () => { - mockAuditChanges.mock.mockImplementation(() => ({ - allowedChanges: [], - disallowedChanges: [], - isAllowed: true, - })); - - const bunPackages = [ - { name: "lodash", version: "4.17.21" }, - { name: "@types/node", version: "20.0.0" }, - ]; - - await scanner.scan({ packages: bunPackages }); - - const expectedChanges = [ - { name: "lodash", version: "4.17.21", type: "add" }, - { name: "@types/node", version: "20.0.0", type: "add" }, - ]; - - assert.deepEqual( - mockAuditChanges.mock.calls[mockAuditChanges.mock.callCount() - 1] - .arguments[0], - expectedChanges - ); - }); -}); diff --git a/packages/safe-chain/bin/aikido-bun.js b/packages/safe-chain/bin/aikido-bun.js index 01e3972..9d11784 100755 --- a/packages/safe-chain/bin/aikido-bun.js +++ b/packages/safe-chain/bin/aikido-bun.js @@ -2,9 +2,13 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "bun"; initializePackageManager(packageManagerName); -var exitCode = await main(process.argv.slice(2)); -process.exit(exitCode); +(async () => { + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/aikido-bunx.js b/packages/safe-chain/bin/aikido-bunx.js index fb378e5..bcc93a6 100755 --- a/packages/safe-chain/bin/aikido-bunx.js +++ b/packages/safe-chain/bin/aikido-bunx.js @@ -2,9 +2,13 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "bunx"; initializePackageManager(packageManagerName); -var exitCode = await main(process.argv.slice(2)); -process.exit(exitCode); +(async () => { + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/aikido-npm.js b/packages/safe-chain/bin/aikido-npm.js index d8b8c3e..7916f7e 100755 --- a/packages/safe-chain/bin/aikido-npm.js +++ b/packages/safe-chain/bin/aikido-npm.js @@ -1,21 +1,14 @@ #!/usr/bin/env node -import { execSync } from "child_process"; import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "npm"; -initializePackageManager(packageManagerName, getNpmVersion()); -var exitCode = await main(process.argv.slice(2)); +initializePackageManager(packageManagerName); -process.exit(exitCode); - -function getNpmVersion() { - try { - return execSync("npm --version").toString().trim(); - } catch { - // Default to 0.0.0 if npm is not found - // That way we don't use any unsupported features - return "0.0.0"; - } -} +(async () => { + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/aikido-npx.js b/packages/safe-chain/bin/aikido-npx.js index 7f06c7c..58f3491 100755 --- a/packages/safe-chain/bin/aikido-npx.js +++ b/packages/safe-chain/bin/aikido-npx.js @@ -2,9 +2,13 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "npx"; -initializePackageManager(packageManagerName, process.versions.node); -var exitCode = await main(process.argv.slice(2)); +initializePackageManager(packageManagerName); -process.exit(exitCode); +(async () => { + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/aikido-pip.js b/packages/safe-chain/bin/aikido-pip.js new file mode 100755 index 0000000..6eb3e4e --- /dev/null +++ b/packages/safe-chain/bin/aikido-pip.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; +import { PIP_PACKAGE_MANAGER, PIP_COMMAND } from "../src/packagemanager/pip/pipSettings.js"; + +// Set eco system +setEcoSystem(ECOSYSTEM_PY); + +initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PIP_COMMAND, args: process.argv.slice(2) }); + +(async () => { + // Pass through only user-supplied pip args + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/aikido-pip3.js b/packages/safe-chain/bin/aikido-pip3.js new file mode 100755 index 0000000..510b688 --- /dev/null +++ b/packages/safe-chain/bin/aikido-pip3.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; +import { PIP_PACKAGE_MANAGER, PIP3_COMMAND } from "../src/packagemanager/pip/pipSettings.js"; + +// Set eco system +setEcoSystem(ECOSYSTEM_PY); + +initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PIP3_COMMAND, args: process.argv.slice(2) }); + +(async () => { + // Pass through only user-supplied pip args + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/aikido-pnpm.js b/packages/safe-chain/bin/aikido-pnpm.js index 7177159..64bc755 100755 --- a/packages/safe-chain/bin/aikido-pnpm.js +++ b/packages/safe-chain/bin/aikido-pnpm.js @@ -2,9 +2,13 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "pnpm"; -initializePackageManager(packageManagerName, process.versions.node); -var exitCode = await main(process.argv.slice(2)); +initializePackageManager(packageManagerName); -process.exit(exitCode); +(async () => { + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/aikido-pnpx.js b/packages/safe-chain/bin/aikido-pnpx.js index 4bb6840..11ee45c 100755 --- a/packages/safe-chain/bin/aikido-pnpx.js +++ b/packages/safe-chain/bin/aikido-pnpx.js @@ -2,9 +2,13 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "pnpx"; -initializePackageManager(packageManagerName, process.versions.node); -var exitCode = await main(process.argv.slice(2)); +initializePackageManager(packageManagerName); -process.exit(exitCode); +(async () => { + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/aikido-python.js b/packages/safe-chain/bin/aikido-python.js new file mode 100755 index 0000000..b769b4a --- /dev/null +++ b/packages/safe-chain/bin/aikido-python.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { PIP_PACKAGE_MANAGER, PYTHON_COMMAND } from "../src/packagemanager/pip/pipSettings.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; +import { main } from "../src/main.js"; + +// Set eco system +setEcoSystem(ECOSYSTEM_PY); + +// Strip nodejs and wrapper script from args +let argv = process.argv.slice(2); + +initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PYTHON_COMMAND, args: argv }); + +(async () => { + var exitCode = await main(argv); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/aikido-python3.js b/packages/safe-chain/bin/aikido-python3.js new file mode 100755 index 0000000..c572a7b --- /dev/null +++ b/packages/safe-chain/bin/aikido-python3.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { PIP_PACKAGE_MANAGER, PYTHON3_COMMAND } from "../src/packagemanager/pip/pipSettings.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; +import { main } from "../src/main.js"; + +// Set eco system +setEcoSystem(ECOSYSTEM_PY); + +// Strip nodejs and wrapper script from args +let argv = process.argv.slice(2); + +initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PYTHON3_COMMAND, args: argv }); + +(async () => { + var exitCode = await main(argv); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/aikido-uv.js b/packages/safe-chain/bin/aikido-uv.js new file mode 100755 index 0000000..4e635de --- /dev/null +++ b/packages/safe-chain/bin/aikido-uv.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; + +// Set eco system +setEcoSystem(ECOSYSTEM_PY); + +initializePackageManager("uv"); + +(async () => { + // Pass through only user-supplied uv args + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/aikido-yarn.js b/packages/safe-chain/bin/aikido-yarn.js index 002a956..6c428db 100755 --- a/packages/safe-chain/bin/aikido-yarn.js +++ b/packages/safe-chain/bin/aikido-yarn.js @@ -2,9 +2,13 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "yarn"; -initializePackageManager(packageManagerName, process.versions.node); -var exitCode = await main(process.argv.slice(2)); +initializePackageManager(packageManagerName); -process.exit(exitCode); +(async () => { + 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 5a7d94b..2793987 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -5,6 +5,28 @@ import { ui } from "../src/environment/userInteraction.js"; import { setup } from "../src/shell-integration/setup.js"; import { teardown } from "../src/shell-integration/teardown.js"; import { setupCi } from "../src/shell-integration/setup-ci.js"; +import { initializeCliArguments } from "../src/config/cliArguments.js"; +import { setEcoSystem } from "../src/config/settings.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { main } from "../src/main.js"; +import path from "path"; +import { fileURLToPath } from "url"; +import fs from "fs"; +import { knownAikidoTools } from "../src/shell-integration/helpers.js"; + +/** @type {string} */ +// This checks the current file's dirname in a way that's compatible with: +// - Modulejs (import.meta.url) +// - ES modules (__dirname) +// This is needed because safe-chain's npm package is built using ES modules, +// but building the binaries requires commonjs. +let dirname; +if (import.meta.url) { + const filename = fileURLToPath(import.meta.url); + dirname = path.dirname(filename); +} else { + dirname = __dirname; +} if (process.argv.length < 3) { ui.writeError("No command provided. Please provide a command to execute."); @@ -13,19 +35,38 @@ if (process.argv.length < 3) { process.exit(1); } +initializeCliArguments(process.argv); + const command = process.argv[2]; -if (command === "help" || command === "--help" || command === "-h") { +const tool = knownAikidoTools.find((tool) => tool.tool === command); + +if (tool) { + const args = process.argv.slice(3); + + setEcoSystem(tool.ecoSystem); + + // Provide tool context to PM (pip uses this; others ignore) + const toolContext = { tool: tool.tool, args }; + initializePackageManager(tool.internalPackageManagerName, toolContext); + + (async () => { + var exitCode = await main(args); + process.exit(exitCode); + })(); +} else if (command === "help" || command === "--help" || command === "-h") { writeHelp(); process.exit(0); -} - -if (command === "setup") { +} else if (command === "setup") { setup(); } else if (command === "teardown") { teardown(); } else if (command === "setup-ci") { setupCi(); +} else if (command === "--version" || command === "-v" || command === "-v") { + (async () => { + ui.writeInformation(`Current safe-chain version: ${await getVersion()}`); + })(); } else { ui.writeError(`Unknown command: ${command}.`); ui.emptyLine(); @@ -43,13 +84,20 @@ function writeHelp() { ui.writeInformation( `Available commands: ${chalk.cyan("setup")}, ${chalk.cyan( "teardown" - )}, ${chalk.cyan("help")}` + )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan( + "--version" + )}` ); ui.emptyLine(); ui.writeInformation( `- ${chalk.cyan( "safe-chain setup" - )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm and pnpx.` + )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.` + ); + ui.writeInformation( + ` ${chalk.yellow( + "--include-python" + )}: Experimental: include Python package managers (pip, pip3) in the setup.` ); ui.writeInformation( `- ${chalk.cyan( @@ -61,5 +109,28 @@ function writeHelp() { "safe-chain setup-ci" )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.` ); + ui.writeInformation( + ` ${chalk.yellow( + "--include-python" + )}: Experimental: include Python package managers (pip, pip3) in the setup.` + ); + ui.writeInformation( + `- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan( + "-v" + )}): Display the current version of safe-chain.` + ); ui.emptyLine(); } + +async function getVersion() { + const packageJsonPath = path.join(dirname, "..", "package.json"); + + const data = await fs.promises.readFile(packageJsonPath); + const json = JSON.parse(data.toString("utf8")); + + if (json && json.version) { + return json.version; + } + + return "0.0.0"; +} diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index 6a927d4..5353635 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -4,7 +4,8 @@ "scripts": { "test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'", "test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'", - "lint": "eslint ." + "lint": "oxlint --deny-warnings", + "typecheck": "tsc --noEmit" }, "bin": { "aikido-npm": "bin/aikido-npm.js", @@ -14,6 +15,11 @@ "aikido-pnpx": "bin/aikido-pnpx.js", "aikido-bun": "bin/aikido-bun.js", "aikido-bunx": "bin/aikido-bunx.js", + "aikido-uv": "bin/aikido-uv.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", "safe-chain": "bin/safe-chain.js" }, "type": "module", @@ -28,17 +34,27 @@ "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), [bun](https://bun.sh/), and [bunx](https://bun.sh/docs/cli/bunx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, or bunx from downloading or running the malware.", + "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": { - "abbrev": "3.0.1", + "certifi": "14.5.15", "chalk": "5.4.1", "https-proxy-agent": "7.0.6", - "make-fetch-happen": "14.0.3", - "node-forge": "1.3.1", - "npm-registry-fetch": "18.0.2", - "ora": "8.2.0", + "ini": "6.0.0", + "make-fetch-happen": "15.0.3", + "node-forge": "1.3.2", + "npm-registry-fetch": "19.1.1", "semver": "7.7.2" }, + "devDependencies": { + "@types/ini": "^4.1.1", + "@types/make-fetch-happen": "^10.0.4", + "@types/node": "^18.19.130", + "@types/node-forge": "^1.3.14", + "@types/npm-registry-fetch": "^8.0.9", + "@types/semver": "^7.7.1", + "esbuild": "^0.27.0", + "typescript": "^5.9.3" + }, "main": "src/main.js", "bugs": { "url": "https://github.com/AikidoSec/safe-chain/issues" diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index c9eeea0..5c04360 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -1,12 +1,27 @@ import fetch from "make-fetch-happen"; +import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; -const malwareDatabaseUrl = - "https://malware-list.aikido.dev/malware_predictions.json"; +const malwareDatabaseUrls = { + [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json", + [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json", +}; +/** + * @typedef {Object} MalwarePackage + * @property {string} package_name + * @property {string} version + * @property {string} reason + */ + +/** + * @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>} + */ export async function fetchMalwareDatabase() { + const ecosystem = getEcoSystem(); + const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)]; const response = await fetch(malwareDatabaseUrl); if (!response.ok) { - throw new Error(`Error fetching malware database: ${response.statusText}`); + throw new Error(`Error fetching ${ecosystem} malware database: ${response.statusText}`); } try { @@ -15,18 +30,24 @@ export async function fetchMalwareDatabase() { malwareDatabase: malwareDatabase, version: response.headers.get("etag") || undefined, }; - } catch (error) { + } catch (/** @type {any} */ error) { throw new Error(`Error parsing malware database: ${error.message}`); } } +/** + * @returns {Promise} + */ export async function fetchMalwareDatabaseVersion() { + const ecosystem = getEcoSystem(); + const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)]; const response = await fetch(malwareDatabaseUrl, { method: "HEAD", }); + if (!response.ok) { throw new Error( - `Error fetching malware database version: ${response.statusText}` + `Error fetching ${ecosystem} malware database version: ${response.statusText}` ); } return response.headers.get("etag") || undefined; diff --git a/packages/safe-chain/src/api/npmApi.js b/packages/safe-chain/src/api/npmApi.js index 96bacc2..c03abef 100644 --- a/packages/safe-chain/src/api/npmApi.js +++ b/packages/safe-chain/src/api/npmApi.js @@ -1,6 +1,11 @@ import * as semver from "semver"; import * as npmFetch from "npm-registry-fetch"; +/** + * @param {string} packageName + * @param {string | null} [versionRange] + * @returns {Promise} + */ export async function resolvePackageVersion(packageName, versionRange) { if (!versionRange) { versionRange = "latest"; @@ -11,7 +16,10 @@ export async function resolvePackageVersion(packageName, versionRange) { return versionRange; } - const packageInfo = await getPackageInfo(packageName); + const packageInfo = ( + /** @type {{"dist-tags"?: Record, versions?: Record} | null} */ + await getPackageInfo(packageName) + ); if (!packageInfo) { // It is possible that no version is found (could be a private package, or a package that doesn't exist) // In this case, we return null to indicate that we couldn't resolve the version @@ -19,12 +27,16 @@ export async function resolvePackageVersion(packageName, versionRange) { } const distTags = packageInfo["dist-tags"]; - if (distTags && distTags[versionRange]) { + if (distTags && isDistTags(distTags) && distTags[versionRange]) { // If the version range is a dist-tag, return the version associated with that tag // e.g., "latest", "next", etc. return distTags[versionRange]; } + if (!packageInfo.versions) { + return null; + } + // If the version range is not a dist-tag, we need to resolve the highest version matching the range. // This is useful for ranges like "^1.0.0" or "~2.3.4". const availableVersions = Object.keys(packageInfo.versions); @@ -37,6 +49,19 @@ export async function resolvePackageVersion(packageName, versionRange) { return null; } +/** + * + * @param {unknown} distTags + * @returns {distTags is Record} + */ +function isDistTags(distTags) { + return typeof distTags === "object"; +} + +/** + * @param {string} packageName + * @returns {Promise | null>} + */ async function getPackageInfo(packageName) { try { return await npmFetch.json(packageName); diff --git a/packages/safe-chain/src/api/npmApi.spec.js b/packages/safe-chain/src/api/npmApi.spec.js new file mode 100644 index 0000000..0c7585d --- /dev/null +++ b/packages/safe-chain/src/api/npmApi.spec.js @@ -0,0 +1,211 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; + +describe("resolvePackageVersion", async () => { + const mockNpmFetchJson = mock.fn(); + + mock.module("npm-registry-fetch", { + namedExports: { + json: mockNpmFetchJson, + }, + }); + + const { resolvePackageVersion } = await import("./npmApi.js"); + + it("should return the version if it is already a fixed version", async () => { + const result = await resolvePackageVersion("express", "4.17.1"); + + assert.strictEqual(result, "4.17.1"); + }); + + it("should use 'latest' as default version range when not provided", async () => { + mockNpmFetchJson.mock.mockImplementationOnce(() => ({ + "dist-tags": { + latest: "4.18.2", + }, + versions: { + "4.18.2": {}, + }, + })); + + const result = await resolvePackageVersion("express"); + + assert.strictEqual(result, "4.18.2"); + }); + + it("should resolve dist-tag versions", async () => { + mockNpmFetchJson.mock.mockImplementationOnce(() => ({ + "dist-tags": { + latest: "4.18.2", + next: "5.0.0-beta.1", + }, + versions: { + "4.18.2": {}, + "5.0.0-beta.1": {}, + }, + })); + + const result = await resolvePackageVersion("express", "next"); + + assert.strictEqual(result, "5.0.0-beta.1"); + }); + + it("should resolve version ranges using semver", async () => { + mockNpmFetchJson.mock.mockImplementationOnce(() => ({ + "dist-tags": { + latest: "4.18.2", + }, + versions: { + "4.16.0": {}, + "4.17.0": {}, + "4.17.1": {}, + "4.18.0": {}, + "4.18.2": {}, + }, + })); + + const result = await resolvePackageVersion("express", "^4.17.0"); + + assert.strictEqual(result, "4.18.2"); + }); + + it("should resolve tilde ranges correctly", async () => { + mockNpmFetchJson.mock.mockImplementationOnce(() => ({ + "dist-tags": { + latest: "4.18.2", + }, + versions: { + "4.17.0": {}, + "4.17.1": {}, + "4.17.3": {}, + "4.18.0": {}, + }, + })); + + const result = await resolvePackageVersion("express", "~4.17.0"); + + assert.strictEqual(result, "4.17.3"); + }); + + it("should return null if package info cannot be fetched", async () => { + mockNpmFetchJson.mock.mockImplementationOnce(() => { + throw new Error("Package not found"); + }); + + const result = await resolvePackageVersion("non-existent-package", "latest"); + + assert.strictEqual(result, null); + }); + + it("should return null if no versions match the range", async () => { + mockNpmFetchJson.mock.mockImplementationOnce(() => ({ + "dist-tags": { + latest: "1.0.0", + }, + versions: { + "1.0.0": {}, + "1.1.0": {}, + }, + })); + + const result = await resolvePackageVersion("express", "^5.0.0"); + + assert.strictEqual(result, null); + }); + + it("should return null if dist-tag does not exist", async () => { + mockNpmFetchJson.mock.mockImplementationOnce(() => ({ + "dist-tags": { + latest: "4.18.2", + }, + versions: { + "4.18.2": {}, + }, + })); + + const result = await resolvePackageVersion("express", "nonexistent-tag"); + + assert.strictEqual(result, null); + }); + + it("should return null if package info has no versions property (retracted package)", async () => { + mockNpmFetchJson.mock.mockImplementationOnce(() => ({ + _id: "zenn", + name: "zenn", + time: { + modified: "2021-04-20T16:20:56.084Z", + created: "2017-07-10T19:48:07.891Z", + unpublished: { + time: "2021-04-20T16:20:56.084Z", + versions: [ + "0.9.0", + "0.9.1", + "0.9.2", + "0.9.3", + "0.9.4", + "0.9.5", + "0.9.6", + "0.9.8", + "0.9.9", + "0.9.10", + "0.9.11", + "0.9.12", + "0.9.13", + "0.9.14", + ], + }, + }, + })); + + const result = await resolvePackageVersion("zenn", "^0.9.0"); + + assert.strictEqual(result, null); + }); + + it("should return dist-tag version even if versions property is missing", async () => { + mockNpmFetchJson.mock.mockImplementationOnce(() => ({ + "dist-tags": { + latest: "4.18.2", + }, + })); + + const result = await resolvePackageVersion("express", "latest"); + + assert.strictEqual(result, "4.18.2"); + }); + + it("should handle scoped packages", async () => { + mockNpmFetchJson.mock.mockImplementationOnce(() => ({ + "dist-tags": { + latest: "1.2.3", + }, + versions: { + "1.2.3": {}, + }, + })); + + const result = await resolvePackageVersion("@scope/package", "latest"); + + assert.strictEqual(result, "1.2.3"); + }); + + it("should handle complex version ranges", async () => { + mockNpmFetchJson.mock.mockImplementationOnce(() => ({ + "dist-tags": { + latest: "2.5.0", + }, + versions: { + "1.0.0": {}, + "2.0.0": {}, + "2.3.0": {}, + "2.4.0": {}, + "2.5.0": {}, + "3.0.0": {}, + }, + })); + + const result = await resolvePackageVersion("express", ">=2.0.0 <3.0.0"); + + assert.strictEqual(result, "2.5.0"); + }); +}); diff --git a/packages/safe-chain/src/config/cliArguments.js b/packages/safe-chain/src/config/cliArguments.js index 87abb7b..ddcd8b9 100644 --- a/packages/safe-chain/src/config/cliArguments.js +++ b/packages/safe-chain/src/config/cliArguments.js @@ -1,12 +1,24 @@ +/** + * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, includePython: boolean}} + */ const state = { - malwareAction: undefined, + loggingLevel: undefined, + skipMinimumPackageAge: undefined, + minimumPackageAgeHours: undefined, + includePython: false, }; const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-"; +/** + * @param {string[]} args + * @returns {string[]} + */ export function initializeCliArguments(args) { // Reset state on each call - state.malwareAction = undefined; + state.loggingLevel = undefined; + state.skipMinimumPackageAge = undefined; + state.minimumPackageAgeHours = undefined; const safeChainArgs = []; const remainingArgs = []; @@ -19,21 +31,19 @@ export function initializeCliArguments(args) { } } - setMalwareAction(safeChainArgs); + setLoggingLevel(safeChainArgs); + setSkipMinimumPackageAge(safeChainArgs); + setMinimumPackageAgeHours(safeChainArgs); + setIncludePython(args); return remainingArgs; } -function setMalwareAction(args) { - const safeChainMalwareActionArg = SAFE_CHAIN_ARG_PREFIX + "malware-action="; - - const action = getLastArgEqualsValue(args, safeChainMalwareActionArg); - if (!action) { - return; - } - state.malwareAction = action.toLowerCase(); -} - +/** + * @param {string[]} args + * @param {string} prefix + * @returns {string | undefined} + */ function getLastArgEqualsValue(args, prefix) { for (var i = args.length - 1; i >= 0; i--) { const arg = args[i]; @@ -45,6 +55,84 @@ function getLastArgEqualsValue(args, prefix) { return undefined; } -export function getMalwareAction() { - return state.malwareAction; +/** + * @param {string[]} args + * @returns {void} + */ +function setLoggingLevel(args) { + const safeChainLoggingArg = SAFE_CHAIN_ARG_PREFIX + "logging="; + + const level = getLastArgEqualsValue(args, safeChainLoggingArg); + if (!level) { + return; + } + state.loggingLevel = level.toLowerCase(); +} + +export function getLoggingLevel() { + return state.loggingLevel; +} + +/** + * @param {string[]} args + * @returns {void} + */ +function setSkipMinimumPackageAge(args) { + const flagName = SAFE_CHAIN_ARG_PREFIX + "skip-minimum-package-age"; + + if (hasFlagArg(args, flagName)) { + state.skipMinimumPackageAge = true; + } +} + +export function getSkipMinimumPackageAge() { + return state.skipMinimumPackageAge; +} + +/** + * @param {string[]} args + * @returns {void} + */ +function setMinimumPackageAgeHours(args) { + const argName = SAFE_CHAIN_ARG_PREFIX + "minimum-package-age-hours="; + + const value = getLastArgEqualsValue(args, argName); + if (value) { + state.minimumPackageAgeHours = value; + } +} + +/** + * @returns {string | undefined} + */ +export function getMinimumPackageAgeHours() { + return state.minimumPackageAgeHours; +} + +/** + * @param {string[]} args + */ +function setIncludePython(args) { + // This flag doesn't have the --safe-chain- prefix because + // it is only used for the safe-chain command itself and + // not when wrapped around package manager commands. + state.includePython = hasFlagArg(args, "--include-python"); +} + +export function includePython() { + return state.includePython; +} + +/** + * @param {string[]} args + * @param {string} flagName + * @returns {boolean} + */ +function hasFlagArg(args, flagName) { + for (const arg of args) { + if (arg.toLowerCase() === flagName.toLowerCase()) { + return true; + } + } + return false; } diff --git a/packages/safe-chain/src/config/cliArguments.spec.js b/packages/safe-chain/src/config/cliArguments.spec.js index 9d5c0ba..bbd5121 100644 --- a/packages/safe-chain/src/config/cliArguments.spec.js +++ b/packages/safe-chain/src/config/cliArguments.spec.js @@ -1,6 +1,11 @@ import { describe, it } from "node:test"; import assert from "node:assert"; -import { initializeCliArguments, getMalwareAction } from "./cliArguments.js"; +import { + initializeCliArguments, + getLoggingLevel, + getSkipMinimumPackageAge, + getMinimumPackageAgeHours, +} from "./cliArguments.js"; describe("initializeCliArguments", () => { it("should return all args when no safe-chain args are present", () => { @@ -57,52 +62,213 @@ describe("initializeCliArguments", () => { assert.deepEqual(result, ["install", "my--safe-chain-package", "--save"]); }); - it("should not set malwareAction when no safe-chain arguments are passed", () => { + it("should not set loggingLevel when no logging argument is passed", () => { const args = ["install", "express", "--save"]; + initializeCliArguments(args); + + assert.strictEqual(getLoggingLevel(), undefined); + }); + + it("should parse logging=silent and set state", () => { + const args = ["--safe-chain-logging=silent", "install", "package"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "package"]); + assert.strictEqual(getLoggingLevel(), "silent"); + }); + + it("should parse logging=normal and set state", () => { + const args = ["--safe-chain-logging=normal", "install", "package"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "package"]); + assert.strictEqual(getLoggingLevel(), "normal"); + }); + + it("should handle multiple logging args, using the last one", () => { + const args = [ + "--safe-chain-logging=normal", + "--safe-chain-logging=silent", + "install", + ]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install"]); + assert.strictEqual(getLoggingLevel(), "silent"); + }); + + it("should handle logging level case-insensitively", () => { + const args = ["--safe-chain-logging=SILENT", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getLoggingLevel(), "silent"); + }); + + it("should capture invalid logging level as-is (lowercased)", () => { + const args = ["--safe-chain-logging=invalid", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getLoggingLevel(), "invalid"); + }); + + it("should handle logging with other safe-chain args", () => { + const args = [ + "--safe-chain-debug", + "--safe-chain-logging=silent", + "--safe-chain-malware-action=block", + "install", + ]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install"]); + assert.strictEqual(getLoggingLevel(), "silent"); + }); + + it("should not set skipMinimumPackageAge when flag is absent", () => { + const args = ["install", "express", "--save"]; + initializeCliArguments(args); + + assert.strictEqual(getSkipMinimumPackageAge(), undefined); + }); + + it("should set skipMinimumPackageAge to true when flag is present", () => { + const args = ["--safe-chain-skip-minimum-package-age", "install", "lodash"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "lodash"]); + assert.strictEqual(getSkipMinimumPackageAge(), true); + }); + + it("should handle skip-minimum-package-age flag case-insensitively", () => { + const args = ["--SAFE-CHAIN-SKIP-MINIMUM-PACKAGE-AGE", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getSkipMinimumPackageAge(), true); + }); + + it("should filter out skip-minimum-package-age flag from returned args", () => { + const args = [ + "install", + "--safe-chain-skip-minimum-package-age", + "express", + "--save", + ]; const result = initializeCliArguments(args); assert.deepEqual(result, ["install", "express", "--save"]); - assert.strictEqual(getMalwareAction(), undefined); }); - it("should parse malware-action=block and set state", () => { - const args = ["--safe-chain-malware-action=block", "install", "package"]; - const result = initializeCliArguments(args); - - assert.deepEqual(result, ["install", "package"]); - assert.strictEqual(getMalwareAction(), "block"); - }); - - it("should parse malware-action=prompt and set state", () => { - const args = ["--safe-chain-malware-action=prompt", "install", "package"]; - const result = initializeCliArguments(args); - - assert.deepEqual(result, ["install", "package"]); - assert.strictEqual(getMalwareAction(), "prompt"); - }); - - it("should handle multiple malware-action args, using the last valid one", () => { + it("should handle skip-minimum-package-age with other safe-chain arguments", () => { const args = [ - "--safe-chain-malware-action=block", - "--safe-chain-malware-action=prompt", + "--safe-chain-logging=verbose", + "--safe-chain-skip-minimum-package-age", "install", + "lodash", ]; const result = initializeCliArguments(args); - assert.deepEqual(result, ["install"]); - assert.strictEqual(getMalwareAction(), "prompt"); + assert.deepEqual(result, ["install", "lodash"]); + assert.strictEqual(getLoggingLevel(), "verbose"); + assert.strictEqual(getSkipMinimumPackageAge(), true); }); - it("should handle malware-action with other safe-chain args", () => { + it("should handle skip-minimum-package-age flag in different positions", () => { + const args = ["install", "lodash", "--safe-chain-skip-minimum-package-age"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "lodash"]); + assert.strictEqual(getSkipMinimumPackageAge(), true); + }); + + it("should return undefined when no minimum-package-age-hours argument is passed", () => { + const args = ["install", "express", "--save"]; + initializeCliArguments(args); + + assert.strictEqual(getMinimumPackageAgeHours(), undefined); + }); + + it("should parse minimum-package-age-hours value and set state", () => { const args = [ - "--safe-chain-debug", - "--safe-chain-malware-action=block", - "--safe-chain-verbose", + "--safe-chain-minimum-package-age-hours=48", "install", + "lodash", ]; const result = initializeCliArguments(args); - assert.deepEqual(result, ["install"]); - assert.strictEqual(getMalwareAction(), "block"); + assert.deepEqual(result, ["install", "lodash"]); + assert.strictEqual(getMinimumPackageAgeHours(), "48"); + }); + + it("should handle minimum-package-age-hours with zero value", () => { + const args = ["--safe-chain-minimum-package-age-hours=0", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getMinimumPackageAgeHours(), "0"); + }); + + it("should handle minimum-package-age-hours with decimal values", () => { + const args = ["--safe-chain-minimum-package-age-hours=1.5", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getMinimumPackageAgeHours(), "1.5"); + }); + + it("should handle minimum-package-age-hours case-insensitively", () => { + const args = ["--SAFE-CHAIN-MINIMUM-PACKAGE-AGE-HOURS=72", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getMinimumPackageAgeHours(), "72"); + }); + + it("should use the last minimum-package-age-hours argument when multiple are provided", () => { + const args = [ + "--safe-chain-minimum-package-age-hours=12", + "--safe-chain-minimum-package-age-hours=36", + "install", + ]; + initializeCliArguments(args); + + assert.strictEqual(getMinimumPackageAgeHours(), "36"); + }); + + it("should filter out minimum-package-age-hours argument from returned args", () => { + const args = [ + "install", + "--safe-chain-minimum-package-age-hours=48", + "express", + "--save", + ]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "express", "--save"]); + }); + + it("should handle minimum-package-age-hours with other safe-chain arguments", () => { + const args = [ + "--safe-chain-logging=verbose", + "--safe-chain-minimum-package-age-hours=96", + "install", + "lodash", + ]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "lodash"]); + assert.strictEqual(getLoggingLevel(), "verbose"); + assert.strictEqual(getMinimumPackageAgeHours(), "96"); + }); + + it("should handle non-numeric values without validation (validation in settings.js)", () => { + const args = ["--safe-chain-minimum-package-age-hours=invalid", "install"]; + initializeCliArguments(args); + + // cliArguments.js just captures the value; validation is in settings.js + assert.strictEqual(getMinimumPackageAgeHours(), "invalid"); + }); + + it("should handle negative values as strings (validation in settings.js)", () => { + const args = ["--safe-chain-minimum-package-age-hours=-24", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getMinimumPackageAgeHours(), "-24"); }); }); diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index 2feb307..ae25a1d 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -2,14 +2,88 @@ import fs from "fs"; import path from "path"; import os from "os"; import { ui } from "../environment/userInteraction.js"; +import { getEcoSystem } from "./settings.js"; +/** + * @typedef {Object} SafeChainConfig + * + * This should be a number, but can be anything because it is user-input. + * We cannot trust the input and should add the necessary validations. + * @property {unknown} scanTimeout + * @property {unknown} minimumPackageAgeHours + */ + +/** + * @returns {number} + */ export function getScanTimeout() { const config = readConfigFile(); - return ( - parseInt(process.env.AIKIDO_SCAN_TIMEOUT_MS) || config.scanTimeout || 10000 // Default to 10 seconds - ); + + if (process.env.AIKIDO_SCAN_TIMEOUT_MS) { + const scanTimeout = validateTimeout(process.env.AIKIDO_SCAN_TIMEOUT_MS); + if (scanTimeout != null) { + return scanTimeout; + } + } + + if (config.scanTimeout) { + const scanTimeout = validateTimeout(config.scanTimeout); + if (scanTimeout != null) { + return scanTimeout; + } + } + + return 10000; // Default to 10 seconds } +/** + * + * @param {any} value + * @returns {number?} + */ +function validateTimeout(value) { + const timeout = Number(value); + if (!Number.isNaN(timeout) && timeout > 0) { + return timeout; + } + return null; +} + +/** + * @param {any} value + * @returns {number | undefined} + */ +function validateMinimumPackageAgeHours(value) { + const hours = Number(value); + if (!Number.isNaN(hours)) { + return hours; + } + return undefined; +} + +/** + * Gets the minimum package age in hours from config file only + * @returns {number | undefined} + */ +export function getMinimumPackageAgeHours() { + const config = readConfigFile(); + if (config.minimumPackageAgeHours) { + const validated = validateMinimumPackageAgeHours( + config.minimumPackageAgeHours + ); + if (validated !== undefined) { + return validated; + } + } + return undefined; +} + +/** + * @param {import("../api/aikido.js").MalwarePackage[]} data + * @param {string | number} version + * + * @returns {void} + */ export function writeDatabaseToLocalCache(data, version) { try { const databasePath = getDatabasePath(); @@ -24,6 +98,9 @@ export function writeDatabaseToLocalCache(data, version) { } } +/** + * @returns {{malwareDatabase: import("../api/aikido.js").MalwarePackage[] | null, version: string | null}} + */ export function readDatabaseFromLocalCache() { try { const databasePath = getDatabasePath(); @@ -55,31 +132,55 @@ export function readDatabaseFromLocalCache() { } } +/** + * @returns {SafeChainConfig} + */ function readConfigFile() { const configFilePath = getConfigFilePath(); if (!fs.existsSync(configFilePath)) { - return {}; + return { + scanTimeout: undefined, + minimumPackageAgeHours: undefined, + }; } - const data = fs.readFileSync(configFilePath, "utf8"); - return JSON.parse(data); + try { + const data = fs.readFileSync(configFilePath, "utf8"); + return JSON.parse(data); + } catch { + return { + scanTimeout: undefined, + minimumPackageAgeHours: undefined, + }; + } } +/** + * @returns {string} + */ function getDatabasePath() { const aikidoDir = getAikidoDirectory(); - return path.join(aikidoDir, "malwareDatabase.json"); + const ecosystem = getEcoSystem(); + return path.join(aikidoDir, `malwareDatabase_${ecosystem}.json`); } function getDatabaseVersionPath() { const aikidoDir = getAikidoDirectory(); - return path.join(aikidoDir, "version.txt"); + const ecosystem = getEcoSystem(); + return path.join(aikidoDir, `version_${ecosystem}.txt`); } +/** + * @returns {string} + */ function getConfigFilePath() { return path.join(getAikidoDirectory(), "config.json"); } +/** + * @returns {string} + */ function getAikidoDirectory() { const homeDir = os.homedir(); const aikidoDir = path.join(homeDir, ".aikido"); diff --git a/packages/safe-chain/src/config/configFile.spec.js b/packages/safe-chain/src/config/configFile.spec.js new file mode 100644 index 0000000..18415bc --- /dev/null +++ b/packages/safe-chain/src/config/configFile.spec.js @@ -0,0 +1,285 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; + +describe("getScanTimeout", () => { + let originalEnv; + let fsMock; + let getScanTimeout; + + beforeEach(async () => { + // Save original environment + originalEnv = process.env.AIKIDO_SCAN_TIMEOUT_MS; + + // Mock fs module + fsMock = { + existsSync: mock.fn(() => false), + readFileSync: mock.fn(() => "{}"), + writeFileSync: mock.fn(), + mkdirSync: mock.fn(), + }; + + mock.module("fs", { + namedExports: fsMock, + }); + + // Re-import the module to get the mocked version + const configFileModule = await import( + `./configFile.js?update=${Date.now()}` + ); + getScanTimeout = configFileModule.getScanTimeout; + }); + + afterEach(() => { + // Restore original environment + if (originalEnv !== undefined) { + process.env.AIKIDO_SCAN_TIMEOUT_MS = originalEnv; + } else { + delete process.env.AIKIDO_SCAN_TIMEOUT_MS; + } + + // Reset all mocks + mock.restoreAll(); + }); + + it("should return default timeout of 10000ms when no config or env var is set", () => { + delete process.env.AIKIDO_SCAN_TIMEOUT_MS; + // Mock: config file doesn't exist + fsMock.existsSync.mock.mockImplementation(() => false); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 10000); + }); + + it("should return timeout from config file when set", () => { + delete process.env.AIKIDO_SCAN_TIMEOUT_MS; + // Mock: config file exists with scanTimeout: 5000 + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: 5000 }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 5000); + }); + + it("should prioritize environment variable over config file", () => { + process.env.AIKIDO_SCAN_TIMEOUT_MS = "20000"; + // Mock: config file exists with scanTimeout: 5000 + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: 5000 }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 20000); + }); + + it("should handle invalid environment variable and fall back to config", () => { + process.env.AIKIDO_SCAN_TIMEOUT_MS = "invalid"; + // Mock: config file exists with scanTimeout: 7000 + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: 7000 }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 7000); + }); + + it("should ignore zero and negative values and fall back to default", () => { + // Mock: config file doesn't exist + fsMock.existsSync.mock.mockImplementation(() => false); + + process.env.AIKIDO_SCAN_TIMEOUT_MS = "0"; + + let timeout = getScanTimeout(); + assert.strictEqual(timeout, 10000); + + process.env.AIKIDO_SCAN_TIMEOUT_MS = "-5000"; + + timeout = getScanTimeout(); + assert.strictEqual(timeout, 10000); + }); + + it("should ignore textual non-numeric values in environment variable and fall back to config", () => { + process.env.AIKIDO_SCAN_TIMEOUT_MS = "fast"; + // Mock: config file exists with scanTimeout: 8000 + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: 8000 }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 8000); + }); + + it("should ignore textual non-numeric values in config file and fall back to default", () => { + delete process.env.AIKIDO_SCAN_TIMEOUT_MS; + // Mock: config file exists with scanTimeout: "slow" + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: "slow" }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 10000); + }); + + it("should ignore textual non-numeric values in both env and config, fall back to default", () => { + process.env.AIKIDO_SCAN_TIMEOUT_MS = "quick"; + // Mock: config file exists with scanTimeout: "medium" + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: "medium" }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 10000); + }); + + it("should ignore mixed alphanumeric strings in environment variable", () => { + process.env.AIKIDO_SCAN_TIMEOUT_MS = "5000ms"; + // Mock: config file exists with scanTimeout: 6000 + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: 6000 }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 6000); + }); + + it("should ignore mixed alphanumeric strings in config file", () => { + delete process.env.AIKIDO_SCAN_TIMEOUT_MS; + // Mock: config file exists with scanTimeout: "3000ms" + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: "3000ms" }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 10000); + }); +}); + +describe("getMinimumPackageAgeHours", () => { + let fsMock; + let getMinimumPackageAgeHours; + + beforeEach(async () => { + // Mock fs module + fsMock = { + existsSync: mock.fn(() => false), + readFileSync: mock.fn(() => "{}"), + writeFileSync: mock.fn(), + mkdirSync: mock.fn(), + }; + + mock.module("fs", { + namedExports: fsMock, + }); + + // Re-import the module to get the mocked version + const configFileModule = await import( + `./configFile.js?update=${Date.now()}` + ); + getMinimumPackageAgeHours = configFileModule.getMinimumPackageAgeHours; + }); + + afterEach(() => { + // Reset all mocks + mock.restoreAll(); + }); + + it("should return null when config file doesn't exist", () => { + fsMock.existsSync.mock.mockImplementation(() => false); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, undefined); + }); + + it("should return null when config file exists but minimumPackageAgeHours is not set", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: 5000 }) + ); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, undefined); + }); + + it("should return value from config file when set to valid number", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ minimumPackageAgeHours: 48 }) + ); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, 48); + }); + + it("should handle string numbers in config file", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ minimumPackageAgeHours: "72" }) + ); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, 72); + }); + + it("should handle decimal values", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ minimumPackageAgeHours: 1.5 }) + ); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, 1.5); + }); + + it("should return null for non-numeric strings", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ minimumPackageAgeHours: "invalid" }) + ); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, undefined); + }); + + it("should return null for values with units suffix", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ minimumPackageAgeHours: "48h" }) + ); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, undefined); + }); + + it("should handle malformed JSON and return null", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => "{ invalid json"); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, undefined); + }); +}); diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js new file mode 100644 index 0000000..5c6056a --- /dev/null +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -0,0 +1,7 @@ +/** + * Gets the minimum package age in hours from environment variable + * @returns {string | undefined} + */ +export function getMinimumPackageAgeHours() { + return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS; +} diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index ed2cae2..7c20358 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -1,14 +1,100 @@ import * as cliArguments from "./cliArguments.js"; +import * as configFile from "./configFile.js"; +import * as environmentVariables from "./environmentVariables.js"; -export function getMalwareAction() { - const action = cliArguments.getMalwareAction(); +export const LOGGING_SILENT = "silent"; +export const LOGGING_NORMAL = "normal"; +export const LOGGING_VERBOSE = "verbose"; - if (action === MALWARE_ACTION_PROMPT) { - return MALWARE_ACTION_PROMPT; +export function getLoggingLevel() { + const level = cliArguments.getLoggingLevel(); + + if (level === LOGGING_SILENT) { + return LOGGING_SILENT; } - return MALWARE_ACTION_BLOCK; + if (level === LOGGING_VERBOSE) { + return LOGGING_VERBOSE; + } + + return LOGGING_NORMAL; } -export const MALWARE_ACTION_BLOCK = "block"; -export const MALWARE_ACTION_PROMPT = "prompt"; +export const ECOSYSTEM_JS = "js"; +export const ECOSYSTEM_PY = "py"; + +// Default to JavaScript ecosystem +const ecosystemSettings = { + ecoSystem: ECOSYSTEM_JS, +}; + +/** @returns {string} - The current ecosystem setting (ECOSYSTEM_JS or ECOSYSTEM_PY) */ +export function getEcoSystem() { + return ecosystemSettings.ecoSystem; +} +/** + * @param {string} setting - The ecosystem to set (ECOSYSTEM_JS or ECOSYSTEM_PY) + */ +export function setEcoSystem(setting) { + ecosystemSettings.ecoSystem = setting; +} + +const defaultMinimumPackageAge = 24; +/** @returns {number} */ +export function getMinimumPackageAgeHours() { + // Priority 1: CLI argument + const cliValue = validateMinimumPackageAgeHours( + cliArguments.getMinimumPackageAgeHours() + ); + if (cliValue !== undefined) { + return cliValue; + } + + // Priority 2: Environment variable + const envValue = validateMinimumPackageAgeHours( + environmentVariables.getMinimumPackageAgeHours() + ); + if (envValue !== undefined) { + return envValue; + } + + // Priority 3: Config file + const configValue = configFile.getMinimumPackageAgeHours(); + if (configValue !== undefined) { + return configValue; + } + + return defaultMinimumPackageAge; +} + +/** + * @param {string | undefined} value + * @returns {number | undefined} + */ +function validateMinimumPackageAgeHours(value) { + if (!value) { + return undefined; + } + + const numericValue = Number(value); + if (Number.isNaN(numericValue)) { + return undefined; + } + + if (numericValue > 0) { + return numericValue; + } + + return undefined; +} + +const defaultSkipMinimumPackageAge = false; +export function skipMinimumPackageAge() { + const cliValue = cliArguments.getSkipMinimumPackageAge(); + + if (cliValue === true) { + return true; + } + + return defaultSkipMinimumPackageAge; +} diff --git a/packages/safe-chain/src/environment/userInteraction.js b/packages/safe-chain/src/environment/userInteraction.js index 5b1cb88..9115b58 100644 --- a/packages/safe-chain/src/environment/userInteraction.js +++ b/packages/safe-chain/src/environment/userInteraction.js @@ -1,96 +1,122 @@ +// oxlint-disable no-console import chalk from "chalk"; -import ora from "ora"; -import { createInterface } from "readline"; import { isCi } from "./environment.js"; +import { + getLoggingLevel, + LOGGING_SILENT, + LOGGING_VERBOSE, +} from "../config/settings.js"; + +/** + * @type {{ bufferOutput: boolean, bufferedMessages:(() => void)[]}} + */ +const state = { + bufferOutput: false, + bufferedMessages: [], +}; + +function isSilentMode() { + return getLoggingLevel() === LOGGING_SILENT; +} + +function isVerboseMode() { + return getLoggingLevel() === LOGGING_VERBOSE; +} function emptyLine() { + if (isSilentMode()) return; + writeInformation(""); } +/** + * @param {string} message + * @param {...any} optionalParams + * @returns {void} + */ function writeInformation(message, ...optionalParams) { - console.log(message, ...optionalParams); + if (isSilentMode()) return; + + writeOrBuffer(() => console.log(message, ...optionalParams)); } +/** + * @param {string} message + * @param {...any} optionalParams + * @returns {void} + */ function writeWarning(message, ...optionalParams) { + if (isSilentMode()) return; + if (!isCi()) { message = chalk.yellow(message); } - console.warn(message, ...optionalParams); + writeOrBuffer(() => console.warn(message, ...optionalParams)); } +/** + * @param {string} message + * @param {...any} optionalParams + * @returns {void} + */ function writeError(message, ...optionalParams) { if (!isCi()) { message = chalk.red(message); } - console.error(message, ...optionalParams); + writeOrBuffer(() => console.error(message, ...optionalParams)); } -function startProcess(message) { - if (isCi()) { - return { - succeed: (message) => { - writeInformation(message); - }, - fail: (message) => { - writeError(message); - }, - stop: () => {}, - setText: (message) => { - writeInformation(message); - }, - }; +function writeExitWithoutInstallingMaliciousPackages() { + let message = "Safe-chain: Exiting without installing malicious packages."; + if (!isCi()) { + message = chalk.red(message); + } + writeOrBuffer(() => console.error(message)); +} + +/** + * @param {string} message + * @param {...any} optionalParams + * @returns {void} + */ +function writeVerbose(message, ...optionalParams) { + if (!isVerboseMode()) return; + + writeOrBuffer(() => console.log(message, ...optionalParams)); +} + +/** + * + * @param {() => void} messageFunction + */ +function writeOrBuffer(messageFunction) { + if (state.bufferOutput) { + state.bufferedMessages.push(messageFunction); } else { - const spinner = ora(message).start(); - return { - succeed: (message) => { - spinner.succeed(message); - }, - fail: (message) => { - spinner.fail(message); - }, - stop: () => { - spinner.stop(); - }, - setText: (message) => { - spinner.text = message; - }, - }; + messageFunction(); } } -async function confirm(config) { - if (isCi()) { - return Promise.resolve(config.default); +function startBufferingLogs() { + state.bufferOutput = true; + state.bufferedMessages = []; +} + +function writeBufferedLogsAndStopBuffering() { + state.bufferOutput = false; + for (const log of state.bufferedMessages) { + log(); } - - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - const defaultText = config.default ? " (Y/n)" : " (y/N)"; - rl.question(`${config.message}${defaultText} `, (answer) => { - rl.close(); - - const normalizedAnswer = answer.trim().toLowerCase(); - - if (normalizedAnswer === "y" || normalizedAnswer === "yes") { - resolve(true); - } else if (normalizedAnswer === "n" || normalizedAnswer === "no") { - resolve(false); - } else { - resolve(config.default); - } - }); - }); + state.bufferedMessages = []; } export const ui = { + writeVerbose, writeInformation, writeWarning, writeError, + writeExitWithoutInstallingMaliciousPackages, emptyLine, - startProcess, - confirm, + startBufferingLogs, + writeBufferedLogsAndStopBuffering, }; diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index e106e83..c46fc61 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -6,11 +6,34 @@ import { getPackageManager } from "./packagemanager/currentPackageManager.js"; import { initializeCliArguments } from "./config/cliArguments.js"; import { createSafeChainProxy } from "./registryProxy/registryProxy.js"; import chalk from "chalk"; +import { getAuditStats } from "./scanning/audit/index.js"; +/** + * @param {string[]} args + * @returns {Promise} + */ export async function main(args) { + process.on("SIGINT", handleProcessTermination); + process.on("SIGTERM", handleProcessTermination); + const proxy = createSafeChainProxy(); await proxy.startServer(); + // Global error handlers to log unhandled errors + process.on("uncaughtException", (error) => { + ui.writeError(`Safe-chain: Uncaught exception: ${error.message}`); + ui.writeVerbose(`Stack trace: ${error.stack}`); + process.exit(1); + }); + + process.on("unhandledRejection", (reason) => { + ui.writeError(`Safe-chain: Unhandled promise rejection: ${reason}`); + if (reason instanceof Error) { + ui.writeVerbose(`Stack trace: ${reason.stack}`); + } + process.exit(1); + }); + try { // This parses all the --safe-chain arguments and removes them from the args array args = initializeCliArguments(args); @@ -25,23 +48,47 @@ export async function main(args) { } } + // Buffer logs during package manager execution, this avoids interleaving + // of logs from the package manager and safe-chain + // Not doing this could cause bugs to disappear when cursor movement codes + // are written by the package manager while safe-chain is writing logs + ui.startBufferingLogs(); const packageManagerResult = await getPackageManager().runCommand(args); + // Write all buffered logs + ui.writeBufferedLogsAndStopBuffering(); + if (!proxy.verifyNoMaliciousPackages()) { return 1; } - ui.emptyLine(); - ui.writeInformation( - `${chalk.green( - "✔" - )} Safe-chain: Command completed, no malicious packages found.` - ); + const auditStats = getAuditStats(); + if (auditStats.totalPackages > 0) { + ui.emptyLine(); + ui.writeInformation( + `${chalk.green("✔")} Safe-chain: Scanned ${ + auditStats.totalPackages + } packages, no malware found.` + ); + } + + if (proxy.hasSuppressedVersions()) { + ui.writeInformation( + `${chalk.yellow( + "ℹ" + )} Safe-chain: Some package versions were suppressed due to minimum age requirement.` + ); + ui.writeInformation( + ` To disable this check, use: ${chalk.cyan( + "--safe-chain-skip-minimum-package-age" + )}` + ); + } // Returning the exit code back to the caller allows the promise // to be awaited in the bin files and return the correct exit code return packageManagerResult.status; - } catch (error) { + } catch (/** @type any */ error) { ui.writeError("Failed to check for malicious packages:", error.message); // Returning the exit code back to the caller allows the promise @@ -51,3 +98,7 @@ export async function main(args) { await proxy.stopServer(); } } + +function handleProcessTermination() { + ui.writeBufferedLogsAndStopBuffering(); +} diff --git a/packages/safe-chain/src/packagemanager/_shared/matchesCommand.js b/packages/safe-chain/src/packagemanager/_shared/matchesCommand.js index d72caca..f939352 100644 --- a/packages/safe-chain/src/packagemanager/_shared/matchesCommand.js +++ b/packages/safe-chain/src/packagemanager/_shared/matchesCommand.js @@ -1,3 +1,8 @@ +/** + * @param {string[]} args + * @param {...string} commandArgs + * @returns {boolean} + */ export function matchesCommand(args, ...commandArgs) { if (args.length < commandArgs.length) { return false; diff --git a/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js b/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js index 14faa5f..037a512 100644 --- a/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js +++ b/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js @@ -2,6 +2,9 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ export function createBunPackageManager() { return { runCommand: (args) => runBunCommand("bun", args), @@ -13,6 +16,9 @@ export function createBunPackageManager() { }; } +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ export function createBunxPackageManager() { return { runCommand: (args) => runBunCommand("bunx", args), @@ -24,6 +30,11 @@ export function createBunxPackageManager() { }; } +/** + * @param {string} command + * @param {string[]} args + * @returns {Promise<{status: number}>} + */ async function runBunCommand(command, args) { try { const result = await safeSpawn(command, args, { @@ -31,7 +42,7 @@ async function runBunCommand(command, args) { env: mergeSafeChainProxyEnvironmentVariables(process.env), }); return { status: result.status }; - } catch (error) { + } catch (/** @type any */ error) { if (error.status) { return { status: error.status }; } else { diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index 2a10d86..a6fad4a 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -9,14 +9,39 @@ import { createPnpxPackageManager, } from "./pnpm/createPackageManager.js"; import { createYarnPackageManager } from "./yarn/createPackageManager.js"; +import { createPipPackageManager } from "./pip/createPackageManager.js"; +import { createUvPackageManager } from "./uv/createUvPackageManager.js"; +/** + * @type {{packageManagerName: PackageManager | null}} + */ const state = { packageManagerName: null, }; -export function initializePackageManager(packageManagerName, version) { +/** + * @typedef {Object} GetDependencyUpdatesResult + * @property {string} name + * @property {string} version + * @property {string} type + */ + +/** + * @typedef {Object} PackageManager + * @property {(args: string[]) => Promise<{ status: number }>} runCommand + * @property {(args: string[]) => boolean} isSupportedCommand + * @property {(args: string[]) => Promise | GetDependencyUpdatesResult[]} getDependencyUpdatesForCommand + */ + +/** + * @param {string} packageManagerName + * @param {{ tool: string, args: string[] }} [context] - Optional tool context for package managers like pip + * + * @return {PackageManager} + */ +export function initializePackageManager(packageManagerName, context) { if (packageManagerName === "npm") { - state.packageManagerName = createNpmPackageManager(version); + state.packageManagerName = createNpmPackageManager(); } else if (packageManagerName === "npx") { state.packageManagerName = createNpxPackageManager(); } else if (packageManagerName === "yarn") { @@ -29,6 +54,10 @@ export function initializePackageManager(packageManagerName, version) { state.packageManagerName = createBunPackageManager(); } else if (packageManagerName === "bunx") { state.packageManagerName = createBunxPackageManager(); + } else if (packageManagerName === "pip") { + state.packageManagerName = createPipPackageManager(context); + } else if (packageManagerName === "uv") { + state.packageManagerName = createUvPackageManager(); } else { throw new Error("Unsupported package manager: " + packageManagerName); } diff --git a/packages/safe-chain/src/packagemanager/npm/createPackageManager.js b/packages/safe-chain/src/packagemanager/npm/createPackageManager.js index bf38209..fa72276 100644 --- a/packages/safe-chain/src/packagemanager/npm/createPackageManager.js +++ b/packages/safe-chain/src/packagemanager/npm/createPackageManager.js @@ -1,34 +1,40 @@ import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js"; -import { dryRunScanner } from "./dependencyScanner/dryRunScanner.js"; import { nullScanner } from "./dependencyScanner/nullScanner.js"; import { runNpm } from "./runNpmCommand.js"; import { getNpmCommandForArgs, npmInstallCommand, - npmCiCommand, - npmInstallTestCommand, - npmInstallCiTestCommand, npmUpdateCommand, - npmAuditCommand, npmExecCommand, } from "./utils/npmCommands.js"; -export function createNpmPackageManager(version) { - // From npm v10.4.0 onwards, the npm commands output detailed information - // when using the --dry-run flag. - // We use that information to scan for dependency changes. - // For older versions of npm we have to rely on parsing the command arguments. - const supportedScanners = isPriorToNpm10_4(version) - ? npm10_3AndBelowSupportedScanners - : npm10_4AndAboveSupportedScanners; - +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ +export function createNpmPackageManager() { + /** + * @param {string[]} args + * + * @returns {boolean} + */ function isSupportedCommand(args) { - const scanner = findDependencyScannerForCommand(supportedScanners, args); + const scanner = findDependencyScannerForCommand( + commandScannerMapping, + args + ); return scanner.shouldScan(args); } + /** + * @param {string[]} args + * + * @returns {ReturnType} + */ function getDependencyUpdatesForCommand(args) { - const scanner = findDependencyScannerForCommand(supportedScanners, args); + const scanner = findDependencyScannerForCommand( + commandScannerMapping, + args + ); return scanner.scan(args); } @@ -39,40 +45,22 @@ export function createNpmPackageManager(version) { }; } -const npm10_4AndAboveSupportedScanners = { - [npmInstallCommand]: dryRunScanner(), - [npmUpdateCommand]: dryRunScanner(), - [npmCiCommand]: dryRunScanner(), - [npmAuditCommand]: dryRunScanner({ - skipScanWhen: (args) => !args.includes("fix"), - }), - [npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run - - // Running dry-run on install-test and install-ci-test will install & run tests. - // We only want to know if there are changes in the dependencies. - // So we run change the dry-run command to only check the install. - [npmInstallTestCommand]: dryRunScanner({ dryRunCommand: npmInstallCommand }), - [npmInstallCiTestCommand]: dryRunScanner({ dryRunCommand: npmCiCommand }), -}; - -const npm10_3AndBelowSupportedScanners = { +/** + * @type {Record} + */ +const commandScannerMapping = { [npmInstallCommand]: commandArgumentScanner(), [npmUpdateCommand]: commandArgumentScanner(), [npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run }; -function isPriorToNpm10_4(version) { - try { - const [major, minor] = version.split(".").map(Number); - if (major < 10) return true; - if (major === 10 && minor < 4) return true; - return false; - } catch { - // Default to true: if version parsing fails, assume it's an older version - return true; - } -} - +/** + * + * @param {Record} scanners + * @param {string[]} args + * + * @returns {import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner} + */ function findDependencyScannerForCommand(scanners, args) { const command = getNpmCommandForArgs(args); if (!command) { diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/commandArgumentScanner.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/commandArgumentScanner.js index ae05f6d..c4f6bb6 100644 --- a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/commandArgumentScanner.js +++ b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/commandArgumentScanner.js @@ -2,6 +2,29 @@ import { resolvePackageVersion } from "../../../api/npmApi.js"; import { parsePackagesFromInstallArgs } from "../parsing/parsePackagesFromInstallArgs.js"; import { hasDryRunArg } from "../utils/npmCommands.js"; +/** + * @typedef {Object} ScanResult + * @property {string} name + * @property {string} version + * @property {string} type + */ + +/** + * @typedef {Object} ScannerOptions + * @property {boolean} [ignoreDryRun] + */ + +/** + * @typedef {Object} CommandArgumentScanner + * @property {(args: string[]) => Promise | ScanResult[]} scan + * @property {(args: string[]) => boolean} shouldScan + */ + +/** + * @param {ScannerOptions} [opts] + * + * @returns {CommandArgumentScanner} + */ export function commandArgumentScanner(opts) { const ignoreDryRun = opts?.ignoreDryRun ?? false; @@ -10,14 +33,28 @@ export function commandArgumentScanner(opts) { shouldScan: (args) => shouldScanDependencies(args, ignoreDryRun), }; } + +/** + * @param {string[]} args + * @returns {Promise} + */ function scanDependencies(args) { return checkChangesFromArgs(args); } +/** + * @param {string[]} args + * @param {boolean} ignoreDryRun + * @returns {boolean} + */ function shouldScanDependencies(args, ignoreDryRun) { return ignoreDryRun || !hasDryRunArg(args); } +/** + * @param {string[]} args + * @returns {Promise} + */ export async function checkChangesFromArgs(args) { const changes = []; const packageUpdates = parsePackagesFromInstallArgs(args); diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js deleted file mode 100644 index 6189b2f..0000000 --- a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.js +++ /dev/null @@ -1,67 +0,0 @@ -import { parseDryRunOutput } from "../parsing/parseNpmInstallDryRunOutput.js"; -import { dryRunNpmCommandAndOutput } from "../runNpmCommand.js"; -import { hasDryRunArg } from "../utils/npmCommands.js"; - -export function dryRunScanner(scannerOptions) { - return { - scan: (args) => scanDependencies(scannerOptions, args), - shouldScan: (args) => shouldScanDependencies(scannerOptions, args), - }; -} - -function scanDependencies(scannerOptions, args) { - let dryRunArgs = args; - - if (scannerOptions?.dryRunCommand) { - // Replace the first argument with the dryRunCommand (eg: "install" instead of "install-test") - dryRunArgs = [scannerOptions.dryRunCommand, ...args.slice(1)]; - } - - return checkChangesWithDryRun(dryRunArgs); -} - -function shouldScanDependencies(scannerOptions, args) { - if (hasDryRunArg(args)) { - return false; - } - - if (scannerOptions?.skipScanWhen && scannerOptions.skipScanWhen(args)) { - return false; - } - - return true; -} - -async function checkChangesWithDryRun(args) { - const dryRunOutput = await dryRunNpmCommandAndOutput(args); - - // Dry-run can return a non-zero status code in some cases - // e.g., when running "npm audit fix --dry-run", it returns exit code 1 - // when there are vulnerabilities that can be fixed. - if (dryRunOutput.status !== 0 && !canCommandReturnNonZeroOnSuccess(args)) { - throw new Error( - `Dry-run command failed with exit code ${dryRunOutput.status} and output:\n${dryRunOutput.output}` - ); - } - - if (dryRunOutput.status !== 0 && !dryRunOutput.output) { - throw new Error( - `Dry-run command failed with exit code ${dryRunOutput.status} and produced no output.` - ); - } - - const parsedOutput = parseDryRunOutput(dryRunOutput.output); - - // reverse the array to have the top-level packages first - return parsedOutput.reverse(); -} - -function canCommandReturnNonZeroOnSuccess(args) { - if (args.length < 2) { - return false; - } - - // `npm audit fix --dry-run` can return exit code 1 when it succesfully ran and - // there were vulnerabilities that could be fixed - return args[0] === "audit" && args[1] === "fix"; -} diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js deleted file mode 100644 index 88d7681..0000000 --- a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/dryRunScanner.spec.js +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, it, mock } from "node:test"; -import assert from "node:assert/strict"; - -describe("dryRunScanner", async () => { - const mockWriteError = mock.fn(); - const mockDryRunNpmCommandAndOutput = mock.fn(); - - // Mock ui module - mock.module("../../../environment/userInteraction.js", { - namedExports: { - ui: { - writeError: mockWriteError, - }, - }, - }); - - // Mock dryRunNpmCommandAndOutput function - mock.module("../runNpmCommand.js", { - namedExports: { - dryRunNpmCommandAndOutput: mockDryRunNpmCommandAndOutput, - }, - }); - - const { dryRunScanner } = await import("./dryRunScanner.js"); - - describe("doesCommandReturnNonZero", () => { - // We need to access the internal function for testing - // Since it's not exported, we'll test it indirectly through the main functionality - - it("should handle npm audit fix commands that return non-zero", async () => { - mockDryRunNpmCommandAndOutput.mock.resetCalls(); - mockWriteError.mock.resetCalls(); - mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({ - status: 1, - output: "found 5 vulnerabilities that can be fixed", - })); - - const scanner = dryRunScanner(); - const result = await scanner.scan(["audit", "fix"]); - - // Should not throw an error for audit fix commands - assert.ok(Array.isArray(result)); - assert.equal(mockWriteError.mock.callCount(), 0); - }); - - it("should throw error for unexpected non-zero exit codes", async () => { - mockDryRunNpmCommandAndOutput.mock.resetCalls(); - mockWriteError.mock.resetCalls(); - mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({ - status: 1, - output: "some error output", - })); - - const scanner = dryRunScanner(); - - await assert.rejects(async () => { - await scanner.scan(["install", "lodash"]); - }, /Dry-run command failed with exit code 1/); - }); - - it("should handle zero exit codes normally", async () => { - mockDryRunNpmCommandAndOutput.mock.resetCalls(); - mockWriteError.mock.resetCalls(); - mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({ - status: 0, - output: "added 1 package", - })); - - const scanner = dryRunScanner(); - const result = await scanner.scan(["install", "lodash"]); - - assert.ok(Array.isArray(result)); - assert.equal(mockWriteError.mock.callCount(), 0); - }); - - it("should throw error for non-zero exit with no output for audit fix", async () => { - mockDryRunNpmCommandAndOutput.mock.resetCalls(); - mockWriteError.mock.resetCalls(); - mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({ - status: 1, - output: "", - })); - - const scanner = dryRunScanner(); - - await assert.rejects(async () => { - await scanner.scan(["audit", "fix"]); - }, /Dry-run command failed with exit code 1/); - }); - }); - - describe("scanner functionality", () => { - it("should use dryRunCommand option when provided", async () => { - mockDryRunNpmCommandAndOutput.mock.resetCalls(); - mockWriteError.mock.resetCalls(); - mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({ - status: 0, - output: "no changes", - })); - - const scanner = dryRunScanner({ dryRunCommand: "install" }); - await scanner.scan(["install-test", "lodash"]); - - // Should call with "install" instead of "install-test" - assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 1); - const calledArgs = - mockDryRunNpmCommandAndOutput.mock.calls[0].arguments[0]; - assert.deepEqual(calledArgs, ["install", "lodash"]); - }); - - it("should skip scanning when hasDryRunArg returns true", async () => { - mockDryRunNpmCommandAndOutput.mock.resetCalls(); - mockWriteError.mock.resetCalls(); - - const scanner = dryRunScanner(); - const shouldScan = scanner.shouldScan(["install", "--dry-run"]); - - assert.equal(shouldScan, false); - // Should not call dryRunNpmCommandAndOutput since scanning is skipped - assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 0); - }); - - it("should skip scanning when skipScanWhen returns true", async () => { - const scanner = dryRunScanner({ - skipScanWhen: (args) => args.includes("--skip"), - }); - const shouldScan = scanner.shouldScan(["install", "--skip"]); - - assert.equal(shouldScan, false); - }); - - it("should scan when conditions are met", async () => { - const scanner = dryRunScanner(); - const shouldScan = scanner.shouldScan(["install", "lodash"]); - - assert.equal(shouldScan, true); - }); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/nullScanner.js b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/nullScanner.js index a7b2ffd..5c1d3bd 100644 --- a/packages/safe-chain/src/packagemanager/npm/dependencyScanner/nullScanner.js +++ b/packages/safe-chain/src/packagemanager/npm/dependencyScanner/nullScanner.js @@ -1,3 +1,6 @@ +/** + * @returns {import("./commandArgumentScanner.js").CommandArgumentScanner} + */ export function nullScanner() { return { scan: () => [], diff --git a/packages/safe-chain/src/packagemanager/npm/parsing/parseNpmInstallDryRunOutput.js b/packages/safe-chain/src/packagemanager/npm/parsing/parseNpmInstallDryRunOutput.js deleted file mode 100644 index 3c1e673..0000000 --- a/packages/safe-chain/src/packagemanager/npm/parsing/parseNpmInstallDryRunOutput.js +++ /dev/null @@ -1,57 +0,0 @@ -export function parseDryRunOutput(output) { - const lines = output.split(/\r?\n/); - const packageChanges = []; - - for (const line of lines) { - if (line.startsWith("add ")) { - packageChanges.push(parseAdd(line)); - } else if (line.startsWith("remove ")) { - packageChanges.push(parseRemove(line)); - } else if (line.startsWith("change ")) { - packageChanges.push(parseChange(line)); - } - } - - return packageChanges; -} - -function parseAdd(line) { - const splitLine = getLineParts(line); - const packageName = splitLine[1]; - const packageVersion = splitLine[splitLine.length - 1]; - return addedPackage(packageName, packageVersion); -} - -function addedPackage(name, version) { - return { type: "add", name, version }; -} - -function parseRemove(line) { - const splitLine = getLineParts(line); - const packageName = splitLine[1]; - const packageVersion = splitLine[splitLine.length - 1]; - return removedPackage(packageName, packageVersion); -} - -function removedPackage(name, version) { - return { type: "remove", name, version }; -} - -function parseChange(line) { - const splitLine = getLineParts(line); - const packageName = splitLine[1]; - const packageVersion = splitLine[splitLine.length - 1]; - const oldVersion = splitLine[2]; - return changedPackage(packageName, packageVersion, oldVersion); -} - -function getLineParts(line) { - return line - .split(" ") - .map((part) => part.trim()) - .filter((part) => part !== ""); -} - -function changedPackage(name, version, oldVersion) { - return { type: "change", name, version, oldVersion }; -} diff --git a/packages/safe-chain/src/packagemanager/npm/parsing/parseNpmInstallDryRunOutput.spec.js b/packages/safe-chain/src/packagemanager/npm/parsing/parseNpmInstallDryRunOutput.spec.js deleted file mode 100644 index cd7c2b1..0000000 --- a/packages/safe-chain/src/packagemanager/npm/parsing/parseNpmInstallDryRunOutput.spec.js +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { parseDryRunOutput } from "./parseNpmInstallDryRunOutput.js"; - -describe("parseNpmInstallDryRunOutput", () => { - it("should parse added packages", () => { - const output = ` -add @jest/transform 29.7.0 -add @jest/test-result 29.7.0 -add @jest/reporters 29.7.0 -add @jest/console 29.7.0 -add jest-cli 29.7.0 -add import-local 3.2.0 -add @jest/types 29.6.3 -add @jest/core 29.7.0 -add jest 29.7.0 - -added 267 packages in 831ms - -32 packages are looking for funding - run \`npm fund\` for details`; - - const expected = [ - { name: "@jest/transform", version: "29.7.0", type: "add" }, - { name: "@jest/test-result", version: "29.7.0", type: "add" }, - { name: "@jest/reporters", version: "29.7.0", type: "add" }, - { name: "@jest/console", version: "29.7.0", type: "add" }, - { name: "jest-cli", version: "29.7.0", type: "add" }, - { name: "import-local", version: "3.2.0", type: "add" }, - { name: "@jest/types", version: "29.6.3", type: "add" }, - { name: "@jest/core", version: "29.7.0", type: "add" }, - { name: "jest", version: "29.7.0", type: "add" }, - ]; - - const result = parseDryRunOutput(output); - - assert.deepEqual(result, expected); - }); - - it("should parse removed packages", () => { - const output = ` -remove react 19.1.0 - - removed 1 package in 115ms`; - - const expected = [{ name: "react", version: "19.1.0", type: "remove" }]; - - const result = parseDryRunOutput(output); - - assert.deepEqual(result, expected); - }); - - it("should parse changed packages", () => { - const output = ` -change react 19.0.0 => 19.1.0 - -changed 1 package in 204ms`; - - const expected = [ - { - name: "react", - version: "19.1.0", - oldVersion: "19.0.0", - type: "change", - }, - ]; - - const result = parseDryRunOutput(output); - - assert.deepEqual(result, expected); - }); - - it("should parse mixed package changes", () => { - const output = ` -add @jest/transform 29.7.0 -add @jest/test-result 29.7.0 -add @jest/reporters 29.7.0 -add @jest/console 29.7.0 -add jest-cli 29.7.0 -add import-local 3.2.0 -add @jest/types 29.6.3 -add @jest/core 29.7.0 -add jest 29.7.0 -remove react 19.1.0 -change lodash 4.17.0 => 4.18.0 - -removed 1 package in 115ms`; - - const expected = [ - { name: "@jest/transform", version: "29.7.0", type: "add" }, - { name: "@jest/test-result", version: "29.7.0", type: "add" }, - { name: "@jest/reporters", version: "29.7.0", type: "add" }, - { name: "@jest/console", version: "29.7.0", type: "add" }, - { name: "jest-cli", version: "29.7.0", type: "add" }, - { name: "import-local", version: "3.2.0", type: "add" }, - { name: "@jest/types", version: "29.6.3", type: "add" }, - { name: "@jest/core", version: "29.7.0", type: "add" }, - { name: "jest", version: "29.7.0", type: "add" }, - { name: "react", version: "19.1.0", type: "remove" }, - { - name: "lodash", - version: "4.18.0", - oldVersion: "4.17.0", - type: "change", - }, - ]; - - const result = parseDryRunOutput(output); - - assert.deepEqual(result, expected); - }); - - it("should work with npm v22.0.0", () => { - const output = ` -add @jest/types 29.6.3 -add @jest/core 29.7.0 -add jest 29.7.0 - -added 257 packages in 791ms - -44 packages are looking for funding - run \`npm fund\` for details`; - - const expected = [ - { name: "@jest/types", version: "29.6.3", type: "add" }, - { name: "@jest/core", version: "29.7.0", type: "add" }, - { name: "jest", version: "29.7.0", type: "add" }, - ]; - - const result = parseDryRunOutput(output); - - assert.deepEqual(result, expected); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js b/packages/safe-chain/src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js index e731240..b7277e7 100644 --- a/packages/safe-chain/src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js +++ b/packages/safe-chain/src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js @@ -1,5 +1,22 @@ +/** + * @typedef {Object} PackageDetail + * @property {string} name + * @property {string} version + */ + +/** + * @typedef {Object} NpmOption + * @property {string} name + * @property {number} numberOfParameters + */ + +/** + * @param {string[]} args + * @returns {PackageDetail[]} + */ export function parsePackagesFromInstallArgs(args) { - const changes = []; + /** @type {{name: string, version: string | null}[]} */ + const changes = []; let defaultTag = "latest"; // Skip first argument (install command) @@ -32,9 +49,13 @@ export function parsePackagesFromInstallArgs(args) { } } - return changes; + return /** @type {PackageDetail[]} */ (changes); } +/** + * @param {string} arg + * @returns {NpmOption | undefined} + */ function getNpmOption(arg) { if (isNpmOptionWithParameter(arg)) { return { @@ -54,6 +75,10 @@ function getNpmOption(arg) { return undefined; } +/** + * @param {string} arg + * @returns {boolean} + */ function isNpmOptionWithParameter(arg) { const optionsWithParameters = [ "--access", @@ -81,6 +106,10 @@ function isNpmOptionWithParameter(arg) { return optionsWithParameters.includes(arg); } +/** + * @param {string} arg + * @returns {{name: string, version: string | null}} + */ function parsePackagename(arg) { arg = removeAlias(arg); const lastAtIndex = arg.lastIndexOf("@"); @@ -102,6 +131,10 @@ function parsePackagename(arg) { }; } +/** + * @param {string} arg + * @returns {string} + */ function removeAlias(arg) { const aliasIndex = arg.indexOf("@npm:"); if (aliasIndex !== -1) { diff --git a/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js b/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js index 26a4a9d..af57fad 100644 --- a/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js +++ b/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js @@ -2,6 +2,11 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +/** + * @param {string[]} args + * + * @returns {Promise<{status: number}>} + */ export async function runNpm(args) { try { const result = await safeSpawn("npm", args, { @@ -9,7 +14,7 @@ export async function runNpm(args) { env: mergeSafeChainProxyEnvironmentVariables(process.env), }); return { status: result.status }; - } catch (error) { + } catch (/** @type any */ error) { if (error.status) { return { status: error.status }; } else { @@ -18,32 +23,3 @@ export async function runNpm(args) { } } } - -export async function dryRunNpmCommandAndOutput(args) { - try { - const result = await safeSpawn( - "npm", - [...args, "--ignore-scripts", "--dry-run"], - { - stdio: "pipe", - env: mergeSafeChainProxyEnvironmentVariables(process.env), - } - ); - return { - status: result.status, - output: result.status === 0 ? result.stdout : result.stderr, - }; - } catch (error) { - if (error.status) { - const output = - error.stdout?.toString() ?? - error.stderr?.toString() ?? - error.message ?? - ""; - return { status: error.status, output }; - } else { - ui.writeError("Error executing command:", error.message); - return { status: 1 }; - } - } -} diff --git a/packages/safe-chain/src/packagemanager/npm/utils/abbrevs-generated.js b/packages/safe-chain/src/packagemanager/npm/utils/abbrevs-generated.js new file mode 100644 index 0000000..8e76ad1 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/npm/utils/abbrevs-generated.js @@ -0,0 +1,359 @@ +// This was ran with the abbrev package to generate the abbrevs object below +// console.log(abbrev(commands.concat(Object.keys(aliases)))); +/** @type {Record} */ +export const abbrevs = { + ac: "access", + acc: "access", + acce: "access", + acces: "access", + access: "access", + add: "add", + "add-": "add-user", + "add-u": "add-user", + "add-us": "add-user", + "add-use": "add-user", + "add-user": "add-user", + addu: "adduser", + addus: "adduser", + adduse: "adduser", + adduser: "adduser", + aud: "audit", + audi: "audit", + audit: "audit", + aut: "author", + auth: "author", + autho: "author", + author: "author", + b: "bugs", + bu: "bugs", + bug: "bugs", + bugs: "bugs", + c: "c", + ca: "cache", + cac: "cache", + cach: "cache", + cache: "cache", + ci: "ci", + cit: "cit", + "clean-install": "clean-install", + "clean-install-": "clean-install-test", + "clean-install-t": "clean-install-test", + "clean-install-te": "clean-install-test", + "clean-install-tes": "clean-install-test", + "clean-install-test": "clean-install-test", + com: "completion", + comp: "completion", + compl: "completion", + comple: "completion", + complet: "completion", + completi: "completion", + completio: "completion", + completion: "completion", + con: "config", + conf: "config", + confi: "config", + config: "config", + cr: "create", + cre: "create", + crea: "create", + creat: "create", + create: "create", + dd: "ddp", + ddp: "ddp", + ded: "dedupe", + dedu: "dedupe", + dedup: "dedupe", + dedupe: "dedupe", + dep: "deprecate", + depr: "deprecate", + depre: "deprecate", + deprec: "deprecate", + depreca: "deprecate", + deprecat: "deprecate", + deprecate: "deprecate", + dif: "diff", + diff: "diff", + "dist-tag": "dist-tag", + "dist-tags": "dist-tags", + docs: "docs", + doct: "doctor", + docto: "doctor", + doctor: "doctor", + ed: "edit", + edi: "edit", + edit: "edit", + exe: "exec", + exec: "exec", + expla: "explain", + explai: "explain", + explain: "explain", + explo: "explore", + explor: "explore", + explore: "explore", + find: "find", + "find-": "find-dupes", + "find-d": "find-dupes", + "find-du": "find-dupes", + "find-dup": "find-dupes", + "find-dupe": "find-dupes", + "find-dupes": "find-dupes", + fu: "fund", + fun: "fund", + fund: "fund", + g: "get", + ge: "get", + get: "get", + help: "help", + "help-": "help-search", + "help-s": "help-search", + "help-se": "help-search", + "help-sea": "help-search", + "help-sear": "help-search", + "help-searc": "help-search", + "help-search": "help-search", + hl: "hlep", + hle: "hlep", + hlep: "hlep", + ho: "home", + hom: "home", + home: "home", + i: "i", + ic: "ic", + in: "in", + inf: "info", + info: "info", + ini: "init", + init: "init", + inn: "innit", + inni: "innit", + innit: "innit", + ins: "ins", + inst: "inst", + insta: "insta", + instal: "instal", + install: "install", + "install-ci": "install-ci-test", + "install-ci-": "install-ci-test", + "install-ci-t": "install-ci-test", + "install-ci-te": "install-ci-test", + "install-ci-tes": "install-ci-test", + "install-ci-test": "install-ci-test", + "install-cl": "install-clean", + "install-cle": "install-clean", + "install-clea": "install-clean", + "install-clean": "install-clean", + "install-t": "install-test", + "install-te": "install-test", + "install-tes": "install-test", + "install-test": "install-test", + isnt: "isnt", + isnta: "isnta", + isntal: "isntal", + isntall: "isntall", + "isntall-": "isntall-clean", + "isntall-c": "isntall-clean", + "isntall-cl": "isntall-clean", + "isntall-cle": "isntall-clean", + "isntall-clea": "isntall-clean", + "isntall-clean": "isntall-clean", + iss: "issues", + issu: "issues", + issue: "issues", + issues: "issues", + it: "it", + la: "la", + lin: "link", + link: "link", + lis: "list", + list: "list", + ll: "ll", + ln: "ln", + logi: "login", + login: "login", + logo: "logout", + logou: "logout", + logout: "logout", + ls: "ls", + og: "ogr", + ogr: "ogr", + or: "org", + org: "org", + ou: "outdated", + out: "outdated", + outd: "outdated", + outda: "outdated", + outdat: "outdated", + outdate: "outdated", + outdated: "outdated", + ow: "owner", + own: "owner", + owne: "owner", + owner: "owner", + pa: "pack", + pac: "pack", + pack: "pack", + pi: "ping", + pin: "ping", + ping: "ping", + pk: "pkg", + pkg: "pkg", + pre: "prefix", + pref: "prefix", + prefi: "prefix", + prefix: "prefix", + pro: "profile", + prof: "profile", + profi: "profile", + profil: "profile", + profile: "profile", + pru: "prune", + prun: "prune", + prune: "prune", + pu: "publish", + pub: "publish", + publ: "publish", + publi: "publish", + publis: "publish", + publish: "publish", + q: "query", + qu: "query", + que: "query", + quer: "query", + query: "query", + r: "r", + rb: "rb", + reb: "rebuild", + rebu: "rebuild", + rebui: "rebuild", + rebuil: "rebuild", + rebuild: "rebuild", + rem: "remove", + remo: "remove", + remov: "remove", + remove: "remove", + rep: "repo", + repo: "repo", + res: "restart", + rest: "restart", + resta: "restart", + restar: "restart", + restart: "restart", + rm: "rm", + ro: "root", + roo: "root", + root: "root", + rum: "rum", + run: "run", + "run-": "run-script", + "run-s": "run-script", + "run-sc": "run-script", + "run-scr": "run-script", + "run-scri": "run-script", + "run-scrip": "run-script", + "run-script": "run-script", + s: "s", + sb: "sbom", + sbo: "sbom", + sbom: "sbom", + se: "se", + sea: "search", + sear: "search", + searc: "search", + search: "search", + set: "set", + sho: "show", + show: "show", + shr: "shrinkwrap", + shri: "shrinkwrap", + shrin: "shrinkwrap", + shrink: "shrinkwrap", + shrinkw: "shrinkwrap", + shrinkwr: "shrinkwrap", + shrinkwra: "shrinkwrap", + shrinkwrap: "shrinkwrap", + si: "sit", + sit: "sit", + star: "star", + stars: "stars", + start: "start", + sto: "stop", + stop: "stop", + t: "t", + tea: "team", + team: "team", + tes: "test", + test: "test", + to: "token", + tok: "token", + toke: "token", + token: "token", + ts: "tst", + tst: "tst", + ud: "udpate", + udp: "udpate", + udpa: "udpate", + udpat: "udpate", + udpate: "udpate", + un: "un", + und: "undeprecate", + unde: "undeprecate", + undep: "undeprecate", + undepr: "undeprecate", + undepre: "undeprecate", + undeprec: "undeprecate", + undepreca: "undeprecate", + undeprecat: "undeprecate", + undeprecate: "undeprecate", + uni: "uninstall", + unin: "uninstall", + unins: "uninstall", + uninst: "uninstall", + uninsta: "uninstall", + uninstal: "uninstall", + uninstall: "uninstall", + unl: "unlink", + unli: "unlink", + unlin: "unlink", + unlink: "unlink", + unp: "unpublish", + unpu: "unpublish", + unpub: "unpublish", + unpubl: "unpublish", + unpubli: "unpublish", + unpublis: "unpublish", + unpublish: "unpublish", + uns: "unstar", + unst: "unstar", + unsta: "unstar", + unstar: "unstar", + up: "up", + upd: "update", + upda: "update", + updat: "update", + update: "update", + upg: "upgrade", + upgr: "upgrade", + upgra: "upgrade", + upgrad: "upgrade", + upgrade: "upgrade", + ur: "urn", + urn: "urn", + v: "v", + veri: "verison", + veris: "verison", + veriso: "verison", + verison: "verison", + vers: "version", + versi: "version", + versio: "version", + version: "version", + vi: "view", + vie: "view", + view: "view", + who: "whoami", + whoa: "whoami", + whoam: "whoami", + whoami: "whoami", + why: "why", + x: "x", +}; diff --git a/packages/safe-chain/src/packagemanager/npm/utils/cmd-list.js b/packages/safe-chain/src/packagemanager/npm/utils/cmd-list.js index 187204d..3bcdd0d 100644 --- a/packages/safe-chain/src/packagemanager/npm/utils/cmd-list.js +++ b/packages/safe-chain/src/packagemanager/npm/utils/cmd-list.js @@ -1,6 +1,6 @@ // Based on https://github.com/npm/cli/blob/latest/lib/utils/cmd-list.js -import abbrev from "abbrev"; +import { abbrevs } from "./abbrevs-generated.js"; const commands = [ "access", @@ -73,6 +73,7 @@ const commands = [ ]; // These must resolve to an entry in commands +/** @type {Record} */ const aliases = { // aliases author: "owner", @@ -138,6 +139,10 @@ const aliases = { "add-user": "adduser", }; +/** + * @param {string} c + * @returns {string | undefined} + */ export function deref(c) { if (!c) { return; @@ -158,8 +163,6 @@ export function deref(c) { return aliases[c]; } - const abbrevs = abbrev(commands.concat(Object.keys(aliases))); - // first deref the abbrev, if there is one // then resolve any aliases // so `npm install-cl` will resolve to `install-clean` then to `ci` diff --git a/packages/safe-chain/src/packagemanager/npm/utils/npmCommands.js b/packages/safe-chain/src/packagemanager/npm/utils/npmCommands.js index 3096144..b645369 100644 --- a/packages/safe-chain/src/packagemanager/npm/utils/npmCommands.js +++ b/packages/safe-chain/src/packagemanager/npm/utils/npmCommands.js @@ -1,5 +1,9 @@ import { deref } from "./cmd-list.js"; +/** + * @param {string[]} args + * @returns {string | null} + */ export function getNpmCommandForArgs(args) { if (args.length === 0) { return null; @@ -13,6 +17,10 @@ export function getNpmCommandForArgs(args) { return argCommand; } +/** + * @param {string[]} args + * @returns {boolean} + */ export function hasDryRunArg(args) { return args.some((arg) => arg === "--dry-run"); } diff --git a/packages/safe-chain/src/packagemanager/npx/createPackageManager.js b/packages/safe-chain/src/packagemanager/npx/createPackageManager.js index a3319fa..96d495b 100644 --- a/packages/safe-chain/src/packagemanager/npx/createPackageManager.js +++ b/packages/safe-chain/src/packagemanager/npx/createPackageManager.js @@ -1,6 +1,9 @@ import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js"; import { runNpx } from "./runNpxCommand.js"; +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ export function createNpxPackageManager() { const scanner = commandArgumentScanner(); diff --git a/packages/safe-chain/src/packagemanager/npx/dependencyScanner/commandArgumentScanner.js b/packages/safe-chain/src/packagemanager/npx/dependencyScanner/commandArgumentScanner.js index 16328cb..689e3f8 100644 --- a/packages/safe-chain/src/packagemanager/npx/dependencyScanner/commandArgumentScanner.js +++ b/packages/safe-chain/src/packagemanager/npx/dependencyScanner/commandArgumentScanner.js @@ -1,16 +1,28 @@ import { resolvePackageVersion } from "../../../api/npmApi.js"; import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js"; +/** + * @returns {import("../../npm/dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner} + */ export function commandArgumentScanner() { return { scan: (args) => scanDependencies(args), shouldScan: () => true, // all npx commands need to be scanned, npx doesn't have dry-run }; } + +/** + * @param {string[]} args + * @returns {Promise} + */ function scanDependencies(args) { return checkChangesFromArgs(args); } +/** + * @param {string[]} args + * @returns {Promise} + */ export async function checkChangesFromArgs(args) { const changes = []; const packageUpdates = parsePackagesFromArguments(args); diff --git a/packages/safe-chain/src/packagemanager/npx/parsing/parsePackagesFromArguments.js b/packages/safe-chain/src/packagemanager/npx/parsing/parsePackagesFromArguments.js index efc8d81..25fb249 100644 --- a/packages/safe-chain/src/packagemanager/npx/parsing/parsePackagesFromArguments.js +++ b/packages/safe-chain/src/packagemanager/npx/parsing/parsePackagesFromArguments.js @@ -1,3 +1,8 @@ +/** + * @param {string[]} args + * + * @returns {{name: string, version: string}[]} + */ export function parsePackagesFromArguments(args) { let defaultTag = "latest"; @@ -21,6 +26,10 @@ export function parsePackagesFromArguments(args) { return []; } +/** + * @param {string} arg + * @returns {{name: string, numberOfParameters: number} | undefined} + */ function getOption(arg) { if (isOptionWithParameter(arg)) { return { @@ -41,6 +50,10 @@ function getOption(arg) { return undefined; } +/** + * @param {string} arg + * @returns {boolean} + */ function isOptionWithParameter(arg) { const optionsWithParameters = [ "--access", @@ -68,6 +81,11 @@ function isOptionWithParameter(arg) { return optionsWithParameters.includes(arg); } +/** + * @param {string} arg + * @param {string} defaultTag + * @returns {{name: string, version: string}} + */ function parsePackagename(arg, defaultTag) { // format can be --package=name@version // in that case, we need to remove the --package= part @@ -97,6 +115,10 @@ function parsePackagename(arg, defaultTag) { }; } +/** + * @param {string} arg + * @returns {string} + */ function removeAlias(arg) { // removes the alias. // Eg.: server@npm:http-server@latest becomes http-server@latest diff --git a/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js b/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js index b8896b7..2501b79 100644 --- a/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js +++ b/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js @@ -2,6 +2,11 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +/** + * @param {string[]} args + * + * @returns {Promise<{status: number}>} + */ export async function runNpx(args) { try { const result = await safeSpawn("npx", args, { @@ -9,7 +14,7 @@ export async function runNpx(args) { env: mergeSafeChainProxyEnvironmentVariables(process.env), }); return { status: result.status }; - } catch (error) { + } catch (/** @type any */ error) { if (error.status) { return { status: error.status }; } else { diff --git a/packages/safe-chain/src/packagemanager/pip/createPackageManager.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.js new file mode 100644 index 0000000..bd78605 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pip/createPackageManager.js @@ -0,0 +1,25 @@ +import { runPip } from "./runPipCommand.js"; +import { PIP_COMMAND } from "./pipSettings.js"; + +/** + * @param {{ tool: string, args: string[] }} [context] - Optional context with tool name and args + * @returns {import("../currentPackageManager.js").PackageManager} + */ +export function createPipPackageManager(context) { + const tool = context?.tool || PIP_COMMAND; + + return { + /** + * @param {string[]} args + */ + runCommand: (args) => { + // Args from main.js are already stripped of --safe-chain-* flags + // We just pass the tool (e.g. "python3") and the args (e.g. ["-m", "pip", "install", ...]) + return runPip(tool, args); + }, + // For pip, rely solely on MITM proxy to detect/deny downloads from known registries. + isSupportedCommand: () => false, + getDependencyUpdatesForCommand: () => [], + }; +} + diff --git a/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js new file mode 100644 index 0000000..d2668c0 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js @@ -0,0 +1,57 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { createPipPackageManager } from "./createPackageManager.js"; + +test("createPipPackageManager", async (t) => { + await t.test("should create package manager with required interface", () => { + const pm = createPipPackageManager(); + + assert.ok(pm); + assert.strictEqual(typeof pm.runCommand, "function"); + assert.strictEqual(typeof pm.isSupportedCommand, "function"); + assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); + }); + + await t.test("should accept pip3 as command parameter", () => { + const pm = createPipPackageManager("pip3"); + assert.ok(pm); + }); + + await t.test("should support install, download, and wheel commands", () => { + const pm = createPipPackageManager(); + // MITM-only approach, pip does not scan args + assert.strictEqual(pm.isSupportedCommand(["install", "requests"]), false); + assert.strictEqual(pm.isSupportedCommand(["download", "requests"]), false); + assert.strictEqual(pm.isSupportedCommand(["wheel", "requests"]), false); + }); + + await t.test("should not support uninstall and info commands", () => { + const pm = createPipPackageManager(); + + assert.strictEqual(pm.isSupportedCommand(["uninstall", "requests"]), false); + assert.strictEqual(pm.isSupportedCommand(["list"]), false); + assert.strictEqual(pm.isSupportedCommand(["show", "requests"]), false); + }); + + await t.test("should extract packages from install command", () => { + const pm = createPipPackageManager(); + const result = pm.getDependencyUpdatesForCommand(["install", "requests==2.28.0"]); + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 0); + }); + + await t.test("should return empty array for unsupported commands", () => { + const pm = createPipPackageManager(); + + const result = pm.getDependencyUpdatesForCommand(["uninstall", "requests"]); + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 0); + }); + + await t.test("should handle empty args gracefully", () => { + const pm = createPipPackageManager(); + + assert.strictEqual(pm.isSupportedCommand([]), false); + assert.deepStrictEqual(pm.getDependencyUpdatesForCommand([]), []); + }); +}); diff --git a/packages/safe-chain/src/packagemanager/pip/pipSettings.js b/packages/safe-chain/src/packagemanager/pip/pipSettings.js new file mode 100644 index 0000000..1ef6720 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pip/pipSettings.js @@ -0,0 +1,6 @@ +export const PIP_PACKAGE_MANAGER = "pip"; + +export const PIP_COMMAND = "pip"; +export const PIP3_COMMAND = "pip3"; +export const PYTHON_COMMAND = "python"; +export const PYTHON3_COMMAND = "python3"; diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js new file mode 100644 index 0000000..dc9a1ad --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js @@ -0,0 +1,211 @@ +import { ui } from "../../environment/userInteraction.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; +import { PIP_COMMAND, PIP3_COMMAND, PYTHON_COMMAND, PYTHON3_COMMAND } from "./pipSettings.js"; +import fs from "node:fs/promises"; +import fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import ini from "ini"; + +/** + * Checks if this pip invocation should bypass safe-chain and spawn directly. + * Returns true if the tool is python/python3 but NOT being run with -m pip/pip3. + * @param {string} command - The command executable + * @param {string[]} args - The arguments + * @returns {boolean} + */ +function shouldBypassSafeChain(command, args) { + if (command === PYTHON_COMMAND || command === PYTHON3_COMMAND) { + // Check if args start with -m pip + if (args.length >= 2 && args[0] === "-m" && (args[1] === PIP_COMMAND || args[1] === PIP3_COMMAND)) { + return false; + } + return true; + } + return false; +} + +/** + * Sets fallback CA bundle environment variables used by Python libraries. + * These are applied in addition to the PIP_CONFIG_FILE to ensure all Python + * network libraries respect the combined CA bundle, even if they don't read pip's config. + * + * @param {NodeJS.ProcessEnv} env - Environment object to modify + * @param {string} combinedCaPath - Path to the combined CA bundle + */ +function setFallbackCaBundleEnvironmentVariables(env, combinedCaPath) { + // REQUESTS_CA_BUNDLE: Used by the popular 'requests' library + if (env.REQUESTS_CA_BUNDLE) { + ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); + } + env.REQUESTS_CA_BUNDLE = combinedCaPath; + + // SSL_CERT_FILE: Used by some Python SSL libraries and urllib + if (env.SSL_CERT_FILE) { + ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); + } + env.SSL_CERT_FILE = combinedCaPath; + + // PIP_CERT: Pip's own environment variable for certificate verification + if (env.PIP_CERT) { + ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); + } + env.PIP_CERT = combinedCaPath; +} + +/** + * Runs a pip command with safe-chain's certificate bundle and proxy configuration. + * + * Creates a temporary pip config file to configure: + * - Cert bundle for HTTPS verification + * - Proxy settings + * + * If the user has an existing PIP_CONFIG_FILE, a new temporary config is created that merges + * their settings with safe-chain's, leaving the original file unchanged. + * + * Special handling for commands that modify config/cache/state: PIP_CONFIG_FILE is NOT overridden to allow + * users to read/write persistent config. Only CA environment variables are set for these commands. + * + * @param {string} command - The pip command executable (e.g., 'pip3' or 'python3') + * @param {string[]} args - Command line arguments to pass to pip + * @returns {Promise<{status: number}>} Exit status of the pip command + */ +export async function runPip(command, args) { + // Check if we should bypass safe-chain (python/python3 without -m pip) + if (shouldBypassSafeChain(command, args)) { + ui.writeVerbose(`Safe-chain: Bypassing safe-chain for non-pip invocation: ${command} ${args.join(" ")}`); + // Spawn the ORIGINAL command with ORIGINAL args + const { spawn } = await import("child_process"); + return new Promise((_resolve) => { + const proc = spawn(command, args, { stdio: "inherit" }); + proc.on("exit", (/** @type {number | null} */ code) => { + process.exit(code ?? 0); + }); + proc.on("error", (/** @type {Error} */ err) => { + ui.writeError(`Error executing command: ${err.message}`); + process.exit(1); + }); + }); + } + + try { + const env = mergeSafeChainProxyEnvironmentVariables(process.env); + + // Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots) + // so that any network request made by pip, including those outside explicit CLI args, + // validates correctly under both MITM'd and tunneled HTTPS. + const combinedCaPath = getCombinedCaBundlePath(); + + // Commands that need access to persistent config/cache/state files + // These should not have PIP_CONFIG_FILE overridden as it would prevent them from + // reading/writing to the user's actual pip configuration and cache directories + const configRelatedCommands = ['config', 'cache', 'debug', 'completion']; + const isConfigRelatedCommand = args.length > 0 && configRelatedCommands.includes(args[0]); + + // https://pip.pypa.io/en/stable/topics/https-certificates/ explains that the 'cert' param (which we're providing via INI file) + // will tell pip to use the provided CA bundle for HTTPS verification. + + // Proxy settings: GLOBAL_AGENT_HTTP_PROXY is our safe-chain proxy (if active), + // otherwise fall back to user-defined HTTPS_PROXY or HTTP_PROXY environment variables + const proxy = env.GLOBAL_AGENT_HTTP_PROXY || env.HTTPS_PROXY || env.HTTP_PROXY || ''; + + const tmpDir = os.tmpdir(); + const pipConfigPath = path.join(tmpDir, `safe-chain-pip-${Date.now()}.ini`); + let cleanupConfigPath = null; // Track temp file for cleanup + + if (isConfigRelatedCommand) { + ui.writeVerbose(`Safe-chain: Skipping PIP_CONFIG_FILE override for 'pip ${args[0]}' command to allow persistent config/cache access.`); + + // Still set the fallback CA bundle environment variables to avoid edge cases where a + // plugin or extension triggers a network call during config introspection + // This can do no harm + setFallbackCaBundleEnvironmentVariables(env, combinedCaPath); + + const result = await safeSpawn(command, args, { + stdio: "inherit", + env, + }); + + return { status: result.status }; + } + + // Note: Setting PIP_CONFIG_FILE overrides all pip config levels (Global/User/Site) per pip's loading order + if (!env.PIP_CONFIG_FILE) { + /** @type {{ global: { cert: string, proxy?: string } }} */ + const configObj = { global: { cert: combinedCaPath } }; + if (proxy) { + configObj.global.proxy = proxy; + } + const pipConfig = ini.stringify(configObj); + await fs.writeFile(pipConfigPath, pipConfig); + env.PIP_CONFIG_FILE = pipConfigPath; + cleanupConfigPath = pipConfigPath; + + } else if (fsSync.existsSync(env.PIP_CONFIG_FILE)) { + ui.writeVerbose("Safe-chain: Merging user provided PIP_CONFIG_FILE with safe-chain certificate and proxy settings."); + const userConfig = env.PIP_CONFIG_FILE; + + // Read the existing config without modifying it + let content = await fs.readFile(userConfig, "utf-8"); + const parsed = ini.parse(content); + + // Ensure [global] section exists + parsed.global = parsed.global || {}; + + // Cert + if (typeof parsed.global.cert !== "undefined") { + ui.writeWarning("Safe-chain: User defined cert found in PIP_CONFIG_FILE. It will be overwritten in the temporary config."); + } + parsed.global.cert = combinedCaPath; + + // Proxy + if (typeof parsed.global.proxy !== "undefined") { + ui.writeWarning("Safe-chain: User defined proxy found in PIP_CONFIG_FILE. It will be overwritten in the temporary config."); + } + if (proxy) { + parsed.global.proxy = proxy; + } + + const updated = ini.stringify(parsed); + + // Save to a new temp file to avoid overwriting user's original config + await fs.writeFile(pipConfigPath, updated, "utf-8"); + env.PIP_CONFIG_FILE = pipConfigPath; + cleanupConfigPath = pipConfigPath; + + } else { + // The user provided PIP_CONFIG_FILE does not exist on disk + // PIP will handle this as an error and inform the user + } + + // Set fallback CA bundle environment variables for Python libraries that don't read pip config + setFallbackCaBundleEnvironmentVariables(env, combinedCaPath); + + const result = await safeSpawn(command, args, { + stdio: "inherit", + env, + }); + + // Cleanup temporary config file if we created one + if (cleanupConfigPath) { + try { + await fs.unlink(cleanupConfigPath); + } catch { + // Ignore cleanup errors - the file may have already been deleted or is inaccessible + // Temp files in os.tmpdir() may eventually be cleaned by the OS, but timing varies by platform + } + } + + return { status: result.status }; + } catch (/** @type any */ error) { + if (error.status) { + return { status: error.status }; + } else { + ui.writeError(`Error executing command: ${error.message}`); + ui.writeError(`Is '${command}' installed and available on your system?`); + return { status: 1 }; + } + } +} diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js new file mode 100644 index 0000000..cf121f6 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js @@ -0,0 +1,400 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import ini from "ini"; + +describe("runPipCommand environment variable handling", () => { + let runPip; + let capturedArgs = null; + let customEnv = null; + let capturedConfigContent = null; // Capture config file content before cleanup + + beforeEach(async () => { + capturedArgs = null; + capturedConfigContent = null; + + // Mock safeSpawn to capture args and config file content before cleanup + mock.module("../../utils/safeSpawn.js", { + namedExports: { + safeSpawn: async (command, args, options) => { + capturedArgs = { command, args, options }; + // Capture the config file content before the function cleans it up + if (options.env.PIP_CONFIG_FILE) { + try { + capturedConfigContent = await fs.readFile(options.env.PIP_CONFIG_FILE, "utf-8"); + } catch { + // Ignore if file doesn't exist or can't be read + } + } + return { status: 0 }; + }, + }, + }); + + // Mock proxy env merge, allow custom env override + mock.module("../../registryProxy/registryProxy.js", { + namedExports: { + mergeSafeChainProxyEnvironmentVariables: (env) => ({ + ...env, + ...customEnv, + // Force deterministic proxy for tests regardless of ambient env + GLOBAL_AGENT_HTTP_PROXY: "http://localhost:8080", + HTTPS_PROXY: "http://localhost:8080", + HTTP_PROXY: "", + }), + }, + }); + + // Mock certBundle to return a test combined bundle path + mock.module("../../registryProxy/certBundle.js", { + namedExports: { + getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem", + }, + }); + + const mod = await import("./runPipCommand.js"); + runPip = mod.runPip; + }); + + afterEach(() => { + mock.reset(); + }); + + it("should NOT set PIP_CONFIG_FILE for 'pip config' commands to allow persistent config access", async () => { + const res = await runPip("pip3", ["config", "set", "global.index-url", "https://test.pypi.org/simple"]); + assert.strictEqual(res.status, 0); + assert.ok(capturedArgs, "safeSpawn should have been called"); + + // PIP_CONFIG_FILE should NOT be set for config commands + assert.strictEqual( + capturedArgs.options.env.PIP_CONFIG_FILE, + undefined, + "PIP_CONFIG_FILE should NOT be set for pip config commands" + ); + + // But CA environment variables should still be set + assert.strictEqual( + capturedArgs.options.env.REQUESTS_CA_BUNDLE, + "/tmp/test-combined-ca.pem", + "REQUESTS_CA_BUNDLE should still be set" + ); + assert.strictEqual( + capturedArgs.options.env.SSL_CERT_FILE, + "/tmp/test-combined-ca.pem", + "SSL_CERT_FILE should still be set" + ); + assert.strictEqual( + capturedArgs.options.env.PIP_CERT, + "/tmp/test-combined-ca.pem", + "PIP_CERT should still be set" + ); + }); + + it("should NOT set PIP_CONFIG_FILE for 'pip config get' commands", async () => { + const res = await runPip("pip3", ["config", "get", "global.index-url"]); + assert.strictEqual(res.status, 0); + assert.ok(capturedArgs, "safeSpawn should have been called"); + + assert.strictEqual( + capturedArgs.options.env.PIP_CONFIG_FILE, + undefined, + "PIP_CONFIG_FILE should NOT be set for pip config get" + ); + }); + + it("should NOT set PIP_CONFIG_FILE for 'pip config list' commands", async () => { + const res = await runPip("pip3", ["config", "list"]); + assert.strictEqual(res.status, 0); + assert.ok(capturedArgs, "safeSpawn should have been called"); + + assert.strictEqual( + capturedArgs.options.env.PIP_CONFIG_FILE, + undefined, + "PIP_CONFIG_FILE should NOT be set for pip config list" + ); + }); + + it("should NOT set PIP_CONFIG_FILE for 'pip cache' commands", async () => { + const res = await runPip("pip3", ["cache", "dir"]); + assert.strictEqual(res.status, 0); + assert.ok(capturedArgs, "safeSpawn should have been called"); + + assert.strictEqual( + capturedArgs.options.env.PIP_CONFIG_FILE, + undefined, + "PIP_CONFIG_FILE should NOT be set for pip cache commands" + ); + + // CA env vars should still be set + assert.strictEqual( + capturedArgs.options.env.SSL_CERT_FILE, + "/tmp/test-combined-ca.pem", + "SSL_CERT_FILE should still be set" + ); + }); + + it("should NOT set PIP_CONFIG_FILE for 'pip debug' commands", async () => { + const res = await runPip("pip3", ["debug"]); + assert.strictEqual(res.status, 0); + assert.ok(capturedArgs, "safeSpawn should have been called"); + + assert.strictEqual( + capturedArgs.options.env.PIP_CONFIG_FILE, + undefined, + "PIP_CONFIG_FILE should NOT be set for pip debug" + ); + }); + + it("should NOT set PIP_CONFIG_FILE for 'pip completion' commands", async () => { + const res = await runPip("pip3", ["completion", "--bash"]); + assert.strictEqual(res.status, 0); + assert.ok(capturedArgs, "safeSpawn should have been called"); + + assert.strictEqual( + capturedArgs.options.env.PIP_CONFIG_FILE, + undefined, + "PIP_CONFIG_FILE should NOT be set for pip completion" + ); + }); + + it("should set PIP_CERT env var and create config file", async () => { + const res = await runPip("pip3", ["install", "requests"]); + assert.strictEqual(res.status, 0); + assert.ok(capturedArgs, "safeSpawn should have been called"); + // Check PIP_CERT env var + assert.strictEqual( + capturedArgs.options.env.PIP_CERT, + "/tmp/test-combined-ca.pem", + "PIP_CERT should be set to combined bundle path" + ); + // Check PIP_CONFIG_FILE env var exists and is a non-empty string + const configPath = capturedArgs.options.env.PIP_CONFIG_FILE; + assert.ok(configPath, "PIP_CONFIG_FILE should be set"); + assert.strictEqual(typeof configPath, "string", "PIP_CONFIG_FILE should be a string"); + assert.ok(configPath.length > 0, "PIP_CONFIG_FILE should be a non-empty path"); + }); + + it("should set REQUESTS_CA_BUNDLE and SSL_CERT_FILE for default PyPI (no explicit index)", async () => { + const res = await runPip("pip3", ["install", "requests"]); + assert.strictEqual(res.status, 0); + + assert.ok(capturedArgs, "safeSpawn should have been called"); + + // Check environment variables are set + assert.strictEqual( + capturedArgs.options.env.REQUESTS_CA_BUNDLE, + "/tmp/test-combined-ca.pem", + "REQUESTS_CA_BUNDLE should be set to combined bundle path" + ); + assert.strictEqual( + capturedArgs.options.env.SSL_CERT_FILE, + "/tmp/test-combined-ca.pem", + "SSL_CERT_FILE should be set to combined bundle path" + ); + }); + + it("should set CA environment variables even for external/test PyPI mirror (covers non-CLI traffic)", async () => { + const res = await runPip("pip3", [ + "install", + "certifi", + "--index-url", + "https://test.pypi.org/simple", + ]); + assert.strictEqual(res.status, 0); + // Env vars should be set unconditionally + assert.strictEqual( + capturedArgs.options.env.REQUESTS_CA_BUNDLE, + "/tmp/test-combined-ca.pem" + ); + assert.strictEqual( + capturedArgs.options.env.SSL_CERT_FILE, + "/tmp/test-combined-ca.pem" + ); + }); + + it("should still set CA env vars for PyPI even with user --cert flag", async () => { + // For default PyPI, we still set env vars; pip CLI --cert takes precedence + const res = await runPip("pip3", ["install", "requests"]); + assert.strictEqual(res.status, 0); + + // Environment variables still set (pip CLI --cert takes precedence) + assert.strictEqual( + capturedArgs.options.env.REQUESTS_CA_BUNDLE, + "/tmp/test-combined-ca.pem" + ); + assert.strictEqual( + capturedArgs.options.env.SSL_CERT_FILE, + "/tmp/test-combined-ca.pem" + ); + }); + + it("should preserve HTTPS_PROXY from proxy merge", async () => { + const res = await runPip("pip3", ["install", "requests"]); + assert.strictEqual(res.status, 0); + + assert.strictEqual( + capturedArgs.options.env.HTTPS_PROXY, + "http://localhost:8080", + "HTTPS_PROXY should be set by proxy merge" + ); + }); + + it("should create a new temp config when existing config exists (original file untouched)", async () => { + const tmpDir = os.tmpdir(); + const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`); + const initial = "[global]\nindex-url = https://example.com/simple\n"; + await fs.writeFile(userCfgPath, initial, "utf-8"); + + customEnv = { PIP_CONFIG_FILE: userCfgPath }; + const res = await runPip("pip3", ["install", "requests"]); + assert.strictEqual(res.status, 0); + const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE; + assert.notStrictEqual(newCfgPath, userCfgPath, "should point to a new temp config file"); + + // Original file unchanged + const originalContent = await fs.readFile(userCfgPath, "utf-8"); + const originalParsed = ini.parse(originalContent); + assert.strictEqual(originalParsed.global.cert, undefined, "original file should not gain cert"); + + // New file has merged settings (read from captured content before cleanup) + assert.ok(capturedConfigContent, "config content should have been captured"); + const newParsed = ini.parse(capturedConfigContent); + assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "new config should include cert"); + assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "new config should include proxy from env"); + assert.strictEqual(newParsed.global["index-url"], "https://example.com/simple", "index-url should be preserved"); + customEnv = null; + }); + + it("should create new config with proxy set from env (ini-validated)", async () => { + // No PIP_CONFIG_FILE in env => creation path + const res = await runPip("pip3", ["install", "requests"]); + assert.strictEqual(res.status, 0); + + assert.ok(capturedConfigContent, "config content should have been captured"); + const parsed = ini.parse(capturedConfigContent); + assert.ok(parsed.global, "[global] should exist after creation"); + assert.strictEqual( + parsed.global.proxy, + "http://localhost:8080", + "proxy should be set from merged env" + ); + assert.strictEqual( + parsed.global.cert, + "/tmp/test-combined-ca.pem", + "cert should be set during creation" + ); + }); + + it("should create new temp config adding cert but preserving existing proxy (original file unchanged)", async () => { + const tmpDir = os.tmpdir(); + const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`); + const initial = "[global]\nproxy = http://original:9999\n"; + await fs.writeFile(userCfgPath, initial, "utf-8"); + + customEnv = { PIP_CONFIG_FILE: userCfgPath }; + const res = await runPip("pip3", ["install", "requests"]); + assert.strictEqual(res.status, 0); + const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE; + assert.notStrictEqual(newCfgPath, userCfgPath, "should use a new temp config file"); + + // Original file unchanged + const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8")); + assert.strictEqual(originalParsed.global.cert, undefined, "original file should not gain cert"); + assert.strictEqual(originalParsed.global.proxy, "http://original:9999", "original proxy remains"); + + // New file: cert and proxy always overwritten (read from captured content) + assert.ok(capturedConfigContent, "config content should have been captured"); + const newParsed = ini.parse(capturedConfigContent); + assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config"); + assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config"); + customEnv = null; + }); + + it("should create new temp config preserving existing cert and proxy while leaving original file unchanged", async () => { + const tmpDir = os.tmpdir(); + const cfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`); + const initialIni = [ + "[global]", + "cert = /path/to/existing.pem", + "proxy = http://original:9999", + "" + ].join("\n"); + await fs.writeFile(cfgPath, initialIni, "utf-8"); + + customEnv = { PIP_CONFIG_FILE: cfgPath }; + const res = await runPip("pip3", ["install", "requests"]); + assert.strictEqual(res.status, 0, "execution should succeed"); + const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE; + assert.notStrictEqual(newCfgPath, cfgPath, "should use a newly generated temp config file"); + + // Original file stays untouched + const originalContent = await fs.readFile(cfgPath, "utf-8"); + const originalParsed = ini.parse(originalContent); + assert.strictEqual(originalParsed.global.cert, "/path/to/existing.pem", "original cert preserved"); + assert.strictEqual(originalParsed.global.proxy, "http://original:9999", "original proxy preserved"); + + // New temp config: cert and proxy always overwritten (read from captured content) + assert.ok(capturedConfigContent, "config content should have been captured"); + const newParsed = ini.parse(capturedConfigContent); + assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config"); + assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config"); + customEnv = null; + }); + + it("should create new temp config preserving existing cert and adding missing proxy", async () => { + const tmpDir = os.tmpdir(); + const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`); + const initial = "[global]\ncert = /path/to/existing.pem\n"; + await fs.writeFile(userCfgPath, initial, "utf-8"); + + customEnv = { PIP_CONFIG_FILE: userCfgPath }; + const res = await runPip("pip3", ["install", "requests"]); + assert.strictEqual(res.status, 0); + const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE; + assert.notStrictEqual(newCfgPath, userCfgPath, "should produce a new temp config file"); + + // Original remains unchanged + const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8")); + assert.strictEqual(originalParsed.global.cert, "/path/to/existing.pem", "original cert unchanged"); + assert.strictEqual(originalParsed.global.proxy, undefined, "original proxy still missing"); + + // New file: cert and proxy always overwritten (read from captured content) + assert.ok(capturedConfigContent, "config content should have been captured"); + const newParsed = ini.parse(capturedConfigContent); + assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config"); + assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config"); + customEnv = null; + }); + + it("should log warnings when cert and proxy are already set in user config file", async () => { + const tmpDir = os.tmpdir(); + const cfgPath = path.join(tmpDir, `safe-chain-test-pip-warn-${Date.now()}.ini`); + const initialIni = [ + "[global]", + "cert = /user/cert.pem", + "proxy = http://user-proxy:9999", + "" + ].join("\n"); + await fs.writeFile(cfgPath, initialIni, "utf-8"); + + customEnv = { PIP_CONFIG_FILE: cfgPath }; + + // Capture stdout/stderr + let output = ""; + const originalWrite = process.stdout.write; + const originalError = process.stderr.write; + process.stdout.write = (chunk, ...args) => { output += chunk; return originalWrite.apply(process.stdout, [chunk, ...args]); }; + process.stderr.write = (chunk, ...args) => { output += chunk; return originalError.apply(process.stderr, [chunk, ...args]); }; + + await runPip("pip3", ["install", "requests"]); + + process.stdout.write = originalWrite; + process.stderr.write = originalError; + + assert.ok(output.includes("cert found in PIP_CONFIG_FILE"), "Should warn about cert overwrite in output"); + assert.ok(output.includes("proxy found in PIP_CONFIG_FILE"), "Should warn about proxy overwrite in output"); + customEnv = null; + }); +}); diff --git a/packages/safe-chain/src/packagemanager/pnpm/createPackageManager.js b/packages/safe-chain/src/packagemanager/pnpm/createPackageManager.js index 15cb628..c3046c8 100644 --- a/packages/safe-chain/src/packagemanager/pnpm/createPackageManager.js +++ b/packages/safe-chain/src/packagemanager/pnpm/createPackageManager.js @@ -4,6 +4,9 @@ import { runPnpmCommand } from "./runPnpmCommand.js"; const scanner = commandArgumentScanner(); +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ export function createPnpmPackageManager() { return { runCommand: (args) => runPnpmCommand(args, "pnpm"), @@ -23,6 +26,9 @@ export function createPnpmPackageManager() { }; } +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ export function createPnpxPackageManager() { return { runCommand: (args) => runPnpmCommand(args, "pnpx"), @@ -32,6 +38,11 @@ export function createPnpxPackageManager() { }; } +/** + * @param {string[]} args + * @param {boolean} isPnpx + * @returns {ReturnType} + */ function getDependencyUpdatesForCommand(args, isPnpx) { if (isPnpx) { return scanner.scan(args); diff --git a/packages/safe-chain/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js b/packages/safe-chain/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js index c184b38..e46d2db 100644 --- a/packages/safe-chain/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js +++ b/packages/safe-chain/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js @@ -1,6 +1,9 @@ import { resolvePackageVersion } from "../../../api/npmApi.js"; import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js"; +/** + * @returns {import("../../npm/dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner} + */ export function commandArgumentScanner() { return { scan: (args) => scanDependencies(args), @@ -8,6 +11,10 @@ export function commandArgumentScanner() { }; } +/** + * @param {string[]} args + * @returns {Promise} + */ async function scanDependencies(args) { const changes = []; const packageUpdates = parsePackagesFromArguments(args); diff --git a/packages/safe-chain/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js b/packages/safe-chain/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js index d0383c2..b8a6f39 100644 --- a/packages/safe-chain/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js +++ b/packages/safe-chain/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js @@ -1,3 +1,7 @@ +/** + * @param {string[]} args + * @returns {{name: string, version: string}[]} + */ export function parsePackagesFromArguments(args) { const changes = []; let defaultTag = "latest"; @@ -22,6 +26,10 @@ export function parsePackagesFromArguments(args) { return changes; } +/** + * @param {string} arg + * @returns {{name: string, numberOfParameters: number} | undefined} + */ function getOption(arg) { if (isOptionWithParameter(arg)) { return { @@ -42,12 +50,21 @@ function getOption(arg) { return undefined; } +/** + * @param {string} arg + * @returns {boolean} + */ function isOptionWithParameter(arg) { const optionsWithParameters = ["--C", "--dir"]; return optionsWithParameters.includes(arg); } +/** + * @param {string} arg + * @param {string} defaultTag + * @returns {{name: string, version: string}} + */ function parsePackagename(arg, defaultTag) { // format can be --package=name@version // in that case, we need to remove the --package= part @@ -77,6 +94,10 @@ function parsePackagename(arg, defaultTag) { }; } +/** + * @param {string} arg + * @returns {string} + */ function removeAlias(arg) { // removes the alias. // Eg.: server@npm:http-server@latest becomes http-server@latest diff --git a/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js b/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js index 794d6e3..d958fb8 100644 --- a/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js +++ b/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js @@ -2,6 +2,11 @@ import { ui } from "../../environment/userInteraction.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; +/** + * @param {string[]} args + * @param {string} [toolName] + * @returns {Promise<{status: number}>} + */ export async function runPnpmCommand(args, toolName = "pnpm") { try { let result; @@ -20,7 +25,7 @@ export async function runPnpmCommand(args, toolName = "pnpm") { } return { status: result.status }; - } catch (error) { + } catch (/** @type any */ error) { if (error.status) { return { status: error.status }; } else { diff --git a/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.js b/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.js new file mode 100644 index 0000000..76f642b --- /dev/null +++ b/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.js @@ -0,0 +1,18 @@ +import { runUv } from "./runUvCommand.js"; + +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ +export function createUvPackageManager() { + return { + /** + * @param {string[]} args + */ + runCommand: (args) => { + return runUv("uv", args); + }, + // For uv, rely solely on MITM + isSupportedCommand: () => false, + getDependencyUpdatesForCommand: () => [], + }; +} diff --git a/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.spec.js b/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.spec.js new file mode 100644 index 0000000..eb42924 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/uv/createUvPackageManager.spec.js @@ -0,0 +1,14 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { createUvPackageManager } from "./createUvPackageManager.js"; + +test("createUvPackageManager", async (t) => { + await t.test("should create package manager with required interface", () => { + const pm = createUvPackageManager(); + + assert.ok(pm); + assert.strictEqual(typeof pm.runCommand, "function"); + assert.strictEqual(typeof pm.isSupportedCommand, "function"); + assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); + }); +}); diff --git a/packages/safe-chain/src/packagemanager/uv/runUvCommand.js b/packages/safe-chain/src/packagemanager/uv/runUvCommand.js new file mode 100644 index 0000000..ed02fe3 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/uv/runUvCommand.js @@ -0,0 +1,71 @@ +import { ui } from "../../environment/userInteraction.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; + +/** + * Sets CA bundle environment variables used by Python libraries and uv. + * + * @param {NodeJS.ProcessEnv} env - Env object + * @param {string} combinedCaPath - Path to the combined CA bundle + */ +function setUvCaBundleEnvironmentVariables(env, combinedCaPath) { + // SSL_CERT_FILE: Used by Python SSL libraries and underlying HTTP clients + if (env.SSL_CERT_FILE) { + ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); + } + env.SSL_CERT_FILE = combinedCaPath; + + // REQUESTS_CA_BUNDLE: Used by the requests library (which uv may use internally) + if (env.REQUESTS_CA_BUNDLE) { + ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); + } + env.REQUESTS_CA_BUNDLE = combinedCaPath; + + // PIP_CERT: Some underlying pip operations may respect this + if (env.PIP_CERT) { + ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); + } + env.PIP_CERT = combinedCaPath; +} + +/** + * Runs a uv command with safe-chain's certificate bundle and proxy configuration. + * + * uv respects standard environment variables for proxy and TLS configuration: + * - HTTP_PROXY / HTTPS_PROXY: Proxy settings + * - SSL_CERT_FILE / REQUESTS_CA_BUNDLE: CA bundle for TLS verification + * + * Unlike pip (which requires a temporary config file for cert configuration), uv directly + * honors environment variables, so no config/ini file is needed. + * + * @param {string} command - The uv command to execute (typically 'uv') + * @param {string[]} args - Command line arguments to pass to uv + * @returns {Promise<{status: number}>} Exit status of the uv command + */ +export async function runUv(command, args) { + try { + const env = mergeSafeChainProxyEnvironmentVariables(process.env); + + const combinedCaPath = getCombinedCaBundlePath(); + setUvCaBundleEnvironmentVariables(env, combinedCaPath); + + // Note: uv uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration + // These are already set by mergeSafeChainProxyEnvironmentVariables + + const result = await safeSpawn(command, args, { + stdio: "inherit", + env, + }); + + return { status: result.status }; + } catch (/** @type any */ error) { + if (error.status) { + return { status: error.status }; + } else { + ui.writeError(`Error executing command: ${error.message}`); + ui.writeError(`Is '${command}' installed and available on your system?`); + return { status: 1 }; + } + } +} diff --git a/packages/safe-chain/src/packagemanager/yarn/createPackageManager.js b/packages/safe-chain/src/packagemanager/yarn/createPackageManager.js index f49c763..f8a0c84 100644 --- a/packages/safe-chain/src/packagemanager/yarn/createPackageManager.js +++ b/packages/safe-chain/src/packagemanager/yarn/createPackageManager.js @@ -3,6 +3,9 @@ import { runYarnCommand } from "./runYarnCommand.js"; const scanner = commandArgumentScanner(); +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ export function createYarnPackageManager() { return { runCommand: runYarnCommand, @@ -18,6 +21,11 @@ export function createYarnPackageManager() { }; } +/** + * @param {string[]} args + * @param {...string} commandArgs + * @returns {boolean} + */ function matchesCommand(args, ...commandArgs) { if (args.length < commandArgs.length) { return false; diff --git a/packages/safe-chain/src/packagemanager/yarn/dependencyScanner/commandArgumentScanner.js b/packages/safe-chain/src/packagemanager/yarn/dependencyScanner/commandArgumentScanner.js index f5bdd9f..5141d54 100644 --- a/packages/safe-chain/src/packagemanager/yarn/dependencyScanner/commandArgumentScanner.js +++ b/packages/safe-chain/src/packagemanager/yarn/dependencyScanner/commandArgumentScanner.js @@ -1,6 +1,9 @@ import { resolvePackageVersion } from "../../../api/npmApi.js"; import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js"; +/** + * @returns {import("../../npm/dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner} + */ export function commandArgumentScanner() { return { scan: (args) => scanDependencies(args), @@ -8,6 +11,10 @@ export function commandArgumentScanner() { }; } +/** + * @param {string[]} args + * @returns {Promise} + */ async function scanDependencies(args) { const changes = []; const packageUpdates = parsePackagesFromArguments(args); diff --git a/packages/safe-chain/src/packagemanager/yarn/parsing/parsePackagesFromArguments.js b/packages/safe-chain/src/packagemanager/yarn/parsing/parsePackagesFromArguments.js index 7b0255e..8f97de5 100644 --- a/packages/safe-chain/src/packagemanager/yarn/parsing/parsePackagesFromArguments.js +++ b/packages/safe-chain/src/packagemanager/yarn/parsing/parsePackagesFromArguments.js @@ -1,3 +1,7 @@ +/** + * @param {string[]} args + * @returns {{name: string, version: string}[]} + */ export function parsePackagesFromArguments(args) { const changes = []; let defaultTag = "latest"; @@ -22,6 +26,11 @@ export function parsePackagesFromArguments(args) { return changes; } +/** + * @param {string} arg + * + * @returns {{name: string, numberOfParameters: number} | undefined} + */ function getOption(arg) { if (isOptionWithParameter(arg)) { return { @@ -42,6 +51,11 @@ function getOption(arg) { return undefined; } +/** + * @param {string} arg + * + * @returns {boolean} + */ function isOptionWithParameter(arg) { const optionsWithParameters = [ "--use-yarnrc", @@ -64,6 +78,12 @@ function isOptionWithParameter(arg) { return optionsWithParameters.includes(arg); } +/** + * @param {string} arg + * @param {string} defaultTag + * + * @returns {{name: string, version: string}} + */ function parsePackagename(arg, defaultTag) { // format can be --package=name@version // in that case, we need to remove the --package= part @@ -93,6 +113,10 @@ function parsePackagename(arg, defaultTag) { }; } +/** + * @param {string} arg + * @returns {string} + */ function removeAlias(arg) { // removes the alias. // Eg.: server@npm:http-server@latest becomes http-server@latest diff --git a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js index 2c3795c..2089551 100644 --- a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js +++ b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js @@ -2,6 +2,11 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +/** + * @param {string[]} args + * + * @returns {Promise<{status: number}>} + */ export async function runYarnCommand(args) { try { const env = mergeSafeChainProxyEnvironmentVariables(process.env); @@ -12,7 +17,7 @@ export async function runYarnCommand(args) { env, }); return { status: result.status }; - } catch (error) { + } catch (/** @type any */ error) { if (error.status) { return { status: error.status }; } else { @@ -22,32 +27,15 @@ export async function runYarnCommand(args) { } } +/** + * @param {Record} env + * + * @returns {Promise} + */ async function fixYarnProxyEnvironmentVariables(env) { - // Yarn ignores standard proxy environment variables HTTPS_PROXY and NODE_EXTRA_CA_CERTS + // Yarn ignores standard proxy environment variable HTTPS_PROXY + // It does respect NODE_EXTRA_CA_CERTS for custom CA certificates though. + // Don't use YARN_HTTPS_CA_FILE_PATH or YARN_CA_FILE_PATH though, it causes yarn to ignore all system CAs - // Yarn v2/v3 and v4+ use different environment variables for proxy and CA certs - // When setting all variables, yarn returns an error about conflicting variables - // - v2/v3: "Usage Error: Unrecognized or legacy configuration settings found: httpsCaFilePath" - // - v4+: "Usage Error: Unrecognized or legacy configuration settings found: caFilePath" - - const version = await yarnVersion(); - const majorVersion = parseInt(version.split(".")[0]); - - if (majorVersion >= 4) { - env.YARN_HTTPS_PROXY = env.HTTPS_PROXY; - env.YARN_HTTPS_CA_FILE_PATH = env.NODE_EXTRA_CA_CERTS; - } else if (majorVersion === 2 || majorVersion === 3) { - env.YARN_HTTPS_PROXY = env.HTTPS_PROXY; - env.YARN_CA_FILE_PATH = env.NODE_EXTRA_CA_CERTS; - } -} - -async function yarnVersion() { - const result = await safeSpawn("yarn", ["--version"], { - stdio: "pipe", - }); - if (result.status !== 0) { - throw new Error("Failed to get yarn version"); - } - return result.stdout.trim(); + env.YARN_HTTPS_PROXY = env.HTTPS_PROXY; } diff --git a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.spec.js b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.spec.js new file mode 100644 index 0000000..21475f9 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.spec.js @@ -0,0 +1,152 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; + +describe("runYarnCommand", () => { + let runYarnCommand; + let capturedEnv; + let yarnVersion; + + beforeEach(async () => { + capturedEnv = null; + yarnVersion = "4.1.0"; // Default to v4 + + // Mock safeSpawn to capture env and control yarn version + mock.module("../../utils/safeSpawn.js", { + namedExports: { + safeSpawn: async (command, args, options) => { + if (args.includes("--version")) { + // Mock yarn version check + return { status: 0, stdout: yarnVersion }; + } + // Capture the env for assertions + capturedEnv = options.env; + return { status: 0 }; + }, + }, + }); + + // Mock mergeSafeChainProxyEnvironmentVariables to return test env + mock.module("../../registryProxy/registryProxy.js", { + namedExports: { + mergeSafeChainProxyEnvironmentVariables: (env) => { + return { + ...env, + HTTPS_PROXY: "http://localhost:8080", + NODE_EXTRA_CA_CERTS: "/path/to/ca-cert.pem", + }; + }, + }, + }); + + // Mock ui to prevent console output + mock.module("../../environment/userInteraction.js", { + namedExports: { + ui: { + writeError: () => {}, + }, + }, + }); + + const module = await import("./runYarnCommand.js"); + runYarnCommand = module.runYarnCommand; + }); + + afterEach(() => { + mock.reset(); + }); + + it("should set YARN_HTTPS_PROXY for Yarn v4+", async () => { + yarnVersion = "4.1.0"; + await runYarnCommand(["add", "lodash"]); + + assert.strictEqual( + capturedEnv.YARN_HTTPS_PROXY, + "http://localhost:8080", + "YARN_HTTPS_PROXY should be set to the HTTPS_PROXY value" + ); + assert.strictEqual( + capturedEnv.YARN_HTTPS_CA_FILE_PATH, + undefined, + "YARN_HTTPS_CA_FILE_PATH should NOT be set to avoid overriding system CAs" + ); + }); + + it("should set YARN_HTTPS_PROXY for Yarn v3", async () => { + yarnVersion = "3.6.4"; + await runYarnCommand(["add", "lodash"]); + + assert.strictEqual( + capturedEnv.YARN_HTTPS_PROXY, + "http://localhost:8080", + "YARN_HTTPS_PROXY should be set to the HTTPS_PROXY value" + ); + assert.strictEqual( + capturedEnv.YARN_CA_FILE_PATH, + undefined, + "YARN_CA_FILE_PATH should NOT be set to avoid overriding system CAs" + ); + }); + + it("should set YARN_HTTPS_PROXY for Yarn v2", async () => { + yarnVersion = "2.4.3"; + await runYarnCommand(["add", "lodash"]); + + assert.strictEqual( + capturedEnv.YARN_HTTPS_PROXY, + "http://localhost:8080", + "YARN_HTTPS_PROXY should be set to the HTTPS_PROXY value" + ); + assert.strictEqual( + capturedEnv.YARN_CA_FILE_PATH, + undefined, + "YARN_CA_FILE_PATH should NOT be set to avoid overriding system CAs" + ); + }); + + it("should set YARN_HTTPS_PROXY for Yarn v1", async () => { + yarnVersion = "1.22.19"; + await runYarnCommand(["add", "lodash"]); + + assert.strictEqual( + capturedEnv.YARN_HTTPS_PROXY, + "http://localhost:8080", + "YARN_HTTPS_PROXY should not be set for Yarn v1" + ); + assert.strictEqual( + capturedEnv.YARN_HTTPS_CA_FILE_PATH, + undefined, + "YARN_HTTPS_CA_FILE_PATH should not be set for Yarn v1" + ); + assert.strictEqual( + capturedEnv.YARN_CA_FILE_PATH, + undefined, + "YARN_CA_FILE_PATH should not be set for Yarn v1" + ); + }); + + it("should preserve NODE_EXTRA_CA_CERTS for all Yarn versions", async () => { + for (const version of ["4.1.0", "3.6.4", "2.4.3", "1.22.19"]) { + yarnVersion = version; + await runYarnCommand(["add", "lodash"]); + + assert.strictEqual( + capturedEnv.NODE_EXTRA_CA_CERTS, + "/path/to/ca-cert.pem", + `NODE_EXTRA_CA_CERTS should be preserved for Yarn ${version}` + ); + } + }); + + it("should preserve HTTPS_PROXY for all Yarn versions", async () => { + for (const version of ["4.1.0", "3.6.4", "2.4.3", "1.22.19"]) { + yarnVersion = version; + await runYarnCommand(["add", "lodash"]); + + assert.strictEqual( + capturedEnv.HTTPS_PROXY, + "http://localhost:8080", + `HTTPS_PROXY should be preserved for Yarn ${version}` + ); + } + }); +}); diff --git a/packages/safe-chain/src/registryProxy/certBundle.js b/packages/safe-chain/src/registryProxy/certBundle.js new file mode 100644 index 0000000..956279d --- /dev/null +++ b/packages/safe-chain/src/registryProxy/certBundle.js @@ -0,0 +1,95 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +// @ts-ignore - certifi has no type definitions +import certifi from "certifi"; +import tls from "node:tls"; +import { X509Certificate } from "node:crypto"; +import { getCaCertPath } from "./certUtils.js"; + +/** + * Check if a PEM string contains only parsable cert blocks. + * @param {string} pem - PEM-encoded certificate string + * @returns {boolean} + */ +function isParsable(pem) { + if (!pem || typeof pem !== "string") return false; + const begin = "-----BEGIN CERTIFICATE-----"; + const end = "-----END CERTIFICATE-----"; + const blocks = []; + + let idx = 0; + while (idx < pem.length) { + const start = pem.indexOf(begin, idx); + if (start === -1) break; + const stop = pem.indexOf(end, start + begin.length); + if (stop === -1) break; + const blockEnd = stop + end.length; + blocks.push(pem.slice(start, blockEnd)); + idx = blockEnd; + } + + if (blocks.length === 0) return false; + try { + for (const b of blocks) { + // throw if invalid + new X509Certificate(b); + } + return true; + } catch { + return false; + } +} + +/** @type {string | null} */ +let cachedPath = null; + +/** + * Build a combined CA bundle for Python and Node HTTPS flows. + * - Includes Safe Chain CA (for MITM of known registries) + * - Includes Mozilla roots via npm `certifi` (public HTTPS) + * - Includes Node's built-in root certificates as a portable fallback + * @returns {string} Path to the combined CA bundle PEM file + */ +export function getCombinedCaBundlePath() { + if (cachedPath && fs.existsSync(cachedPath)) return cachedPath; + + // Concatenate PEM files + const parts = []; + + // 1) Safe Chain CA (for MITM'd registries) + const safeChainPath = getCaCertPath(); + try { + const safeChainPem = fs.readFileSync(safeChainPath, "utf8"); + if (isParsable(safeChainPem)) parts.push(safeChainPem.trim()); + } catch { + // Ignore if Safe Chain CA is not available + } + + // 2) certifi (Mozilla CA bundle for all public HTTPS) + try { + const certifiPem = fs.readFileSync(certifi, "utf8"); + if (isParsable(certifiPem)) parts.push(certifiPem.trim()); + } catch { + // Ignore if certifi bundle is not available + } + + // 3) Node's built-in root certificates + try { + const nodeRoots = tls.rootCertificates; + if (Array.isArray(nodeRoots) && nodeRoots.length) { + for (const rootPem of nodeRoots) { + if (typeof rootPem !== "string") continue; + if (isParsable(rootPem)) parts.push(rootPem.trim()); + } + } + } catch { + // Ignore if unavailable + } + + const combined = parts.filter(Boolean).join("\n"); + const target = path.join(os.tmpdir(), "safe-chain-ca-bundle.pem"); + fs.writeFileSync(target, combined, { encoding: "utf8" }); + cachedPath = target; + return cachedPath; +} diff --git a/packages/safe-chain/src/registryProxy/certBundle.spec.js b/packages/safe-chain/src/registryProxy/certBundle.spec.js new file mode 100644 index 0000000..2f26d51 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/certBundle.spec.js @@ -0,0 +1,71 @@ +import { describe, it, beforeEach, mock } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import tls from "node:tls"; + +// Utility to remove the generated bundle so the module rebuilds it on demand +function removeBundleIfExists() { + const target = path.join(os.tmpdir(), "safe-chain-ca-bundle.pem"); + try { + if (fs.existsSync(target)) fs.unlinkSync(target); + } catch { + // ignore + } +} + +describe("certBundle.getCombinedCaBundlePath", () => { + beforeEach(() => { + mock.restoreAll(); + removeBundleIfExists(); + }); + + it("includes Safe Chain CA when parsable and produces a PEM bundle", async () => { + // Prepare a temporary Safe Chain CA file with a recognizable marker and a valid cert block + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-")); + const safeChainPath = path.join(tmpDir, "safechain-ca.pem"); + const marker = "# SAFE_CHAIN_TEST_MARKER"; + const rootPem = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : ""; + assert.ok(rootPem.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test"); + fs.writeFileSync(safeChainPath, `${marker}\n${rootPem}`, "utf8"); + + // Mock the certUtils.getCaCertPath to return our temp file + mock.module("./certUtils.js", { + namedExports: { + getCaCertPath: () => safeChainPath, + }, + }); + + const { getCombinedCaBundlePath } = await import("./certBundle.js"); + const bundlePath = getCombinedCaBundlePath(); + assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); + const contents = fs.readFileSync(bundlePath, "utf8"); + assert.match(contents, /-----BEGIN CERTIFICATE-----/); + assert.ok(contents.includes(marker), "Bundle should include Safe Chain CA content when parsable"); + }); + + it("ignores invalid Safe Chain CA but still builds from other sources", async () => { + // Write an invalid file (no cert blocks) + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-")); + const safeChainPath = path.join(tmpDir, "safechain-invalid.pem"); + const invalidMarker = "INVALID_SAFE_CHAIN_CONTENT"; + fs.writeFileSync(safeChainPath, invalidMarker, "utf8"); + + // Mock the certUtils.getCaCertPath to return our invalid file + mock.module("./certUtils.js", { + namedExports: { + getCaCertPath: () => safeChainPath, + }, + }); + + // Ensure fresh build + removeBundleIfExists(); + const { getCombinedCaBundlePath } = await import("./certBundle.js"); + const bundlePath = getCombinedCaBundlePath(); + assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); + const contents = fs.readFileSync(bundlePath, "utf8"); + assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Bundle should contain certificate blocks from certifi/Node roots"); + assert.ok(!contents.includes(invalidMarker), "Bundle should not include invalid Safe Chain content"); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js index d5d414c..6b326c8 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.js +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -12,6 +12,10 @@ export function getCaCertPath() { return path.join(certFolder, "ca-cert.pem"); } +/** + * @param {string} hostname + * @returns {{privateKey: string, certificate: string}} + */ export function generateCertForHost(hostname) { let existingCert = certCache.get(hostname); if (existingCert) { @@ -44,6 +48,16 @@ export function generateCertForHost(hostname) { digitalSignature: true, keyEncipherment: true, }, + { + /* + extKeyUsage serverAuth is required for TLS server authentication. + This is especially important for Python venv environments, which use their own + certificate validation logic and will reject certificates lacking the serverAuth EKU. + Adding serverAuth does not impact other usages + */ + name: "extKeyUsage", + serverAuth: true, + }, ]); cert.sign(ca.privateKey, forge.md.sha256.create()); diff --git a/packages/safe-chain/src/registryProxy/http-utils.js b/packages/safe-chain/src/registryProxy/http-utils.js new file mode 100644 index 0000000..e14a977 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/http-utils.js @@ -0,0 +1,17 @@ +/** + * @param {NodeJS.Dict | undefined} headers + * @param {string} headerName + */ +export function getHeaderValueAsString(headers, headerName) { + if (!headers) { + return undefined; + } + + let header = headers[headerName]; + + if (Array.isArray(header)) { + return header.join(", "); + } + + return header; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js b/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js new file mode 100644 index 0000000..79b5200 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js @@ -0,0 +1,25 @@ +import { + ECOSYSTEM_JS, + ECOSYSTEM_PY, + getEcoSystem, +} from "../../config/settings.js"; +import { npmInterceptorForUrl } from "./npm/npmInterceptor.js"; +import { pipInterceptorForUrl } from "./pipInterceptor.js"; + +/** + * @param {string} url + * @returns {import("./interceptorBuilder.js").Interceptor | undefined} + */ +export function createInterceptorForUrl(url) { + const ecosystem = getEcoSystem(); + + if (ecosystem === ECOSYSTEM_JS) { + return npmInterceptorForUrl(url); + } + + if (ecosystem === ECOSYSTEM_PY) { + return pipInterceptorForUrl(url); + } + + return undefined; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js new file mode 100644 index 0000000..e25e641 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -0,0 +1,140 @@ +import { EventEmitter } from "events"; + +/** + * @typedef {Object} Interceptor + * @property {(targetUrl: string) => Promise} handleRequest + * @property {(event: string, listener: (...args: any[]) => void) => Interceptor} on + * @property {(event: string, ...args: any[]) => boolean} emit + * + * + * @typedef {Object} RequestInterceptionContext + * @property {string} targetUrl + * @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware + * @property {(modificationFunc: (headers: NodeJS.Dict) => NodeJS.Dict) => void} modifyRequestHeaders + * @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict | undefined) => Buffer) => void} modifyBody + * @property {() => RequestInterceptionHandler} build + * + * + * @typedef {Object} RequestInterceptionHandler + * @property {{statusCode: number, message: string} | undefined} blockResponse + * @property {(headers: NodeJS.Dict | undefined) => NodeJS.Dict | undefined} modifyRequestHeaders + * @property {() => boolean} modifiesResponse + * @property {(body: Buffer, headers: NodeJS.Dict | undefined) => Buffer} modifyBody + */ + +/** + * @param {(requestHandlerBuilder: RequestInterceptionContext) => Promise} requestInterceptionFunc + * @returns {Interceptor} + */ +export function interceptRequests(requestInterceptionFunc) { + return buildInterceptor([requestInterceptionFunc]); +} + +/** + * @param {Array<(requestHandlerBuilder: RequestInterceptionContext) => Promise>} requestHandlers + * @returns {Interceptor} + */ +function buildInterceptor(requestHandlers) { + const eventEmitter = new EventEmitter(); + + return { + async handleRequest(targetUrl) { + const requestContext = createRequestContext(targetUrl, eventEmitter); + + for (const handler of requestHandlers) { + await handler(requestContext); + } + + return requestContext.build(); + }, + on(event, listener) { + eventEmitter.on(event, listener); + return this; + }, + emit(event, ...args) { + return eventEmitter.emit(event, ...args); + }, + }; +} + +/** + * @param {string} targetUrl + * @param {import('events').EventEmitter} eventEmitter + * @returns {RequestInterceptionContext} + */ +function createRequestContext(targetUrl, eventEmitter) { + /** @type {{statusCode: number, message: string} | undefined} */ + let blockResponse = undefined; + /** @type {Array<(headers: NodeJS.Dict) => NodeJS.Dict>} */ + let reqheaderModificationFuncs = []; + /** @type {Array<(body: Buffer, headers: NodeJS.Dict | undefined) => Buffer>} */ + let modifyBodyFuncs = []; + + /** + * @param {string | undefined} packageName + * @param {string | undefined} version + */ + function blockMalwareSetup(packageName, version) { + blockResponse = { + statusCode: 403, + message: "Forbidden - blocked by safe-chain", + }; + + // Emit the malwareBlocked event + eventEmitter.emit("malwareBlocked", { + packageName, + version, + targetUrl, + timestamp: Date.now(), + }); + } + + /** @returns {RequestInterceptionHandler} */ + function build() { + /** + * @param {NodeJS.Dict | undefined} headers + * @returns {NodeJS.Dict | undefined} + */ + function modifyRequestHeaders(headers) { + if (headers) { + for (const func of reqheaderModificationFuncs) { + func(headers); + } + } + + return headers; + } + + /** + * @param {Buffer} body + * @param {NodeJS.Dict | undefined} headers + * @returns {Buffer} + */ + function modifyBody(body, headers) { + let modifiedBody = body; + + for (var func of modifyBodyFuncs) { + modifiedBody = func(body, headers); + } + + return modifiedBody; + } + + // These functions are invoked in the proxy, allowing to apply the configured modifications + return { + blockResponse, + modifyRequestHeaders: modifyRequestHeaders, + modifiesResponse: () => modifyBodyFuncs.length > 0, + modifyBody, + }; + } + + // These functions are used to setup the modifications + return { + targetUrl, + blockMalware: blockMalwareSetup, + modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func), + modifyBody: (func) => modifyBodyFuncs.push(func), + build, + }; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js new file mode 100644 index 0000000..2ee4eb8 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -0,0 +1,177 @@ +import { getMinimumPackageAgeHours } from "../../../config/settings.js"; +import { ui } from "../../../environment/userInteraction.js"; +import { getHeaderValueAsString } from "../../http-utils.js"; + +const state = { + hasSuppressedVersions: false, +}; + +/** + * @param {NodeJS.Dict} headers + * @returns {NodeJS.Dict} + */ +export function modifyNpmInfoRequestHeaders(headers) { + const accept = getHeaderValueAsString(headers, "accept"); + if (accept?.includes("application/vnd.npm.install-v1+json")) { + // The npm registry sometimes serves a more compact format that lacks + // the time metadata we need to filter out too new packages. + // Force the registry to return the full metadata by changing the Accept header. + headers["accept"] = "application/json"; + } + return headers; +} + +/** + * @param {string} url + * @returns {boolean} + */ +export function isPackageInfoUrl(url) { + // Remove query string and fragment to get the actual path + const urlWithoutParams = url.split("?")[0].split("#")[0]; + + // Tarball downloads end with .tgz + if (urlWithoutParams.endsWith(".tgz")) return false; + + // Special endpoints start with /-/ and should not be modified + // Examples: /-/npm/v1/security/advisories/bulk, /-/v1/search, /-/package/foo/access + if (urlWithoutParams.includes("/-/")) return false; + + // Everything else is package metadata that can be modified + return true; +} +/** + * + * @param {Buffer} body + * @param {NodeJS.Dict | undefined} headers + * @returns Buffer + */ +export function modifyNpmInfoResponse(body, headers) { + try { + const contentType = getHeaderValueAsString(headers, "content-type"); + if (!contentType?.toLowerCase().includes("application/json")) { + return body; + } + + if (body.byteLength === 0) { + return body; + } + + // utf-8 is default encoding for JSON, so we don't check if charset is defined in content-type header + const bodyContent = body.toString("utf8"); + const bodyJson = JSON.parse(bodyContent); + + if (!bodyJson.time || !bodyJson["dist-tags"] || !bodyJson.versions) { + // Just return the current body if the format is not + return body; + } + + const cutOff = new Date( + new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 + ); + + const hasLatestTag = !!bodyJson["dist-tags"]["latest"]; + + const versions = Object.entries(bodyJson.time) + .map(([version, timestamp]) => ({ + version, + timestamp, + })) + .filter((x) => x.version !== "created" && x.version !== "modified"); + + for (const { version, timestamp } of versions) { + const timestampValue = new Date(timestamp); + if (timestampValue > cutOff) { + deleteVersionFromJson(bodyJson, version); + 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"]; + } + } + } + + if (hasLatestTag && !bodyJson["dist-tags"]["latest"]) { + // The latest tag was removed because it contained a package younger than the treshold. + // A new latest tag needs to be calculated + bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson.time); + } + + return Buffer.from(JSON.stringify(bodyJson)); + } catch (/** @type {any} */ err) { + ui.writeVerbose( + `Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}` + ); + return body; + } +} + +/** + * @param {any} json + * @param {string} version + */ +function deleteVersionFromJson(json, version) { + state.hasSuppressedVersions = true; + + ui.writeVerbose( + `Safe-chain: ${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).` + ); + + delete json.time[version]; + delete json.versions[version]; + + for (const [tag, distVersion] of Object.entries(json["dist-tags"])) { + if (version == distVersion) { + delete json["dist-tags"][tag]; + } + } +} + +/** + * @param {Record} tagList + * @returns {string | undefined} + */ +function calculateLatestTag(tagList) { + const entries = Object.entries(tagList).filter( + ([version, _]) => version !== "created" && version !== "modified" + ); + + const latestFullRelease = getMostRecentTag( + Object.fromEntries(entries.filter(([version, _]) => !version.includes("-"))) + ); + if (latestFullRelease) { + return latestFullRelease; + } + + const latestPrerelease = getMostRecentTag( + Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))) + ); + return latestPrerelease; +} + +/** + * @param {Record} tagList + * @returns {string | undefined} + */ +function getMostRecentTag(tagList) { + let current, currentDate; + + for (const [version, timestamp] of Object.entries(tagList)) { + if (!currentDate || currentDate < timestamp) { + current = version; + currentDate = timestamp; + } + } + + return current; +} + +/** + * @returns {boolean} + */ +export function getHasSuppressedVersions() { + return state.hasSuppressedVersions; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js new file mode 100644 index 0000000..eaf50db --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -0,0 +1,47 @@ +import { skipMinimumPackageAge } from "../../../config/settings.js"; +import { isMalwarePackage } from "../../../scanning/audit/index.js"; +import { interceptRequests } from "../interceptorBuilder.js"; +import { + isPackageInfoUrl, + modifyNpmInfoRequestHeaders, + modifyNpmInfoResponse, +} from "./modifyNpmInfo.js"; +import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js"; + +const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; + +/** + * @param {string} url + * @returns {import("../interceptorBuilder.js").Interceptor | undefined} + */ +export function npmInterceptorForUrl(url) { + const registry = knownJsRegistries.find((reg) => url.includes(reg)); + + if (registry) { + return buildNpmInterceptor(registry); + } + + return undefined; +} + +/** + * @param {string} registry + * @returns {import("../interceptorBuilder.js").Interceptor} + */ +function buildNpmInterceptor(registry) { + return interceptRequests(async (reqContext) => { + const { packageName, version } = parseNpmPackageUrl( + reqContext.targetUrl, + registry + ); + + if (await isMalwarePackage(packageName, version)) { + reqContext.blockMalware(packageName, version); + } + + if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) { + reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders); + reqContext.modifyBody(modifyNpmInfoResponse); + } + }); +} 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 new file mode 100644 index 0000000..999e64a --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -0,0 +1,384 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; + +describe("npmInterceptor minimum package age", async () => { + let minimumPackageAgeSettings = 48; + let skipMinimumPackageAgeSetting = false; + + mock.module("../../../config/settings.js", { + namedExports: { + getMinimumPackageAgeHours: () => minimumPackageAgeSettings, + skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, + }, + }); + + mock.module("../../../scanning/audit/index.js", { + namedExports: { + isMalwarePackage: async () => { + return false; + }, + }, + }); + mock.module("../../../environment/userInteraction.js", { + namedExports: { + ui: { + startProcess: () => {}, + writeError: () => {}, + writeInformation: () => {}, + writeWarning: () => {}, + writeVerbose: () => {}, + writeExitWithoutInstallingMaliciousPackages: () => {}, + emptyLine: () => {}, + }, + }, + }); + const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); + + for (const packageInfoUrl of [ + // Basic package metadata + "https://registry.npmjs.org/lodash", + "https://registry.npmjs.org/express", + // Scoped packages + "https://registry.npmjs.org/@vercel/functions", + "https://registry.npmjs.org/@babel/core", + "https://registry.npmjs.org/@types/node", + // With query parameters + "https://registry.npmjs.org/lodash?write=true", + "https://registry.npmjs.org/@babel/core?param=value&other=test", + // With fragments + "https://registry.npmjs.org/lodash#readme", + "https://registry.npmjs.org/@babel/core#installation", + // Version-specific metadata + "https://registry.npmjs.org/lodash/4.17.21", + "https://registry.npmjs.org/lodash/latest", + "https://registry.npmjs.org/@babel/core/7.21.4", + // URL-encoded scoped packages + "https://registry.npmjs.org/@types%2Fnode", + "https://registry.npmjs.org/@babel%2Fcore", + // With trailing slashes + "https://registry.npmjs.org/lodash/", + "https://registry.npmjs.org/@babel/core/", + ]) { + it(`modifyResponse should be true for package info requests: ${packageInfoUrl}`, async () => { + const interceptor = npmInterceptorForUrl(packageInfoUrl); + const requestInterceptor = await interceptor.handleRequest( + packageInfoUrl + ); + + assert.equal(requestInterceptor.modifiesResponse(), true); + }); + } + + for (const packageUrl of [ + // Regular package tarballs + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + // Scoped package tarballs + "https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz", + "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", + // Tarballs with query parameters (integrity checks) + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123", + "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz?integrity=sha512-def456&cache=false", + // Tarballs with fragments + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#sha512-abc123", + "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz#hash", + // Prerelease versions + "https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz", + "https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz", + ]) { + it(`modifyResponse should be false for package downloads: ${packageUrl}`, async () => { + const interceptor = npmInterceptorForUrl(packageUrl); + const requestInterceptor = await interceptor.handleRequest(packageUrl); + + assert.equal(requestInterceptor.modifiesResponse(), false); + }); + } + + for (const specialEndpoint of [ + // Security advisory endpoints + "https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", + "https://registry.npmjs.org/-/npm/v1/security/audits", + "https://registry.npmjs.org/-/npm/v1/security/audits/quick", + // Search endpoints + "https://registry.npmjs.org/-/v1/search?text=lodash&size=20", + "https://registry.npmjs.org/-/v1/search?text=react&from=0", + // Package access/collaboration endpoints + "https://registry.npmjs.org/-/package/lodash/access", + "https://registry.npmjs.org/-/package/@babel/core/collaborators", + "https://registry.npmjs.org/-/package/lodash/dist-tags", + "https://registry.npmjs.org/-/package/@babel/core/dist-tags/latest", + // User/organization endpoints + "https://registry.npmjs.org/-/user/org.couchdb.user:username", + "https://registry.npmjs.org/-/org/myorg/package", + // Anonymous metrics + "https://registry.npmjs.org/-/npm/anon-metrics/v1/", + // Ping/health check + "https://registry.npmjs.org/-/ping", + ]) { + it(`modifyResponse should be false for special endpoints: ${specialEndpoint}`, async () => { + const interceptor = npmInterceptorForUrl(specialEndpoint); + const requestInterceptor = await interceptor.handleRequest( + specialEndpoint + ); + + assert.equal(requestInterceptor.modifiesResponse(), false); + }); + } + + it("Should remove packages older than the treshold", async () => { + minimumPackageAgeSettings = 5; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + modified: getDate(-3), + ["1.0.0"]: getDate(-7), + // cutoff-date here + ["2.0.0"]: getDate(-4), + ["3.0.0"]: getDate(-3), + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + assert.equal(Object.keys(modifiedJson.time).length, 3); + assert.equal(Object.keys(modifiedJson.versions).length, 1); + assert.ok(Object.keys(modifiedJson.time).includes("1.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(!Object.keys(modifiedJson.time).includes("2.0.0")); + assert.ok(!Object.keys(modifiedJson.versions).includes("2.0.0")); + assert.ok(!Object.keys(modifiedJson.time).includes("3.0.0")); + assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0")); + }); + + it("Should set the package to the new latest non-preview release", async () => { + minimumPackageAgeSettings = 5; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + modified: getDate(-3), + ["1.0.0"]: getDate(-7), + ["0.0.1"]: getDate(-8), // package order: this package is older than 1.0.0, it should not be considered latest + ["2.0.0-alpha"]: getDate(-6), //package is a pre-release, it should not be latest + // cutoff-date here + ["2.0.0"]: getDate(-4), + ["3.0.0"]: getDate(-3), + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + assert.equal(modifiedJson["dist-tags"]["latest"], "1.0.0"); + }); + + it("Should remove dist-tags if version was removed", async () => { + minimumPackageAgeSettings = 5; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + alpha: "2.0.0-alpha", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + modified: getDate(-4), + ["1.0.0"]: getDate(-7), + // cutoff-date here + ["2.0.0-alpha"]: getDate(-4), + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + console.log(modifiedJson); + + assert.equal(modifiedJson["dist-tags"]["alpha"], undefined); + }); + + it("Should not filter packages when skipMinimumPackageAge is enabled", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = true; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const originalBody = JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + modified: getDate(-3), + ["1.0.0"]: getDate(-7), + // cutoff-date here + ["2.0.0"]: getDate(-4), + ["3.0.0"]: getDate(-3), + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + originalBody + ); + + const modifiedJson = JSON.parse(modifiedBody); + + // All versions should remain unchanged + assert.equal(Object.keys(modifiedJson.versions).length, 3); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("3.0.0")); + + // Latest should remain unchanged + assert.equal(modifiedJson["dist-tags"]["latest"], "3.0.0"); + }); + + it("Should use custom minimum package age of 48 hours", async () => { + minimumPackageAgeSettings = 48; + skipMinimumPackageAgeSetting = false; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "4.0.0", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + ["4.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + modified: getDate(-24), + ["1.0.0"]: getDate(-72), // 3 days old - should remain + ["2.0.0"]: getDate(-50), // ~2 days old - should remain + // 48-hour cutoff here + ["3.0.0"]: getDate(-40), // ~1.7 days old - should be removed + ["4.0.0"]: getDate(-24), // 1 day old - should be removed + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + // Versions older than 48 hours should remain + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0")); + + // Versions newer than 48 hours should be removed + assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0")); + assert.ok(!Object.keys(modifiedJson.versions).includes("4.0.0")); + + // Latest should be recalculated to 2.0.0 + assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0"); + + assert.equal(Object.keys(modifiedJson.versions).length, 2); + }); + + it("Should use very small minimum package age of 1 hour", async () => { + minimumPackageAgeSettings = 1; + skipMinimumPackageAgeSetting = false; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-48), + modified: getDate(0), + ["1.0.0"]: getDate(-3), // 3 hours old - should remain + ["2.0.0"]: getDate(-2), // 2 hours old - should remain + // 1-hour cutoff here + ["3.0.0"]: getDate(0), // just published - should be removed + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + assert.equal(Object.keys(modifiedJson.versions).length, 2); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0")); + assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0")); + assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0"); + }); + + function getDate(plusHours) { + const date = new Date(); + date.setHours(date.getHours() + plusHours); + + return date; + } + + /** + * @param {import("../interceptorBuilder.js").Interceptor} interceptor + * @param {string} body + * @returns {Promise} + */ + async function runModifyNpmInfoRequest(url, body) { + const interceptor = npmInterceptorForUrl(url); + const requestHandler = await interceptor.handleRequest(url); + + if (requestHandler.modifiesResponse()) { + const modifiedBuffer = requestHandler.modifyBody(Buffer.from(body), { + ["content-type"]: "application/json", + }); + return modifiedBuffer.toString("utf8"); + } + + return body; + } +}); diff --git a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js similarity index 66% rename from packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js rename to packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index 0b8f700..a90432e 100644 --- a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -1,9 +1,22 @@ -import { describe, it } from "node:test"; +import { describe, it, mock } from "node:test"; import assert from "node:assert"; -import { parsePackageFromUrl } from "./parsePackageFromUrl.js"; -describe("parsePackageFromUrl", () => { - const testCases = [ +describe("npmInterceptor", async () => { + let lastPackage; + let malwareResponse = false; + + mock.module("../../../scanning/audit/index.js", { + namedExports: { + isMalwarePackage: async (packageName, version) => { + lastPackage = { packageName, version }; + return malwareResponse; + }, + }, + }); + + const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); + + const parserCases = [ // Regular packages { url: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -78,11 +91,6 @@ describe("parsePackageFromUrl", () => { url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz", expected: { packageName: "@babel/core", version: "7.21.4" }, }, - // Invalid URLs should return undefined values - { - url: "https://example.com/package.tgz", - expected: { packageName: undefined, version: undefined }, - }, // URL to get package info, not tarball { url: "https://registry.npmjs.org/lodash", @@ -105,10 +113,51 @@ describe("parsePackageFromUrl", () => { }, ]; - testCases.forEach(({ url, expected }, index) => { - it(`should parse URL ${index + 1}: ${url}`, () => { - const result = parsePackageFromUrl(url); - assert.deepEqual(result, expected); + parserCases.forEach(({ url, expected }, index) => { + it(`should parse URL ${index + 1}: ${url}`, async () => { + const interceptor = npmInterceptorForUrl(url); + assert.ok( + interceptor, + "Interceptor should be created for known npm registry" + ); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, expected); }); }); + + it("should not create interceptor for unknown registry", () => { + const url = "https://example.com/some-package/-/some-package-1.0.0.tgz"; + + const interceptor = npmInterceptorForUrl(url); + + assert.equal( + interceptor, + undefined, + "Interceptor should be undefined for unknown registry" + ); + }); + + it("should block malicious package", async () => { + const url = + "https://registry.npmjs.org/malicious-package/-/malicious-package-1.0.0.tgz"; + malwareResponse = true; + + const interceptor = npmInterceptorForUrl(url); + + const result = await interceptor.handleRequest(url); + + assert.ok(result.blockResponse, "Should contain a blockResponse"); + assert.equal( + result.blockResponse.statusCode, + 403, + "Block response should have status code 403" + ); + assert.equal( + result.blockResponse.message, + "Forbidden - blocked by safe-chain", + "Block response should have correct status message" + ); + }); }); diff --git a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js similarity index 79% rename from packages/safe-chain/src/registryProxy/parsePackageFromUrl.js rename to packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js index 7368b35..fa256d4 100644 --- a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js @@ -1,15 +1,10 @@ -export const knownRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; - -export function parsePackageFromUrl(url) { - let packageName, version, registry; - - for (const knownRegistry of knownRegistries) { - if (url.includes(knownRegistry)) { - registry = knownRegistry; - break; - } - } - +/** + * @param {string} url + * @param {string} registry + * @returns {{packageName: string | undefined, version: string | undefined}} + */ +export function parseNpmPackageUrl(url, registry) { + let packageName, version; if (!registry || !url.endsWith(".tgz")) { return { packageName, 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..212c830 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js @@ -0,0 +1,115 @@ +import { isMalwarePackage } from "../../scanning/audit/index.js"; +import { interceptRequests } from "./interceptorBuilder.js"; + +const knownPipRegistries = [ + "files.pythonhosted.org", + "pypi.org", + "pypi.python.org", + "pythonhosted.org", +]; + +/** + * @param {string} url + * @returns {import("./interceptorBuilder.js").Interceptor | undefined} + */ +export function pipInterceptorForUrl(url) { + const registry = knownPipRegistries.find((reg) => url.includes(reg)); + + if (registry) { + return buildPipInterceptor(registry); + } + + return undefined; +} + +/** + * @param {string} registry + * @returns {import("./interceptorBuilder.js").Interceptor | undefined} + */ +function buildPipInterceptor(registry) { + return interceptRequests(async (reqContext) => { + const { packageName, version } = parsePipPackageFromUrl( + reqContext.targetUrl, + registry + ); + if (await isMalwarePackage(packageName, version)) { + 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) + if (filename.endsWith(".whl")) { + const base = filename.slice(0, -4); // remove ".whl" + 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; // preserve underscores + 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) + const sdistExtMatch = filename.match(/\.(tar\.gz|zip|tar\.bz2|tar\.xz)$/i); + if (sdistExtMatch) { + const base = filename.slice(0, -sdistExtMatch[0].length); + 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/pipInterceptor.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js new file mode 100644 index 0000000..8b60b9b --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js @@ -0,0 +1,135 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; + +describe("pipInterceptor", async () => { + let lastPackage; + let malwareResponse = false; + + mock.module("../../scanning/audit/index.js", { + namedExports: { + isMalwarePackage: async (packageName, version) => { + lastPackage = { packageName, version }; + return malwareResponse; + }, + }, + }); + + 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" }, + }, + { + url: "https://pypi.org/packages/source/f/foobar/foobar-1.2.3.tar.gz", + expected: { packageName: "foobar", version: "1.2.3" }, + }, + { + url: "https://pypi.org/packages/source/f/foo-bar/foo-bar-0.9.0.tar.gz", + expected: { packageName: "foo-bar", version: "0.9.0" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-py3-none-any.whl", + expected: { packageName: "foo_bar", version: "2.0.0" }, + }, + { + url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl", + expected: { packageName: "foo_bar", version: "2.0.0" }, + }, + { + url: "https://pypi.org/packages/source/f/foo.bar/foo.bar-1.0.0.tar.gz", + expected: { packageName: "foo.bar", version: "1.0.0" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0b1.tar.gz", + expected: { packageName: "foo_bar", version: "2.0.0b1" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0rc1.tar.gz", + expected: { packageName: "foo_bar", version: "2.0.0rc1" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.post1.tar.gz", + expected: { packageName: "foo_bar", version: "2.0.0.post1" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.dev1.tar.gz", + expected: { packageName: "foo_bar", version: "2.0.0.dev1" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz", + expected: { packageName: "foo_bar", version: "2.0.0a1" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl", + expected: { packageName: "foo_bar", version: "2.0.0" }, + }, + // Invalid pip URLs + { + url: "https://pypi.org/simple/", + expected: { packageName: undefined, version: undefined }, + }, + { + url: "https://pypi.org/project/foobar/", + expected: { packageName: undefined, version: undefined }, + }, + { + url: "https://files.pythonhosted.org/packages/xx/yy/foobar-latest.tar.gz", + expected: { packageName: undefined, version: undefined }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-latest.tar.gz", + expected: { packageName: undefined, version: undefined }, + }, + ]; + + parserCases.forEach(({ url, expected }, index) => { + it(`should parse URL ${index + 1}: ${url}`, async () => { + const interceptor = pipInterceptorForUrl(url); + assert.ok( + interceptor, + "Interceptor should be created for known npm registry" + ); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, expected); + }); + }); + + it("should not create interceptor for unknown registry", () => { + const url = "https://example.com/packages/xx/yy/foobar-1.2.3.tar.gz"; + + const interceptor = pipInterceptorForUrl(url); + + assert.equal( + interceptor, + undefined, + "Interceptor should be undefined for unknown registry" + ); + }); + + it("should block malicious package", async () => { + 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, "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" + ); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/isImdsEndpoint.js b/packages/safe-chain/src/registryProxy/isImdsEndpoint.js new file mode 100644 index 0000000..deccf10 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/isImdsEndpoint.js @@ -0,0 +1,13 @@ +// Instance Metadata Service (IMDS) endpoints used by cloud providers. +// Cloud SDK tools probe these to detect environment and retrieve credentials. +// When outside cloud environments, connections timeout - we reduce timeout (3s vs 30s) +// and suppress error logging since this is expected behavior. +const imdsEndpoints = [ + "metadata.google.internal", + "metadata.goog", + "169.254.169.254", // AWS, Azure, Oracle Cloud, GCP +]; + +export function isImdsEndpoint(/** @type {string} */ host) { + return imdsEndpoints.includes(host); +} diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 4be9987..cf2af5b 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -1,11 +1,41 @@ import https from "https"; import { generateCertForHost } from "./certUtils.js"; import { HttpsProxyAgent } from "https-proxy-agent"; +import { ui } from "../environment/userInteraction.js"; +import { gunzipSync, gzipSync } from "zlib"; -export function mitmConnect(req, clientSocket, isAllowed) { +/** + * @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor + */ + +/** + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} clientSocket + * @param {Interceptor} interceptor + */ +export function mitmConnect(req, clientSocket, interceptor) { + ui.writeVerbose(`Safe-chain: Set up MITM tunnel for ${req.url}`); const { hostname } = new URL(`http://${req.url}`); - const server = createHttpsServer(hostname, isAllowed); + clientSocket.on("error", (err) => { + ui.writeVerbose( + `Safe-chain: Client socket error for ${req.url}: ${err.message}` + ); + // NO-OP + // This can happen if the client TCP socket sends RST instead of FIN. + // Not subscribing to 'close' event will cause node to throw and crash. + }); + + const server = createHttpsServer(hostname, interceptor); + + server.on("error", (err) => { + ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`); + if (!clientSocket.headersSent) { + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + } else if (clientSocket.writable) { + clientSocket.end(); + } + }); // Establish the connection clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); @@ -14,32 +44,60 @@ export function mitmConnect(req, clientSocket, isAllowed) { server.emit("connection", clientSocket); } -function createHttpsServer(hostname, isAllowed) { +/** + * @param {string} hostname + * @param {Interceptor} interceptor + * @returns {import("https").Server} + */ +function createHttpsServer(hostname, interceptor) { const cert = generateCertForHost(hostname); + /** + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} res + * + * @returns {Promise} + */ async function handleRequest(req, res) { + if (!req.url) { + ui.writeError("Safe-chain: Request missing URL"); + res.writeHead(400, "Bad Request"); + res.end("Bad Request: Missing URL"); + return; + } + const pathAndQuery = getRequestPathAndQuery(req.url); const targetUrl = `https://${hostname}${pathAndQuery}`; - if (!(await isAllowed(targetUrl))) { - res.writeHead(403, "Forbidden - blocked by safe-chain"); - res.end("Blocked by safe-chain"); + const requestInterceptor = await interceptor.handleRequest(targetUrl); + const blockResponse = requestInterceptor.blockResponse; + + if (blockResponse) { + ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`); + res.writeHead(blockResponse.statusCode, blockResponse.message); + res.end(blockResponse.message); return; } // Collect request body - forwardRequest(req, hostname, res); + forwardRequest(req, hostname, res, requestInterceptor); } - return https.createServer( + const server = https.createServer( { key: cert.privateKey, cert: cert.certificate, }, handleRequest ); + + return server; } +/** + * @param {string} url + * @returns {string} + */ function getRequestPathAndQuery(url) { if (url.startsWith("http://") || url.startsWith("https://")) { const parsedUrl = new URL(url); @@ -48,42 +106,125 @@ function getRequestPathAndQuery(url) { return url; } -function forwardRequest(req, hostname, res) { - const proxyReq = createProxyRequest(hostname, req, res); +/** + * @param {import("http").IncomingMessage} req + * @param {string} hostname + * @param {import("http").ServerResponse} res + * @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler + */ +function forwardRequest(req, hostname, res, requestHandler) { + const proxyReq = createProxyRequest(hostname, req, res, requestHandler); - proxyReq.on("error", () => { + proxyReq.on("error", (err) => { + ui.writeVerbose( + `Safe-chain: Error occurred while proxying request to ${req.url} for ${hostname}: ${err.message}` + ); res.writeHead(502); res.end("Bad Gateway"); }); + req.on("error", (err) => { + ui.writeError( + `Safe-chain: Error reading client request to ${req.url} for ${hostname}: ${err.message}` + ); + proxyReq.destroy(); + }); + req.on("data", (chunk) => { proxyReq.write(chunk); }); req.on("end", () => { + ui.writeVerbose( + `Safe-chain: Finished proxying request to ${req.url} for ${hostname}` + ); proxyReq.end(); }); } -function createProxyRequest(hostname, req, res) { +/** + * @param {string} hostname + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} res + * @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler + * + * @returns {import("http").ClientRequest} + */ +function createProxyRequest(hostname, req, res, requestHandler) { + /** @type {NodeJS.Dict | undefined} */ + let headers = { ...req.headers }; + // Remove the host header from the incoming request before forwarding. + // Node's http module sets the correct host header for the target hostname automatically. + if (headers.host) { + delete headers.host; + } + headers = requestHandler.modifyRequestHeaders(headers); + + /** @type {import("http").RequestOptions} */ const options = { hostname: hostname, port: 443, path: req.url, method: req.method, - headers: { ...req.headers }, + headers: { ...headers }, }; - delete options.headers.host; - const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; if (httpsProxy) { options.agent = new HttpsProxyAgent(httpsProxy); } const proxyReq = https.request(options, (proxyRes) => { - res.writeHead(proxyRes.statusCode, proxyRes.headers); - proxyRes.pipe(res); + proxyRes.on("error", (err) => { + ui.writeError( + `Safe-chain: Error reading upstream response to ${req.url} for ${hostname}: ${err.message}` + ); + if (!res.headersSent) { + res.writeHead(502); + res.end("Bad Gateway"); + } + }); + + if (!proxyRes.statusCode) { + ui.writeError( + `Safe-chain: Proxy response missing status code to ${req.url} for ${hostname}` + ); + res.writeHead(500); + res.end("Internal Server Error"); + return; + } + + const { statusCode, headers } = proxyRes; + + if (requestHandler.modifiesResponse()) { + /** @type {Array} */ + let chunks = []; + + proxyRes.on("data", (chunk) => chunks.push(chunk)); + + proxyRes.on("end", () => { + /** @type {Buffer} */ + let buffer = Buffer.concat(chunks); + + if (proxyRes.headers["content-encoding"] === "gzip") { + buffer = gunzipSync(buffer); + } + + buffer = requestHandler.modifyBody(buffer, headers); + + if (proxyRes.headers["content-encoding"] === "gzip") { + buffer = gzipSync(buffer); + } + + res.writeHead(statusCode, headers); + res.end(buffer); + }); + } else { + // If the response is not being modified, we can + // just pipe without the need for buffering the output + res.writeHead(statusCode, headers); + proxyRes.pipe(res); + } }); return proxyReq; diff --git a/packages/safe-chain/src/registryProxy/plainHttpProxy.js b/packages/safe-chain/src/registryProxy/plainHttpProxy.js new file mode 100644 index 0000000..75b9d77 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/plainHttpProxy.js @@ -0,0 +1,95 @@ +import * as http from "http"; +import * as https from "https"; +import { ui } from "../environment/userInteraction.js"; + +/** + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} res + * + * @returns {void} + */ +export function handleHttpProxyRequest(req, res) { + if (!req.url) { + ui.writeError("Safe-chain: Request missing URL"); + res.writeHead(400, "Bad Request"); + res.end("Bad Request: Missing URL"); + return; + } + + const url = new URL(req.url); + + // The protocol for the plainHttpProxy should usually only be http: + // but when the client for some reason sends an https: request directly + // instead of using the CONNECT method, we should handle it gracefully. + let protocol; + if (url.protocol === "http:") { + protocol = http; + } else if (url.protocol === "https:") { + protocol = https; + } else { + res.writeHead(502); + res.end(`Bad Gateway: Unsupported protocol ${url.protocol}`); + return; + } + + const proxyRequest = protocol + .request( + req.url, + { method: req.method, headers: req.headers }, + (proxyRes) => { + if (!proxyRes.statusCode) { + ui.writeError("Safe-chain: Proxy response missing status code"); + res.writeHead(500); + res.end("Internal Server Error"); + return; + } + + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + + proxyRes.on("error", () => { + // Proxy response stream error + // Clean up client response stream + if (res.writable) { + res.end(); + } + }); + + proxyRes.on("close", () => { + // Clean up if the proxy response stream closes + if (res.writable) { + res.end(); + } + }); + } + ) + .on("error", (err) => { + if (!res.headersSent) { + res.writeHead(502); + res.end(`Bad Gateway: ${err.message}`); + } else { + // Headers already sent, just destroy the response + res.destroy(); + } + }); + + req.on("error", () => { + // Client request stream error + // Abort the proxy request + proxyRequest.destroy(); + }); + + res.on("error", () => { + // Client response stream error (client disconnected) + // Clean up proxy streams + proxyRequest.destroy(); + }); + + res.on("close", () => { + // Client disconnected + // Abort the proxy request to avoid unnecessary work + proxyRequest.destroy(); + }); + + req.pipe(proxyRequest); +} diff --git a/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js new file mode 100644 index 0000000..b382d3f --- /dev/null +++ b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js @@ -0,0 +1,351 @@ +import { before, after, describe, it, mock } from "node:test"; +import assert from "node:assert"; +import net from "net"; +import tls from "tls"; + +// Mock isImdsEndpoint BEFORE any other imports that might use it +// This allows us to use TEST-NET-1 (192.0.2.1) as a test IMDS endpoint +mock.module("./isImdsEndpoint.js", { + namedExports: { + isImdsEndpoint: (host) => { + // 192.0.2.1 is TEST-NET-1, reserved for testing (RFC 5737) + if (host === "192.0.2.1") return true; + // Real IMDS endpoints + return [ + "metadata.google.internal", + "metadata.goog", + "169.254.169.254", + ].includes(host); + }, + }, +}); + +// Use dynamic import AFTER mocking to ensure mock is applied +const { createSafeChainProxy, mergeSafeChainProxyEnvironmentVariables } = + await import("./registryProxy.js"); + +describe("registryProxy.connectTunnel", () => { + let proxy, proxyHost, proxyPort; + + before(async () => { + proxy = createSafeChainProxy(); + await proxy.startServer(); + const envVars = mergeSafeChainProxyEnvironmentVariables([]); + const proxyUrl = new URL(envVars.HTTPS_PROXY); + proxyHost = proxyUrl.hostname; + proxyPort = parseInt(proxyUrl.port, 10); + }); + + after(async () => { + await proxy.stopServer(); + }); + + it("should establish a tunnel for HTTP connect", async () => { + const socket = await connectToProxy(proxyHost, proxyPort); + const tunnelResponse = await establishHttpsTunnel( + socket, + "postman-echo.com", + 443 + ); + + assert.ok(tunnelResponse.includes("HTTP/1.1 200 Connection Established")); + socket.destroy(); + }); + + it("should send HTTPS request through the established tunnel", async () => { + const socket = await connectToProxy(proxyHost, proxyPort); + await establishHttpsTunnel(socket, "postman-echo.com", 443); + const httpsResponse = await sendHttpsRequestThroughTunnel( + socket, + "GET", + new URL("https://postman-echo.com/status/200") + ); + + assert.ok(httpsResponse.includes("HTTP/1.1 200 OK")); + + socket.destroy(); + }); + + it("should use destination's real certificate (not safe-chain's self-signed CA)", async () => { + const socket = await connectToProxy(proxyHost, proxyPort); + await establishHttpsTunnel(socket, "postman-echo.com", 443); + + // Verifies that tunnel requests pass through the destination's real certificate + // without interception by the safe-chain MITM proxy. + const certInfo = await getTlsCertificateInfo( + socket, + new URL("https://postman-echo.com") + ); + + // Verify the certificate is NOT issued by our safe-chain CA + // Our self-signed CA would have issuer: "Safe-Chain Proxy CA" + assert.ok( + certInfo.issuer !== undefined, + "Certificate should have an issuer" + ); + assert.ok( + !certInfo.issuer.includes("Safe-Chain"), + `Tunnel should use destination's real certificate, not safe-chain CA. Issuer: ${certInfo.issuer}` + ); + + // Verify it's a real certificate with proper hostname + assert.strictEqual( + certInfo.subject.includes("postman-echo.com"), + true, + `Certificate subject should include postman-echo.com, got: ${certInfo.subject}` + ); + + socket.destroy(); + }); + + describe("Error Handling", () => { + it("should return 502 Bad Gateway for invalid hostname", async () => { + const socket = await connectToProxy(proxyHost, proxyPort); + const connectRequest = `CONNECT invalid.hostname.that.does.not.exist:443 HTTP/1.1\r\nHost: invalid.hostname.that.does.not.exist:443\r\n\r\n`; + socket.write(connectRequest); + + let responseData = ""; + await new Promise((resolve) => { + socket.once("data", (data) => { + responseData += data.toString(); + resolve(); + }); + }); + + assert.ok(responseData.includes("HTTP/1.1 502 Bad Gateway")); + socket.destroy(); + }); + + it("should handle client disconnect during tunnel establishment", async () => { + const socket = await connectToProxy(proxyHost, proxyPort); + const connectRequest = `CONNECT postman-echo.com:443 HTTP/1.1\r\nHost: postman-echo.com:443\r\n\r\n`; + socket.write(connectRequest); + + // Immediately destroy the socket before tunnel is fully established + socket.destroy(); + + // If no crash occurs, the test passes + assert.ok(true); + }); + + it("should handle socket errors without crashing", async () => { + const socket = await connectToProxy(proxyHost, proxyPort); + + socket.on("error", () => { + // Error handler is set to prevent crashes + }); + + const connectRequest = `CONNECT postman-echo.com:443 HTTP/1.1\r\nHost: postman-echo.com:443\r\n\r\n`; + socket.write(connectRequest); + + // Force an error by destroying the socket + socket.destroy(); + + // Wait a bit to ensure error handling completes + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Test passes if no unhandled error crashes the process + assert.ok(true); + }); + }); + + describe("Connection Timeout", () => { + it("should timeout quickly when connecting to IMDS endpoint (3s)", async () => { + // We need to make sure we're not running behind an existing safe-chain installation to allow this test to work + const https_proxy = process.env.HTTPS_PROXY; + delete process.env.HTTPS_PROXY; + const socket = await connectToProxy(proxyHost, proxyPort); + const startTime = Date.now(); + + // 192.0.2.1 is TEST-NET-1 (RFC 5737), guaranteed to never route + const connectRequest = `CONNECT 192.0.2.1:443 HTTP/1.1\r\nHost: 192.0.2.1:443\r\n\r\n`; + socket.write(connectRequest); + + let responseData = ""; + await new Promise((resolve) => { + socket.once("data", (data) => { + responseData += data.toString(); + resolve(); + }); + }); + + const duration = Date.now() - startTime; + + // Should return 502 Bad Gateway + assert.ok( + responseData.includes("HTTP/1.1 502 Bad Gateway"), + "Should return 502 for timeout" + ); + + // Should timeout around 3 seconds for IMDS endpoints (allow some margin) + assert.ok( + duration >= 2800 && duration < 5000, + `IMDS timeout should be ~3s, got ${duration}ms` + ); + + socket.destroy(); + if (https_proxy) { + process.env.HTTPS_PROXY = https_proxy; + } + }); + + it("should cache timed-out endpoints and fail immediately on retry", async () => { + // We need to make sure we're not running behind an existing safe-chain installation to allow this test to work + const https_proxy = process.env.HTTPS_PROXY; + delete process.env.HTTPS_PROXY; + // First connection - will timeout + const socket1 = await connectToProxy(proxyHost, proxyPort); + const connectRequest = `CONNECT 192.0.2.1:80 HTTP/1.1\r\nHost: 192.0.2.1:80\r\n\r\n`; + socket1.write(connectRequest); + + await new Promise((resolve) => { + socket1.once("data", () => resolve()); + }); + socket1.destroy(); + + // Second connection - should fail immediately (cached) + const socket2 = await connectToProxy(proxyHost, proxyPort); + const startTime = Date.now(); + socket2.write(connectRequest); + + let responseData = ""; + await new Promise((resolve) => { + socket2.once("data", (data) => { + responseData += data.toString(); + resolve(); + }); + }); + + const duration = Date.now() - startTime; + + // Should return 502 immediately (cached timeout) + assert.ok( + responseData.includes("HTTP/1.1 502 Bad Gateway"), + "Should return 502 for cached timeout" + ); + + // Should be nearly instant (< 100ms) since it's cached + assert.ok( + duration < 100, + `Cached timeout should be instant, got ${duration}ms` + ); + + socket2.destroy(); + if (https_proxy) { + process.env.HTTPS_PROXY = https_proxy; + } + }); + }); +}); + +function connectToProxy(host, port) { + return new Promise((resolve, reject) => { + const socket = net.connect({ host, port }, () => { + resolve(socket); + }); + + socket.on("error", (err) => { + reject(err); + }); + }); +} + +function establishHttpsTunnel(socket, targetHost, targetPort) { + return new Promise((resolve, reject) => { + const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n\r\n`; + socket.write(connectRequest); + + let responseData = ""; + const onData = (data) => { + responseData += data.toString(); + if (responseData.includes("\r\n\r\n")) { + socket.removeListener("data", onData); + socket.removeListener("error", onError); + resolve(responseData); + } + }; + + const onError = (err) => { + socket.removeListener("data", onData); + socket.removeListener("error", onError); + reject(err); + }; + + socket.on("data", onData); + socket.on("error", onError); + }); +} + +function sendHttpsRequestThroughTunnel( + socket, + verb, + url, + rejectUnauthorized = false +) { + return new Promise((resolve, reject) => { + const tlsSocket = tls.connect( + { + socket: socket, + servername: url.hostname, + // Tests should focus on tunnel behavior, not system CA state; + // disable CA verification to avoid flakiness on machines without full roots. + rejectUnauthorized: rejectUnauthorized, + }, + () => { + tlsSocket.write( + `${verb} ${url.pathname} HTTP/1.1\r\nHost: ${url.hostname}\r\nConnection: close\r\n\r\n` + ); + } + ); + + let tlsData = ""; + + tlsSocket.on("data", (data) => { + tlsData += data.toString(); + }); + + tlsSocket.on("end", () => { + resolve(tlsData); + }); + + tlsSocket.on("error", (err) => { + reject(err); + }); + }); +} + +function getTlsCertificateInfo(socket, url) { + return new Promise((resolve, reject) => { + const tlsSocket = tls.connect( + { + socket: socket, + servername: url.hostname, + // Don't reject unauthorized to avoid system CA issues in CI + // We just want to inspect the certificate + rejectUnauthorized: false, + }, + () => { + const cert = tlsSocket.getPeerCertificate(); + + // Extract issuer and subject information + const issuer = cert.issuer + ? Object.entries(cert.issuer) + .map(([k, v]) => `${k}=${v}`) + .join(", ") + : "unknown"; + const subject = cert.subject + ? Object.entries(cert.subject) + .map(([k, v]) => `${k}=${v}`) + .join(", ") + : "unknown"; + + tlsSocket.end(); + resolve({ issuer, subject }); + } + ); + + tlsSocket.on("error", (err) => { + reject(err); + }); + }); +} diff --git a/packages/safe-chain/src/registryProxy/registryProxy.http-proxy.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.http-proxy.spec.js new file mode 100644 index 0000000..970543c --- /dev/null +++ b/packages/safe-chain/src/registryProxy/registryProxy.http-proxy.spec.js @@ -0,0 +1,225 @@ +import { before, after, describe, it } from "node:test"; +import assert from "node:assert"; +import http from "http"; +import { + createSafeChainProxy, + mergeSafeChainProxyEnvironmentVariables, +} from "./registryProxy.js"; + +describe("registryProxy.httpProxy", () => { + let proxy, proxyHost, proxyPort; + let testHttpServer, testHttpServerPort; + + before(async () => { + // Start safe-chain proxy + proxy = createSafeChainProxy(); + await proxy.startServer(); + const envVars = mergeSafeChainProxyEnvironmentVariables([]); + const proxyUrl = new URL(envVars.HTTPS_PROXY); + proxyHost = proxyUrl.hostname; + proxyPort = parseInt(proxyUrl.port, 10); + + // Start a test HTTP server to forward requests to + testHttpServer = http.createServer((req, res) => { + if (req.url === "/test") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("HTTP test response"); + } else if (req.url === "/echo-headers") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(req.headers)); + } else if (req.url === "/echo-method") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(req.method); + } else if (req.url === "/post-echo") { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(body); + }); + } else if (req.url === "/404") { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + } else { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("OK"); + } + }); + + testHttpServerPort = await new Promise((resolve) => { + testHttpServer.listen(0, () => { + resolve(testHttpServer.address().port); + }); + }); + }); + + after(async () => { + await proxy.stopServer(); + await new Promise((resolve) => { + testHttpServer.close(() => resolve()); + setTimeout(resolve, 1000); + }); + }); + + it("should forward HTTP GET requests", async () => { + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + `http://localhost:${testHttpServerPort}/test`, + "GET" + ); + + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body, "HTTP test response"); + }); + + it("should forward HTTP POST requests with body", async () => { + const postData = "test post data"; + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + `http://localhost:${testHttpServerPort}/post-echo`, + "POST", + postData + ); + + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body, postData); + }); + + it("should preserve request headers", async () => { + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + `http://localhost:${testHttpServerPort}/echo-headers`, + "GET", + null, + { + "X-Custom-Header": "test-value", + "User-Agent": "test-agent/1.0", + } + ); + + assert.strictEqual(response.statusCode, 200); + const headers = JSON.parse(response.body); + assert.strictEqual(headers["x-custom-header"], "test-value"); + assert.strictEqual(headers["user-agent"], "test-agent/1.0"); + }); + + it("should preserve HTTP methods", async () => { + const methods = ["GET", "POST", "PUT", "DELETE"]; + + for (const method of methods) { + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + `http://localhost:${testHttpServerPort}/echo-method`, + method + ); + + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body, method); + } + }); + + it("should forward 404 responses correctly", async () => { + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + `http://localhost:${testHttpServerPort}/404`, + "GET" + ); + + assert.strictEqual(response.statusCode, 404); + assert.strictEqual(response.body, "Not Found"); + }); + + it("should handle invalid host with 502 Bad Gateway", async () => { + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + "http://invalid-host-that-does-not-exist.test:9999/test", + "GET" + ); + + assert.strictEqual(response.statusCode, 502); + assert.ok(response.body.includes("Bad Gateway")); + }); + + it("should handle HTTPS URLs sent to HTTP proxy", async () => { + // Some clients incorrectly send https:// URLs to the HTTP proxy handler + // instead of using CONNECT. The proxy should handle this gracefully. + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + "https://registry.npmjs.org/lodash", + "GET" + ); + + // Should successfully forward the HTTPS request + assert.strictEqual(response.statusCode, 200); + assert.ok(response.body.includes("lodash")); + }); + + it("should handle unsupported protocols with 502", async () => { + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + "ftp://example.com/file.txt", + "GET" + ); + + assert.strictEqual(response.statusCode, 502); + assert.ok(response.body.includes("Unsupported protocol")); + }); +}); + +function makeHttpProxyRequest( + proxyHost, + proxyPort, + targetUrl, + method = "GET", + body = null, + extraHeaders = {} +) { + return new Promise((resolve, reject) => { + const options = { + hostname: proxyHost, + port: proxyPort, + path: targetUrl, + method: method, + headers: { + Host: new URL(targetUrl).host, + ...extraHeaders, + }, + }; + + const req = http.request(options, (res) => { + let responseBody = ""; + + res.on("data", (chunk) => { + responseBody += chunk.toString(); + }); + + res.on("end", () => { + resolve({ + statusCode: res.statusCode, + headers: res.headers, + body: responseBody, + }); + }); + }); + + req.on("error", (err) => { + reject(err); + }); + + if (body) { + req.write(body); + } + + req.end(); + }); +} diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 3558673..8169086 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -1,13 +1,17 @@ import * as http from "http"; import { tunnelRequest } from "./tunnelRequestHandler.js"; import { mitmConnect } from "./mitmRequestHandler.js"; +import { handleHttpProxyRequest } from "./plainHttpProxy.js"; import { getCaCertPath } from "./certUtils.js"; -import { auditChanges } from "../scanning/audit/index.js"; -import { knownRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js"; import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; +import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; +import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; const SERVER_STOP_TIMEOUT_MS = 1000; +/** + * @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}} + */ const state = { port: null, blockedRequests: [], @@ -15,15 +19,18 @@ const state = { export function createSafeChainProxy() { const server = createProxyServer(); - server.on("connect", handleConnect); return { startServer: () => startServer(server), stopServer: () => stopServer(server), verifyNoMaliciousPackages, + hasSuppressedVersions: getHasSuppressedVersions, }; } +/** + * @returns {Record} + */ function getSafeChainProxyEnvironmentVariables() { if (!state.port) { return {}; @@ -36,6 +43,11 @@ function getSafeChainProxyEnvironmentVariables() { }; } +/** + * @param {Record} env + * + * @returns {Record} + */ export function mergeSafeChainProxyEnvironmentVariables(env) { const proxyEnv = getSafeChainProxyEnvironmentVariables(); @@ -45,7 +57,7 @@ export function mergeSafeChainProxyEnvironmentVariables(env) { // So we only copy the variable if it's not already set in a different case const upperKey = key.toUpperCase(); - if (!proxyEnv[upperKey]) { + if (!proxyEnv[upperKey] && env[key]) { proxyEnv[key] = env[key]; } } @@ -54,17 +66,24 @@ export function mergeSafeChainProxyEnvironmentVariables(env) { } function createProxyServer() { - const server = http.createServer((_, res) => { - res.writeHead(400, "Bad Request"); - res.write( - "Safe-chain proxy: Direct http not supported. Only CONNECT requests are allowed." - ); - res.end(); - }); + const server = http.createServer( + // This handles direct HTTP requests (non-CONNECT requests) + // This is normally http-only traffic, but we also handle + // https for clients that don't properly use CONNECT + handleHttpProxyRequest + ); + + // This handles HTTPS requests via the CONNECT method + server.on("connect", handleConnect); return server; } +/** + * @param {import("http").Server} server + * + * @returns {Promise} + */ function startServer(server) { return new Promise((resolve, reject) => { // Passing port 0 makes the OS assign an available port @@ -84,6 +103,11 @@ function startServer(server) { }); } +/** + * @param {import("http").Server} server + * + * @returns {Promise} + */ function stopServer(server) { return new Promise((resolve) => { try { @@ -97,39 +121,41 @@ function stopServer(server) { }); } +/** + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} clientSocket + * @param {Buffer} head + * + * @returns {void} + */ function handleConnect(req, clientSocket, head) { // CONNECT method is used for HTTPS requests // It establishes a tunnel to the server identified by the request URL - if (knownRegistries.some((reg) => req.url.includes(reg))) { - // For npm and yarn registries, we want to intercept and inspect the traffic - // so we can block packages with malware - mitmConnect(req, clientSocket, isAllowedUrl); + const interceptor = createInterceptorForUrl(req.url || ""); + + if (interceptor) { + // Subscribe to malware blocked events + interceptor.on("malwareBlocked", (event) => { + onMalwareBlocked(event.packageName, event.version, event.url); + }); + + mitmConnect(req, clientSocket, interceptor); } else { // For other hosts, just tunnel the request to the destination tcp socket + ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`); tunnelRequest(req, clientSocket, head); } } -async function isAllowedUrl(url) { - const { packageName, version } = parsePackageFromUrl(url); - - // packageName and version are undefined when the URL is not a package download - // In that case, we can allow the request to proceed - if (!packageName || !version) { - return true; - } - - const auditResult = await auditChanges([ - { name: packageName, version, type: "add" }, - ]); - - if (!auditResult.isAllowed) { - state.blockedRequests.push({ packageName, version, url }); - return false; - } - - return true; +/** + * + * @param {string} packageName + * @param {string} version + * @param {string} url + */ +function onMalwareBlocked(packageName, version, url) { + state.blockedRequests.push({ packageName, version, url }); } function verifyNoMaliciousPackages() { @@ -151,7 +177,7 @@ function verifyNoMaliciousPackages() { } ui.emptyLine(); - ui.writeError("Exiting without installing malicious packages."); + ui.writeExitWithoutInstallingMaliciousPackages(); ui.emptyLine(); return false; diff --git a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js new file mode 100644 index 0000000..df4332e --- /dev/null +++ b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js @@ -0,0 +1,324 @@ +import { before, after, describe, it } from "node:test"; +import assert from "node:assert"; +import net from "net"; +import tls from "tls"; +import { + createSafeChainProxy, + mergeSafeChainProxyEnvironmentVariables, +} from "./registryProxy.js"; +import { getCaCertPath } from "./certUtils.js"; +import { setEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; +import fs from "fs"; + +describe("registryProxy.mitm", () => { + let proxy, proxyHost, proxyPort; + + before(async () => { + proxy = createSafeChainProxy(); + await proxy.startServer(); + const envVars = mergeSafeChainProxyEnvironmentVariables([]); + const proxyUrl = new URL(envVars.HTTPS_PROXY); + proxyHost = proxyUrl.hostname; + proxyPort = parseInt(proxyUrl.port, 10); + // Default to JS ecosystem for JS registry tests + setEcoSystem(ECOSYSTEM_JS); + }); + + after(async () => { + await proxy.stopServer(); + }); + + it("should intercept HTTPS requests to npm registry", async () => { + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/lodash" + ); + + assert.strictEqual(response.statusCode, 200); + assert.ok(response.body.includes("lodash")); + }); + + it("should allow non-malicious package downloads", async () => { + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/lodash/-/lodash-4.17.21.tgz" + ); + + // Should get a response (200 or redirect, but not 403 blocked) + assert.notStrictEqual(response.statusCode, 403); + }); + + it("should handle 404 responses correctly", async () => { + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/this-package-definitely-does-not-exist-12345" + ); + + assert.strictEqual(response.statusCode, 404); + }); + + it("should handle query parameters in URL", async () => { + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/lodash?write=true" + ); + + assert.strictEqual(response.statusCode, 200); + }); + + it("should generate valid certificates for yarn registry", async () => { + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "registry.yarnpkg.com", + "/lodash" + ); + + assert.strictEqual(response.statusCode, 200); + }); + + it("should generate certificate with correct hostname in CN", async () => { + const { cert } = await makeRegistryRequestAndGetCert( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/lodash" + ); + + // Check certificate common name matches the target hostname + assert.strictEqual(cert.subject.CN, "registry.npmjs.org"); + + // Check Subject Alternative Name includes the hostname + const san = cert.subjectaltname; + assert.ok(san.includes("registry.npmjs.org")); + + // Check certificate is issued by safe-chain CA + assert.strictEqual(cert.issuer.CN, "safe-chain proxy"); + }); + + it("should generate different certificates for different hostnames", async () => { + const { cert: cert1 } = await makeRegistryRequestAndGetCert( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/lodash" + ); + + const { cert: cert2 } = await makeRegistryRequestAndGetCert( + proxyHost, + proxyPort, + "registry.yarnpkg.com", + "/lodash" + ); + + // Different hostnames should have different certificates + assert.notStrictEqual(cert1.fingerprint, cert2.fingerprint); + assert.strictEqual(cert1.subject.CN, "registry.npmjs.org"); + assert.strictEqual(cert2.subject.CN, "registry.yarnpkg.com"); + }); + + it("should cache generated certificates for same hostname", async () => { + const { cert: cert1 } = await makeRegistryRequestAndGetCert( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/lodash" + ); + + const { cert: cert2 } = await makeRegistryRequestAndGetCert( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/package/lodash" + ); + + // Same hostname should get the same certificate (fingerprint) + assert.strictEqual(cert1.fingerprint, cert2.fingerprint); + }); + + // --- Pip registry MITM and env var tests --- + it("should NOT set Python CA environment variables in proxy merge (handled by runPipCommand)", () => { + const envVars = mergeSafeChainProxyEnvironmentVariables([]); + assert.strictEqual(envVars.PIP_CERT, undefined); + assert.strictEqual(envVars.REQUESTS_CA_BUNDLE, undefined); + assert.strictEqual(envVars.SSL_CERT_FILE, undefined); + }); + + it("should intercept HTTPS requests to pypi.org for pip package", async () => { + // Switch to Python ecosystem for pip registry MITM tests + setEcoSystem(ECOSYSTEM_PY); + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "pypi.org", + "/packages/source/f/foo_bar/foo_bar-2.0.0.tar.gz" + ); + assert.notStrictEqual(response.statusCode, 403); + assert.ok(typeof response.body === "string"); + }); + + it("should intercept HTTPS requests to files.pythonhosted.org for pip wheel", async () => { + // Ensure Python ecosystem + setEcoSystem(ECOSYSTEM_PY); + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "files.pythonhosted.org", + "/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl" + ); + assert.notStrictEqual(response.statusCode, 403); + assert.ok(typeof response.body === "string"); + }); + + it("should handle pip package with a1 version", async () => { + // Ensure Python ecosystem + setEcoSystem(ECOSYSTEM_PY); + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "pypi.org", + "/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz" + ); + assert.notStrictEqual(response.statusCode, 403); + assert.ok(typeof response.body === "string"); + }); + + it("should handle pip package with latest version (should not block)", async () => { + // Ensure Python ecosystem + setEcoSystem(ECOSYSTEM_PY); + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "pypi.org", + "/packages/source/f/foo_bar/foo_bar-latest.tar.gz" + ); + assert.notStrictEqual(response.statusCode, 403); + assert.ok(typeof response.body === "string"); + }); +}); + +async function makeRegistryRequest(proxyHost, proxyPort, targetHost, path) { + // Step 1: Connect to proxy + const socket = await new Promise((resolve, reject) => { + const sock = net.connect({ host: proxyHost, port: proxyPort }, () => { + resolve(sock); + }); + sock.on("error", reject); + }); + + // Step 2: Send CONNECT request + await new Promise((resolve) => { + const connectRequest = `CONNECT ${targetHost}:443 HTTP/1.1\r\nHost: ${targetHost}:443\r\n\r\n`; + socket.write(connectRequest); + socket.once("data", resolve); + }); + + // Step 3: Upgrade to TLS using the proxy's CA cert + const tlsSocket = tls.connect({ + socket: socket, + servername: targetHost, + ca: fs.readFileSync(getCaCertPath()), + rejectUnauthorized: true, + }); + + await new Promise((resolve) => { + tlsSocket.on("secureConnect", resolve); + }); + + // Step 4: Send HTTP request over TLS + const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\n\r\n`; + tlsSocket.write(httpRequest); + + // Step 5: Read response + return new Promise((resolve, reject) => { + let data = ""; + + tlsSocket.on("data", (chunk) => { + data += chunk.toString(); + }); + + tlsSocket.on("end", () => { + const lines = data.split("\r\n"); + const statusLine = lines[0]; + const statusCode = parseInt(statusLine.split(" ")[1]); + + // Find body after empty line + const emptyLineIndex = lines.findIndex(line => line === ""); + const body = lines.slice(emptyLineIndex + 1).join("\r\n"); + + resolve({ statusCode, body }); + }); + + tlsSocket.on("error", reject); + }); +} + +async function makeRegistryRequestAndGetCert(proxyHost, proxyPort, targetHost, path) { + // Step 1: Connect to proxy + const socket = await new Promise((resolve, reject) => { + const sock = net.connect({ host: proxyHost, port: proxyPort }, () => { + resolve(sock); + }); + sock.on("error", reject); + }); + + // Step 2: Send CONNECT request + await new Promise((resolve) => { + const connectRequest = `CONNECT ${targetHost}:443 HTTP/1.1\r\nHost: ${targetHost}:443\r\n\r\n`; + socket.write(connectRequest); + socket.once("data", resolve); + }); + + // Step 3: Upgrade to TLS and capture certificate + const tlsSocket = tls.connect({ + socket: socket, + servername: targetHost, + ca: fs.readFileSync(getCaCertPath()), + rejectUnauthorized: true, + }); + + let peerCert; + await new Promise((resolve) => { + tlsSocket.on("secureConnect", () => { + peerCert = tlsSocket.getPeerCertificate(); + resolve(); + }); + }); + + // Step 4: Send HTTP request over TLS + const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\n\r\n`; + tlsSocket.write(httpRequest); + + // Step 5: Read response + const response = await new Promise((resolve, reject) => { + let data = ""; + + tlsSocket.on("data", (chunk) => { + data += chunk.toString(); + }); + + tlsSocket.on("end", () => { + const lines = data.split("\r\n"); + const statusLine = lines[0]; + const statusCode = parseInt(statusLine.split(" ")[1]); + + // Find body after empty line + const emptyLineIndex = lines.findIndex(line => line === ""); + const body = lines.slice(emptyLineIndex + 1).join("\r\n"); + + resolve({ statusCode, body }); + }); + + tlsSocket.on("error", reject); + }); + + return { cert: peerCert, response }; +} diff --git a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js index fa12aee..b97799b 100644 --- a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js @@ -1,6 +1,17 @@ import * as net from "net"; import { ui } from "../environment/userInteraction.js"; +import { isImdsEndpoint } from "./isImdsEndpoint.js"; +/** @type {string[]} */ +let timedoutEndpoints = []; + +/** + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} clientSocket + * @param {Buffer} head + * + * @returns {void} + */ export function tunnelRequest(req, clientSocket, head) { const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; @@ -21,26 +32,97 @@ export function tunnelRequest(req, clientSocket, head) { } } +/** + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} clientSocket + * @param {Buffer} head + * + * @returns {void} + */ function tunnelRequestToDestination(req, clientSocket, head) { const { port, hostname } = new URL(`http://${req.url}`); + const isImds = isImdsEndpoint(hostname); - const serverSocket = net.connect(port || 443, hostname, () => { - clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); - serverSocket.write(head); - serverSocket.pipe(clientSocket); - clientSocket.pipe(serverSocket); + if (timedoutEndpoints.includes(hostname)) { + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + if (isImds) { + ui.writeVerbose( + `Safe-chain: Closing connection because previously timedout connect to ${hostname}` + ); + } else { + ui.writeError( + `Safe-chain: Closing connection because previously timedout connect to ${hostname}` + ); + } + return; + } + + const serverSocket = net.connect( + Number.parseInt(port) || 443, + hostname, + () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + serverSocket.write(head); + serverSocket.pipe(clientSocket); + clientSocket.pipe(serverSocket); + } + ); + + // Set explicit connection timeout to avoid waiting for OS default (~2 minutes). + // IMDS endpoints get shorter timeout (3s) since they're commonly unreachable outside cloud environments. + const connectTimeout = getConnectTimeout(hostname); + serverSocket.setTimeout(connectTimeout); + + serverSocket.on("timeout", () => { + timedoutEndpoints.push(hostname); + // Suppress error logging for IMDS endpoints - timeouts are expected when not in cloud + if (isImds) { + ui.writeVerbose( + `Safe-chain: connect to ${hostname}:${ + port || 443 + } timed out after ${connectTimeout}ms` + ); + } else { + ui.writeError( + `Safe-chain: connect to ${hostname}:${ + port || 443 + } timed out after ${connectTimeout}ms` + ); + } + serverSocket.destroy(); // Clean up socket to prevent event loop hanging + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + }); + + clientSocket.on("error", () => { + // This can happen if the client TCP socket sends RST instead of FIN. + // Not subscribing to 'error' event will cause node to throw and crash. + if (serverSocket.writable) { + serverSocket.end(); + } }); serverSocket.on("error", (err) => { - ui.writeError( - `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}` - ); + if (isImds) { + ui.writeVerbose( + `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}` + ); + } else { + ui.writeError( + `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}` + ); + } if (clientSocket.writable) { clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); } }); } +/** + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} clientSocket + * @param {Buffer} head + * @param {string} proxyUrl + */ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { const { port, hostname } = new URL(`http://${req.url}`); const proxy = new URL(proxyUrl); @@ -48,7 +130,7 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { // Connect to proxy server const proxySocket = net.connect({ host: proxy.hostname, - port: proxy.port, + port: Number.parseInt(proxy.port) || 80, }); proxySocket.on("connect", () => { @@ -97,6 +179,13 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { if (clientSocket.writable) { clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); } + } else { + ui.writeError( + `Safe-chain: proxy socket error after connection - ${err.message}` + ); + if (clientSocket.writable) { + clientSocket.end(); + } } }); @@ -106,3 +195,15 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { } }); } + +/** + * Returns appropriate connection timeout for a host. + * - IMDS endpoints: 3s (fail fast when outside cloud, reduce 5min delay to ~20s) + * - Other endpoints: 30s (allow for slow networks while preventing indefinite hangs) + */ +function getConnectTimeout(/** @type {string} */ host) { + if (isImdsEndpoint(host)) { + return 3000; + } + return 30000; +} diff --git a/packages/safe-chain/src/scanning/audit/index.js b/packages/safe-chain/src/scanning/audit/index.js index 215bfa0..771401e 100644 --- a/packages/safe-chain/src/scanning/audit/index.js +++ b/packages/safe-chain/src/scanning/audit/index.js @@ -1,8 +1,67 @@ +import { ui } from "../../environment/userInteraction.js"; import { MALWARE_STATUS_MALWARE, openMalwareDatabase, } from "../malwareDatabase.js"; +/** + * @typedef {Object} PackageChange + * @property {string} name + * @property {string} version + * @property {string} type + */ + +/** + * @typedef {Object} AuditResult + * @property {PackageChange[]} allowedChanges + * @property {(PackageChange & {reason: string})[]} disallowedChanges + * @property {boolean} isAllowed + */ + +/** + * @typedef {Object} AuditStats + * @property {number} totalPackages + * @property {number} safePackages + * @property {number} malwarePackages + */ + +/** + * @type AuditStats + */ +const auditStats = { + totalPackages: 0, + safePackages: 0, + malwarePackages: 0, +}; + +/** + * @returns {AuditStats} + */ +export function getAuditStats() { + return auditStats; +} + +/** + * + * @param {string | undefined} name + * @param {string | undefined} version + * @returns {Promise} + */ +export async function isMalwarePackage(name, version) { + if (!name || !version) { + return false; + } + + const auditResult = await auditChanges([{ name, version, type: "add" }]); + + return !auditResult.isAllowed; +} + +/** + * @param {PackageChange[]} changes + * + * @returns {Promise} + */ export async function auditChanges(changes) { const allowedChanges = []; const disallowedChanges = []; @@ -19,10 +78,20 @@ export async function auditChanges(changes) { ); if (malwarePackage) { + auditStats.malwarePackages += 1; + ui.writeVerbose( + `Safe-chain: Package ${change.name}@${change.version} is marked as malware: ${malwarePackage.status}` + ); disallowedChanges.push({ ...change, reason: malwarePackage.status }); } else { + auditStats.safePackages += 1; + ui.writeVerbose( + `Safe-chain: Package ${change.name}@${change.version} is clean` + ); allowedChanges.push(change); } + + auditStats.totalPackages += 1; } const auditResults = { @@ -34,6 +103,10 @@ export async function auditChanges(changes) { return auditResults; } +/** + * @param {{name: string, version: string, type: string}[]} changes + * @returns {Promise<{name: string, version: string, status: string}[]>} + */ async function getPackagesWithMalware(changes) { if (changes.length === 0) { return []; diff --git a/packages/safe-chain/src/scanning/audit/index.spec.js b/packages/safe-chain/src/scanning/audit/index.spec.js new file mode 100644 index 0000000..33ca9e3 --- /dev/null +++ b/packages/safe-chain/src/scanning/audit/index.spec.js @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import { describe, it, mock, beforeEach } from "node:test"; + +describe("audit/index", async () => { + const mockWriteVerbose = mock.fn(); + + // Mock UI module + mock.module("../../environment/userInteraction.js", { + namedExports: { + ui: { + writeVerbose: mockWriteVerbose, + }, + }, + }); + + // Mock malware database + const mockIsMalware = mock.fn(); + mock.module("../malwareDatabase.js", { + namedExports: { + MALWARE_STATUS_MALWARE: "malware", + openMalwareDatabase: async () => ({ + isMalware: mockIsMalware, + }), + }, + }); + + const { auditChanges, getAuditStats } = await import("./index.js"); + + beforeEach(() => { + mockWriteVerbose.mock.resetCalls(); + mockIsMalware.mock.resetCalls(); + }); + + describe("getAuditStats", () => { + it("should return audit stats object with correct structure", () => { + const stats = getAuditStats(); + + assert.ok(stats.hasOwnProperty("totalPackages")); + assert.ok(stats.hasOwnProperty("safePackages")); + assert.ok(stats.hasOwnProperty("malwarePackages")); + assert.equal(typeof stats.totalPackages, "number"); + assert.equal(typeof stats.safePackages, "number"); + assert.equal(typeof stats.malwarePackages, "number"); + }); + + it("should return the same object reference on multiple calls", () => { + const stats1 = getAuditStats(); + const stats2 = getAuditStats(); + + assert.equal(stats1, stats2); + }); + }); + + describe("auditChanges", () => { + it("should return empty allowed and disallowed arrays when no changes provided", async () => { + const result = await auditChanges([]); + + assert.deepEqual(result.allowedChanges, []); + assert.deepEqual(result.disallowedChanges, []); + assert.equal(result.isAllowed, true); + }); + + it("should mark package as allowed when not malware", async () => { + mockIsMalware.mock.mockImplementation(() => false); + + const changes = [{ name: "lodash", version: "4.17.21", type: "add" }]; + const result = await auditChanges(changes); + + assert.equal(result.allowedChanges.length, 1); + assert.equal(result.disallowedChanges.length, 0); + assert.equal(result.isAllowed, true); + assert.deepEqual(result.allowedChanges[0], changes[0]); + }); + + it("should mark package as disallowed when malware detected", async () => { + mockIsMalware.mock.mockImplementation(() => true); + + const changes = [ + { name: "malicious-pkg", version: "1.0.0", type: "add" }, + ]; + const result = await auditChanges(changes); + + assert.equal(result.allowedChanges.length, 0); + assert.equal(result.disallowedChanges.length, 1); + assert.equal(result.isAllowed, false); + assert.equal(result.disallowedChanges[0].name, "malicious-pkg"); + assert.equal(result.disallowedChanges[0].version, "1.0.0"); + assert.equal(result.disallowedChanges[0].reason, "malware"); + }); + + it("should handle mixed safe and malware packages", async () => { + mockIsMalware.mock.mockImplementation((name) => { + return name === "malicious-pkg"; + }); + + const changes = [ + { name: "lodash", version: "4.17.21", type: "add" }, + { name: "malicious-pkg", version: "1.0.0", type: "add" }, + { name: "express", version: "4.18.0", type: "add" }, + ]; + const result = await auditChanges(changes); + + assert.equal(result.allowedChanges.length, 2); + assert.equal(result.disallowedChanges.length, 1); + assert.equal(result.isAllowed, false); + assert.equal(result.disallowedChanges[0].name, "malicious-pkg"); + }); + + it("should only check malware for add and change types", async () => { + mockIsMalware.mock.mockImplementation(() => false); + + const changes = [ + { name: "pkg1", version: "1.0.0", type: "add" }, + { name: "pkg2", version: "2.0.0", type: "change" }, + { name: "pkg3", version: "3.0.0", type: "remove" }, + ]; + await auditChanges(changes); + + // Should only check pkg1 and pkg2, not pkg3 (remove type) + assert.equal(mockIsMalware.mock.calls.length, 2); + }); + + it("should increment totalPackages counter for each package", async () => { + mockIsMalware.mock.mockImplementation(() => false); + + const statsBefore = getAuditStats(); + const initialCount = statsBefore.totalPackages; + + const changes = [ + { name: "pkg1", version: "1.0.0", type: "add" }, + { name: "pkg2", version: "2.0.0", type: "add" }, + { name: "pkg3", version: "3.0.0", type: "add" }, + ]; + await auditChanges(changes); + + const statsAfter = getAuditStats(); + assert.equal(statsAfter.totalPackages, initialCount + 3); + }); + + it("should increment safePackages counter for safe packages", async () => { + mockIsMalware.mock.mockImplementation(() => false); + + const statsBefore = getAuditStats(); + const initialCount = statsBefore.safePackages; + + const changes = [ + { name: "lodash", version: "4.17.21", type: "add" }, + { name: "express", version: "4.18.0", type: "add" }, + ]; + await auditChanges(changes); + + const statsAfter = getAuditStats(); + assert.equal(statsAfter.safePackages, initialCount + 2); + }); + + it("should increment malwarePackages counter for malware packages", async () => { + mockIsMalware.mock.mockImplementation(() => true); + + const statsBefore = getAuditStats(); + const initialCount = statsBefore.malwarePackages; + + const changes = [ + { name: "malicious-1", version: "1.0.0", type: "add" }, + { name: "malicious-2", version: "2.0.0", type: "add" }, + ]; + await auditChanges(changes); + + const statsAfter = getAuditStats(); + assert.equal(statsAfter.malwarePackages, initialCount + 2); + }); + + it("should accumulate stats across multiple auditChanges calls", async () => { + mockIsMalware.mock.mockImplementation(() => false); + + const statsBefore = getAuditStats(); + const initialCount = statsBefore.totalPackages; + + // First call + await auditChanges([{ name: "pkg1", version: "1.0.0", type: "add" }]); + + // Second call + await auditChanges([{ name: "pkg2", version: "2.0.0", type: "add" }]); + + const statsAfter = getAuditStats(); + assert.equal(statsAfter.totalPackages, initialCount + 2); + }); + }); +}); diff --git a/packages/safe-chain/src/scanning/index.js b/packages/safe-chain/src/scanning/index.js index 36f62ca..abfc420 100644 --- a/packages/safe-chain/src/scanning/index.js +++ b/packages/safe-chain/src/scanning/index.js @@ -4,8 +4,12 @@ import { setTimeout } from "timers/promises"; import chalk from "chalk"; import { getPackageManager } from "../packagemanager/currentPackageManager.js"; import { ui } from "../environment/userInteraction.js"; -import { getMalwareAction, MALWARE_ACTION_PROMPT } from "../config/settings.js"; +/** + * @param {string[]} args + * + * @returns {boolean} + */ export function shouldScanCommand(args) { if (!args || args.length === 0) { return false; @@ -14,41 +18,30 @@ export function shouldScanCommand(args) { return getPackageManager().isSupportedCommand(args); } +/** + * @param {string[]} args + * + * @returns {Promise} + */ export async function scanCommand(args) { if (!shouldScanCommand(args)) { - return []; + return 0; } let timedOut = false; - - const spinner = ui.startProcess( - "Safe-chain: Scanning for malicious packages..." - ); + /** @type {import("./audit/index.js").AuditResult | undefined} */ let audit; await Promise.race([ (async () => { - try { - const packageManager = getPackageManager(); - const changes = await packageManager.getDependencyUpdatesForCommand( - args - ); + const packageManager = getPackageManager(); + const changes = await packageManager.getDependencyUpdatesForCommand(args); - if (timedOut) { - return; - } - - if (changes.length > 0) { - spinner.setText( - `Safe-chain: Scanning ${changes.length} package(s)...` - ); - } - - audit = await auditChanges(changes); - } catch (error) { - spinner.fail(`Safe-chain: Error while scanning.`); - throw error; + if (timedOut) { + return; } + + audit = await auditChanges(changes); })(), setTimeout(getScanTimeout()).then(() => { timedOut = true; @@ -56,44 +49,34 @@ export async function scanCommand(args) { ]); if (timedOut) { - spinner.fail("Safe-chain: Timeout exceeded while scanning."); throw new Error("Timeout exceeded while scanning npm install command."); } if (!audit || audit.isAllowed) { - spinner.stop(); return 0; } else { - printMaliciousChanges(audit.disallowedChanges, spinner); - return await onMalwareFound(); + printMaliciousChanges(audit.disallowedChanges); + onMalwareFound(); + return 1; } } -function printMaliciousChanges(changes, spinner) { - spinner.fail("Safe-chain: " + chalk.bold("Malicious changes detected:")); +/** + * @param {import("./audit/index.js").PackageChange[]} changes + * @return {void} + */ +function printMaliciousChanges(changes) { + ui.writeInformation( + chalk.red("✖") + " Safe-chain: " + chalk.bold("Malicious changes detected:") + ); for (const change of changes) { ui.writeInformation(` - ${change.name}@${change.version}`); } } -async function onMalwareFound() { +function onMalwareFound() { ui.emptyLine(); - - if (getMalwareAction() === MALWARE_ACTION_PROMPT) { - const continueInstall = await ui.confirm({ - message: - "Malicious packages were found. Do you want to continue with the installation?", - default: false, - }); - - if (continueInstall) { - ui.writeWarning("Continuing with the installation despite the risks..."); - return 0; - } - } - - ui.writeError("Exiting without installing malicious packages."); + ui.writeExitWithoutInstallingMaliciousPackages(); ui.emptyLine(); - return 1; } diff --git a/packages/safe-chain/src/scanning/index.scanCommand.spec.js b/packages/safe-chain/src/scanning/index.scanCommand.spec.js index 1858d10..944cf11 100644 --- a/packages/safe-chain/src/scanning/index.scanCommand.spec.js +++ b/packages/safe-chain/src/scanning/index.scanCommand.spec.js @@ -1,22 +1,10 @@ import assert from "node:assert/strict"; -import { beforeEach, describe, it, mock } from "node:test"; +import { describe, it, mock } from "node:test"; import { setTimeout } from "node:timers/promises"; -import { - MALWARE_ACTION_PROMPT, - MALWARE_ACTION_BLOCK, -} from "../config/settings.js"; describe("scanCommand", async () => { const getScanTimeoutMock = mock.fn(() => 1000); const mockGetDependencyUpdatesForCommand = mock.fn(); - const mockStartProcess = mock.fn(() => ({ - setText: () => {}, - succeed: () => {}, - fail: () => {}, - stop: () => {}, - })); - const mockConfirm = mock.fn(() => true); - let malwareAction = MALWARE_ACTION_PROMPT; // import { getPackageManager } from "../packagemanager/currentPackageManager.js"; mock.module("../packagemanager/currentPackageManager.js", { @@ -42,24 +30,15 @@ describe("scanCommand", async () => { mock.module("../environment/userInteraction.js", { namedExports: { ui: { - startProcess: mockStartProcess, writeError: () => {}, writeInformation: () => {}, writeWarning: () => {}, + writeExitWithoutInstallingMaliciousPackages: () => {}, emptyLine: () => {}, - confirm: mockConfirm, }, }, }); - mock.module("../config/settings.js", { - namedExports: { - getMalwareAction: () => malwareAction, - MALWARE_ACTION_PROMPT, - MALWARE_ACTION_BLOCK, - }, - }); - // import { auditChanges, MAX_LENGTH_EXCEEDED } from "./audit/index.js"; mock.module("./audit/index.js", { namedExports: { @@ -88,57 +67,21 @@ describe("scanCommand", async () => { const { scanCommand } = await import("./index.js"); - beforeEach(() => { - // Reset malware action back to prompt mode for other tests - malwareAction = MALWARE_ACTION_PROMPT; - }); - it("should succeed when there are no changes", async () => { - let progressWasStopped = false; - mockStartProcess.mock.mockImplementationOnce(() => ({ - setText: () => {}, - succeed: () => {}, - fail: () => {}, - stop: () => { - progressWasStopped = true; - }, - })); mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => []); await scanCommand(["install", "lodash"]); - - assert.equal(progressWasStopped, true); }); it("should succeed when changes are not malicious", async () => { - let progressWasStopped = false; - mockStartProcess.mock.mockImplementationOnce(() => ({ - setText: () => {}, - succeed: () => {}, - fail: () => {}, - stop: () => { - progressWasStopped = true; - }, - })); mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ { name: "lodash", version: "4.17.21" }, ]); await scanCommand(["install", "lodash"]); - - assert.equal(progressWasStopped, true); }); it("should throw an error when timing out", async () => { - let failureMessageWasSet = false; - mockStartProcess.mock.mockImplementationOnce(() => ({ - setText: () => {}, - succeed: () => {}, - fail: () => { - failureMessageWasSet = true; - }, - stop: () => {}, - })); getScanTimeoutMock.mock.mockImplementationOnce(() => 100); mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => { await setTimeout(150); @@ -146,120 +89,15 @@ describe("scanCommand", async () => { }); await assert.rejects(scanCommand(["install", "lodash"])); - - assert.equal(failureMessageWasSet, true); }); - it("should fail and prompt the user when malicious changes are detected", async () => { - let failureMessageWasSet = false; - mockStartProcess.mock.mockImplementationOnce(() => ({ - setText: () => {}, - succeed: () => {}, - fail: () => { - failureMessageWasSet = true; - }, - stop: () => {}, - })); - mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ - { name: "malicious", version: "1.0.0" }, - ]); - let userWasPrompted = false; - mockConfirm.mock.mockImplementationOnce(() => { - userWasPrompted = true; - return true; // Simulate user accepting the risk, otherwise the process would exit - }); - - await scanCommand(["install", "malicious"]); - - assert.equal(failureMessageWasSet, true); - assert.equal(userWasPrompted, true); - }); - - it("should not report a timeout when the user takes a long time to respond (it should not affect the timeout)", async () => { - let failureMessages = []; - mockStartProcess.mock.mockImplementationOnce(() => ({ - setText: () => {}, - succeed: () => {}, - fail: (message) => { - failureMessages.push(message); - }, - stop: () => {}, - })); - getScanTimeoutMock.mock.mockImplementationOnce(() => 100); - mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => { - return [{ name: "malicious", version: "4.17.21" }]; - }); - mockConfirm.mock.mockImplementationOnce(async () => { - await setTimeout(200); - return true; // Simulate user accepting the risk, otherwise the process would exit - }); - - await scanCommand(["install", "malicious"]); - - assert.equal(failureMessages.length, 1); - const failureMessage = failureMessages[0]; - assert.equal(failureMessage.toLowerCase().includes("timeout"), false); - assert.equal(failureMessage.toLowerCase().includes("malicious"), true); - }); - - it("should exit immediately when malicious changes are detected in block mode", async () => { - // Set malware action to block mode for this test - malwareAction = MALWARE_ACTION_BLOCK; - - // Reset mock call count - mockConfirm.mock.resetCalls(); - - let failureMessageWasSet = false; - - mockStartProcess.mock.mockImplementationOnce(() => ({ - setText: () => {}, - succeed: () => {}, - fail: () => { - failureMessageWasSet = true; - }, - stop: () => {}, - })); - + it("should fail and return 1 malicious changes are detected", async () => { mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ { name: "malicious", version: "1.0.0" }, ]); const result = await scanCommand(["install", "malicious"]); - assert.equal(failureMessageWasSet, true); assert.equal(result, 1); - // Confirm should not have been called in block mode - assert.equal(mockConfirm.mock.callCount(), 0); - }); - - it("should exit immediately when malicious changes are detected in block mode without prompting", async () => { - // Set malware action to block mode for this test - malwareAction = MALWARE_ACTION_BLOCK; - - // Reset mock call count - mockConfirm.mock.resetCalls(); - - let userWasPrompted = false; - - mockStartProcess.mock.mockImplementationOnce(() => ({ - setText: () => {}, - succeed: () => {}, - fail: () => {}, - stop: () => {}, - })); - - mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [ - { name: "malicious", version: "1.0.0" }, - ]); - - mockConfirm.mock.mockImplementationOnce(() => { - userWasPrompted = true; - return false; - }); - - const result = await scanCommand(["install", "malicious"]); - - assert.equal(result, 1); - assert.equal(userWasPrompted, false); }); }); diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index 1cb781b..4aba43c 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -7,9 +7,33 @@ import { writeDatabaseToLocalCache, } from "../config/configFile.js"; import { ui } from "../environment/userInteraction.js"; +import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js"; +/** + * @typedef {Object} MalwareDatabase + * @property {function(string, string): string} getPackageStatus + * @property {function(string, string): boolean} isMalware + */ + +/** @type {MalwareDatabase | null} */ let cachedMalwareDatabase = null; +/** + * Normalize package name for comparison. + * For Python packages (PEP-503): lowercase and replace _, -, . with - + * For js packages: keep as-is (case-sensitive) + * @param {string} name + * @returns {string} + */ +function normalizePackageName(name) { + const ecosystem = getEcoSystem(); + if (ecosystem === ECOSYSTEM_PY) { + return name.toLowerCase().replace(/[-_.]+/g, "-"); + } + + return name; +} + export async function openMalwareDatabase() { if (cachedMalwareDatabase) { return cachedMalwareDatabase; @@ -17,11 +41,19 @@ export async function openMalwareDatabase() { 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) => - pkg.package_name === name && - (pkg.version === version || pkg.version === "*") + (pkg) => { + const normalizedPkgName = normalizePackageName(pkg.package_name); + return normalizedPkgName === normalizedName && + (pkg.version === version || pkg.version === "*"); + } ); if (!packageData) { @@ -31,7 +63,7 @@ export async function openMalwareDatabase() { return packageData.reason; } - // This implicitely caches the malware database + // This implicitly caches the malware database // that's closed over by the getPackageStatus function cachedMalwareDatabase = { getPackageStatus, @@ -43,6 +75,9 @@ export async function openMalwareDatabase() { return cachedMalwareDatabase; } +/** + * @returns {Promise} + */ async function getMalwareDatabase() { const { malwareDatabase: cachedDatabase, version: cachedVersion } = readDatabaseFromLocalCache(); @@ -56,10 +91,20 @@ async function getMalwareDatabase() { } const { malwareDatabase, version } = await fetchMalwareDatabase(); - writeDatabaseToLocalCache(malwareDatabase, version); - return malwareDatabase; - } catch (error) { + if (version) { + // Only cache the malware database when we have a version. + writeDatabaseToLocalCache(malwareDatabase, version); + return malwareDatabase; + } else { + // We received a valid malware database, but the response + // did not contain an etag header with the version + ui.writeWarning( + "The malware database was downloaded, but could not be cached due to a missing version." + ); + return malwareDatabase; + } + } catch (/** @type any */ error) { if (cachedDatabase) { ui.writeWarning( "Failed to fetch the latest malware database. Using cached version." @@ -70,6 +115,11 @@ async function getMalwareDatabase() { } } +/** + * @param {string} status + * + * @returns {boolean} + */ function isMalwareStatus(status) { let malwareStatus = status.toUpperCase(); return malwareStatus === MALWARE_STATUS_MALWARE; diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 2345022..1d32e82 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -2,15 +2,92 @@ import { spawnSync } from "child_process"; import * as os from "os"; import fs from "fs"; import path from "path"; +import { ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; +/** + * @typedef {Object} AikidoTool + * @property {string} tool + * @property {string} aikidoCommand + * @property {string} ecoSystem + * @property {string} internalPackageManagerName + */ + +/** + * @type {AikidoTool[]} + */ export const knownAikidoTools = [ - { tool: "npm", aikidoCommand: "aikido-npm" }, - { tool: "npx", aikidoCommand: "aikido-npx" }, - { tool: "yarn", aikidoCommand: "aikido-yarn" }, - { tool: "pnpm", aikidoCommand: "aikido-pnpm" }, - { tool: "pnpx", aikidoCommand: "aikido-pnpx" }, - { tool: "bun", aikidoCommand: "aikido-bun" }, - { tool: "bunx", aikidoCommand: "aikido-bunx" }, + { + tool: "npm", + aikidoCommand: "aikido-npm", + ecoSystem: ECOSYSTEM_JS, + internalPackageManagerName: "npm", + }, + { + tool: "npx", + aikidoCommand: "aikido-npx", + ecoSystem: ECOSYSTEM_JS, + internalPackageManagerName: "npx", + }, + { + tool: "yarn", + aikidoCommand: "aikido-yarn", + ecoSystem: ECOSYSTEM_JS, + internalPackageManagerName: "yarn", + }, + { + tool: "pnpm", + aikidoCommand: "aikido-pnpm", + ecoSystem: ECOSYSTEM_JS, + internalPackageManagerName: "pnpm", + }, + { + tool: "pnpx", + aikidoCommand: "aikido-pnpx", + ecoSystem: ECOSYSTEM_JS, + internalPackageManagerName: "pnpx", + }, + { + tool: "bun", + aikidoCommand: "aikido-bun", + ecoSystem: ECOSYSTEM_JS, + internalPackageManagerName: "bun", + }, + { + tool: "bunx", + aikidoCommand: "aikido-bunx", + ecoSystem: ECOSYSTEM_JS, + internalPackageManagerName: "bunx", + }, + { + tool: "uv", + aikidoCommand: "aikido-uv", + ecoSystem: ECOSYSTEM_PY, + internalPackageManagerName: "uv", + }, + { + tool: "pip", + aikidoCommand: "aikido-pip", + ecoSystem: ECOSYSTEM_PY, + internalPackageManagerName: "pip", + }, + { + tool: "pip3", + aikidoCommand: "aikido-pip3", + ecoSystem: ECOSYSTEM_PY, + internalPackageManagerName: "pip", + }, + { + tool: "python", + aikidoCommand: "aikido-python", + ecoSystem: ECOSYSTEM_PY, + internalPackageManagerName: "pip", + }, + { + tool: "python3", + aikidoCommand: "aikido-python3", + ecoSystem: ECOSYSTEM_PY, + internalPackageManagerName: "pip", + }, // When adding a new tool here, also update the documentation for the new tool in the README.md ]; @@ -30,6 +107,11 @@ export function getPackageManagerList() { return `${tools.join(", ")}, and ${lastTool} commands`; } +/** + * @param {string} executableName + * + * @returns {boolean} + */ export function doesExecutableExistOnSystem(executableName) { if (os.platform() === "win32") { const result = spawnSync("where", [executableName], { stdio: "ignore" }); @@ -40,6 +122,13 @@ export function doesExecutableExistOnSystem(executableName) { } } +/** + * @param {string} filePath + * @param {RegExp} pattern + * @param {string} [eol] + * + * @returns {void} + */ export function removeLinesMatchingPattern(filePath, pattern, eol) { if (!fs.existsSync(filePath)) { return; @@ -54,6 +143,12 @@ export function removeLinesMatchingPattern(filePath, pattern, eol) { } const maxLineLength = 100; + +/** + * @param {string} line + * @param {RegExp} pattern + * @returns {boolean} + */ function shouldRemoveLine(line, pattern) { const isPatternMatch = pattern.test(line); @@ -82,6 +177,13 @@ function shouldRemoveLine(line, pattern) { return true; } +/** + * @param {string} filePath + * @param {string} line + * @param {string} [eol] + * + * @returns {void} + */ export function addLineToFile(filePath, line, eol) { createFileIfNotExists(filePath); @@ -92,6 +194,11 @@ export function addLineToFile(filePath, line, eol) { fs.writeFileSync(filePath, updatedContent, "utf-8"); } +/** + * @param {string} filePath + * + * @returns {void} + */ function createFileIfNotExists(filePath) { if (fs.existsSync(filePath)) { return; 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 6e6d826..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 @@ -7,9 +7,9 @@ remove_shim_from_path() { echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g" } -if command -v {{AIKIDO_COMMAND}} >/dev/null 2>&1; then +if command -v safe-chain >/dev/null 2>&1; then # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops - PATH=$(remove_shim_from_path) exec {{AIKIDO_COMMAND}} "$@" + PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@" else # Dynamically find original {{PACKAGE_MANAGER}} (excluding this shim directory) original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}}) @@ -19,4 +19,4 @@ else echo "Error: Could not find original {{PACKAGE_MANAGER}}" >&2 exit 1 fi -fi \ No newline at end of file +fi 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 b7a65fa..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 @@ -7,10 +7,10 @@ set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims" call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%" REM Check if aikido command is available with clean PATH -set "PATH=%CLEAN_PATH%" & where {{AIKIDO_COMMAND}} >nul 2>&1 +set "PATH=%CLEAN_PATH%" & where safe-chain >nul 2>&1 if %errorlevel%==0 ( REM Call aikido command with clean PATH - set "PATH=%CLEAN_PATH%" & {{AIKIDO_COMMAND}} %* + set "PATH=%CLEAN_PATH%" & safe-chain {{PACKAGE_MANAGER}} %* ) else ( REM Find the original command with clean PATH for /f "tokens=*" %%i in ('set "PATH=%CLEAN_PATH%" ^& where {{PACKAGE_MANAGER}} 2^>nul') do ( diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 0449ac4..bc5c5e6 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -1,10 +1,26 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; -import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; +import { getPackageManagerList, knownAikidoTools } from "./helpers.js"; import fs from "fs"; import os from "os"; import path from "path"; import { fileURLToPath } from "url"; +import { includePython } from "../config/cliArguments.js"; +import { ECOSYSTEM_PY } from "../config/settings.js"; + +/** @type {string} */ +// This checks the current file's dirname in a way that's compatible with: +// - 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. @@ -17,6 +33,7 @@ export async function setupCi() { ui.emptyLine(); const shimsDir = path.join(os.homedir(), ".safe-chain", "shims"); + 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 }); @@ -24,16 +41,19 @@ export async function setupCi() { createShims(shimsDir); ui.writeInformation(`Created shims in ${shimsDir}`); - modifyPathForCi(shimsDir); + modifyPathForCi(shimsDir, binDir); ui.writeInformation(`Added shims directory to PATH for CI environments.`); } +/** + * @param {string} shimsDir + * + * @returns {void} + */ function createUnixShims(shimsDir) { // Read the template file - const __filename = fileURLToPath(import.meta.url); - const __dirname = path.dirname(__filename); const templatePath = path.resolve( - __dirname, + dirname, "path-wrappers", "templates", "unix-wrapper.template.sh" @@ -47,7 +67,8 @@ function createUnixShims(shimsDir) { const template = fs.readFileSync(templatePath, "utf-8"); // Create a shim for each tool - for (const toolInfo of knownAikidoTools) { + let created = 0; + for (const toolInfo of getToolsToSetup()) { const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); @@ -57,19 +78,21 @@ function createUnixShims(shimsDir) { // Make the shim executable on Unix systems fs.chmodSync(shimPath, 0o755); + created++; } - ui.writeInformation( - `Created ${knownAikidoTools.length} Unix shim(s) in ${shimsDir}` - ); + ui.writeInformation(`Created ${created} Unix shim(s) in ${shimsDir}`); } +/** + * @param {string} shimsDir + * + * @returns {void} + */ function createWindowsShims(shimsDir) { // Read the template file - const __filename = fileURLToPath(import.meta.url); - const __dirname = path.dirname(__filename); const templatePath = path.resolve( - __dirname, + dirname, "path-wrappers", "templates", "windows-wrapper.template.cmd" @@ -83,20 +106,25 @@ function createWindowsShims(shimsDir) { const template = fs.readFileSync(templatePath, "utf-8"); // Create a shim for each tool - for (const toolInfo of knownAikidoTools) { + let created = 0; + for (const toolInfo of getToolsToSetup()) { const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); - const shimPath = path.join(shimsDir, `${toolInfo.tool}.cmd`); + const shimPath = `${shimsDir}/${toolInfo.tool}.cmd`; fs.writeFileSync(shimPath, shimContent, "utf-8"); + created++; } - ui.writeInformation( - `Created ${knownAikidoTools.length} Windows shim(s) in ${shimsDir}` - ); + ui.writeInformation(`Created ${created} Windows shim(s) in ${shimsDir}`); } +/** + * @param {string} shimsDir + * + * @returns {void} + */ function createShims(shimsDir) { if (os.platform() === "win32") { createWindowsShims(shimsDir); @@ -105,10 +133,20 @@ function createShims(shimsDir) { } } -function modifyPathForCi(shimsDir) { +/** + * @param {string} shimsDir + * @param {string} binDir + * + * @returns {void} + */ +function modifyPathForCi(shimsDir, binDir) { if (process.env.GITHUB_PATH) { // In GitHub Actions, append the shims directory to GITHUB_PATH - fs.appendFileSync(process.env.GITHUB_PATH, shimsDir + os.EOL, "utf-8"); + fs.appendFileSync( + process.env.GITHUB_PATH, + shimsDir + os.EOL + binDir + os.EOL, + "utf-8" + ); ui.writeInformation( `Added shims directory to GITHUB_PATH for GitHub Actions.` ); @@ -119,5 +157,14 @@ function modifyPathForCi(shimsDir) { // ##vso[task.prependpath]/path/to/add // Logging this to stdout will cause the Azure Pipelines agent to pick it up ui.writeInformation("##vso[task.prependpath]" + shimsDir); + ui.writeInformation("##vso[task.prependpath]" + binDir); + } +} + +function getToolsToSetup() { + if (includePython()) { + return knownAikidoTools; + } else { + return knownAikidoTools.filter((tool) => tool.ecoSystem !== ECOSYSTEM_PY); } } 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 0a26124..92ef82e 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -147,4 +147,4 @@ describe("Setup CI shell integration", () => { assert.ok(!fs.existsSync(unixNpmShim), "Unix npm shim should not exist on Windows"); }); }); -}); \ No newline at end of file +}); diff --git a/packages/safe-chain/src/shell-integration/setup.js b/packages/safe-chain/src/shell-integration/setup.js index afa96e8..d5c4be9 100644 --- a/packages/safe-chain/src/shell-integration/setup.js +++ b/packages/safe-chain/src/shell-integration/setup.js @@ -5,8 +5,23 @@ import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; import fs from "fs"; import os from "os"; import path from "path"; +import { includePython } from "../config/cliArguments.js"; import { fileURLToPath } from "url"; +/** @type {string} */ +// 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. */ @@ -43,7 +58,7 @@ export async function setup() { ui.emptyLine(); ui.writeInformation(`Please restart your terminal to apply the changes.`); } - } catch (error) { + } catch (/** @type {any} */ error) { ui.writeError( `Failed to set up shell aliases: ${error.message}. Please check your shell configuration.` ); @@ -53,6 +68,7 @@ export async function setup() { /** * Calls the setup function for the given shell and reports the result. + * @param {import("./shellDetection.js").Shell} shell */ function setupShell(shell) { let success = false; @@ -60,7 +76,7 @@ function setupShell(shell) { try { shell.teardown(knownAikidoTools); // First, tear down to prevent duplicate aliases success = shell.setup(knownAikidoTools); - } catch (err) { + } catch (/** @type {any} */ err) { success = false; error = err; } @@ -101,9 +117,11 @@ function copyStartupFiles() { } // Use absolute path for source - const __filename = fileURLToPath(import.meta.url); - const __dirname = path.dirname(__filename); - const sourcePath = path.resolve(__dirname, "startup-scripts", file); + const sourcePath = path.join( + dirname, + includePython() ? "startup-scripts/include-python" : "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 d868f6f..9e0f110 100644 --- a/packages/safe-chain/src/shell-integration/shellDetection.js +++ b/packages/safe-chain/src/shell-integration/shellDetection.js @@ -5,6 +5,17 @@ import windowsPowershell from "./supported-shells/windowsPowershell.js"; import fish from "./supported-shells/fish.js"; import { ui } from "../environment/userInteraction.js"; +/** + * @typedef {Object} Shell + * @property {string} name + * @property {() => boolean} isInstalled + * @property {(tools: import("./helpers.js").AikidoTool[]) => boolean} setup + * @property {(tools: import("./helpers.js").AikidoTool[]) => boolean} teardown + */ + +/** + * @returns {Shell[]} + */ export function detectShells() { let possibleShells = [zsh, bash, powershell, windowsPowershell, fish]; let availableShells = []; @@ -15,7 +26,7 @@ export function detectShells() { availableShells.push(shell); } } - } catch (error) { + } catch (/** @type {any} */ error) { ui.writeError( `We were not able to detect which shells are installed on your system. Please check your shell configuration. Error: ${error.message}` ); diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-fish.fish new file mode 100644 index 0000000..4c881ba --- /dev/null +++ b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-fish.fish @@ -0,0 +1,94 @@ +set -gx PATH $PATH $HOME/.safe-chain/bin + +function npx + wrapSafeChainCommand "npx" $argv +end + +function yarn + wrapSafeChainCommand "yarn" $argv +end + +function pnpm + wrapSafeChainCommand "pnpm" $argv +end + +function pnpx + wrapSafeChainCommand "pnpx" $argv +end + +function bun + wrapSafeChainCommand "bun" $argv +end + +function bunx + wrapSafeChainCommand "bunx" $argv +end + +function npm + # If args is just -v or --version and nothing else, just run the `npm -v` command + # This is because nvm uses this to check the version of npm + set argc (count $argv) + if test $argc -eq 1 + switch $argv[1] + case "-v" "--version" + command npm $argv + return + end + end + + wrapSafeChainCommand "npm" $argv +end + + +function pip + wrapSafeChainCommand "pip" $argv +end + +function pip3 + wrapSafeChainCommand "pip3" $argv +end + +function uv + wrapSafeChainCommand "uv" $argv +end + +# `python -m pip`, `python -m pip3`. +function python + wrapSafeChainCommand "python" $argv +end + +# `python3 -m pip`, `python3 -m pip3'. +function python3 + wrapSafeChainCommand "python3" $argv +end + +function printSafeChainWarning + set original_cmd $argv[1] + + # Fish equivalent of ANSI color codes: yellow background, black text for "Warning:" + set_color -b yellow black + printf "Warning:" + set_color normal + printf " safe-chain is not available to protect you from installing malware. %s will run without it.\n" $original_cmd + + # Cyan text for the install command + printf "Install safe-chain by using " + set_color cyan + printf "npm install -g @aikidosec/safe-chain" + set_color normal + printf ".\n" +end + +function wrapSafeChainCommand + set original_cmd $argv[1] + set cmd_args $argv[2..-1] + + if type -q safe-chain + # If the safe-chain command is available, just run it with the provided arguments + safe-chain $original_cmd $cmd_args + else + # If the safe-chain command is not available, print a warning and run the original command + printSafeChainWarning $original_cmd + command $original_cmd $cmd_args + end +end diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-posix.sh new file mode 100644 index 0000000..af5b18e --- /dev/null +++ b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-posix.sh @@ -0,0 +1,81 @@ +export PATH="$PATH:$HOME/.safe-chain/bin" + +function npx() { + wrapSafeChainCommand "npx" "$@" +} + +function yarn() { + wrapSafeChainCommand "yarn" "$@" +} + +function pnpm() { + wrapSafeChainCommand "pnpm" "$@" +} + +function pnpx() { + wrapSafeChainCommand "pnpx" "$@" +} + +function bun() { + wrapSafeChainCommand "bun" "$@" +} + +function bunx() { + wrapSafeChainCommand "bunx" "$@" +} + +function npm() { + if [[ "$1" == "-v" || "$1" == "--version" ]] && [[ $# -eq 1 ]]; then + # If args is just -v or --version and nothing else, just run the npm version command + # This is because nvm uses this to check the version of npm + command npm "$@" + return + fi + + wrapSafeChainCommand "npm" "$@" +} + + +function pip() { + wrapSafeChainCommand "pip" "$@" +} + +function pip3() { + wrapSafeChainCommand "pip3" "$@" +} + +function uv() { + wrapSafeChainCommand "uv" "$@" +} + +# `python -m pip`, `python -m pip3`. +function python() { + wrapSafeChainCommand "python" "$@" +} + +# `python3 -m pip`, `python3 -m pip3'. +function python3() { + wrapSafeChainCommand "python3" "$@" +} + +function printSafeChainWarning() { + # \033[43;30m is used to set the background color to yellow and text color to black + # \033[0m is used to reset the text formatting + printf "\033[43;30mWarning:\033[0m safe-chain is not available to protect you from installing malware. %s will run without it.\n" "$1" + # \033[36m is used to set the text color to cyan + printf "Install safe-chain by using \033[36mnpm install -g @aikidosec/safe-chain\033[0m.\n" +} + +function wrapSafeChainCommand() { + local original_cmd="$1" + + if command -v safe-chain > /dev/null 2>&1; then + # If the aikido command is available, just run it with the provided arguments + safe-chain "$@" + else + # If the aikido command is not available, print a warning and run the original command + printSafeChainWarning "$original_cmd" + + command "$original_cmd" "$@" + fi +} diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 new file mode 100644 index 0000000..c8d3310 --- /dev/null +++ b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 @@ -0,0 +1,115 @@ +# Use cross-platform path separator (: on Unix, ; on Windows) +$pathSeparator = if ($IsWindows) { ';' } else { ':' } +$safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin' +$env:PATH = "$env:PATH$pathSeparator$safeChainBin" + +function npx { + Invoke-WrappedCommand "npx" $args +} + +function yarn { + Invoke-WrappedCommand "yarn" $args +} + +function pnpm { + Invoke-WrappedCommand "pnpm" $args +} + +function pnpx { + Invoke-WrappedCommand "pnpx" $args +} + +function bun { + Invoke-WrappedCommand "bun" $args +} + +function bunx { + Invoke-WrappedCommand "bunx" $args +} + +function npm { + # If args is just -v or --version and nothing else, just run the npm version command + # This is because nvm uses this to check the version of npm + if (($args.Length -eq 1) -and (($args[0] -eq "-v") -or ($args[0] -eq "--version"))) { + Invoke-RealCommand "npm" $args + return + } + + Invoke-WrappedCommand "npm" $args +} + +function pip { + Invoke-WrappedCommand "pip" $args +} + +function pip3 { + Invoke-WrappedCommand "pip3" $args +} + +function uv { + Invoke-WrappedCommand "uv" $args +} + +# `python -m pip`, `python -m pip3`. +function python { + Invoke-WrappedCommand 'python' $args +} + +# `python3 -m pip`, `python3 -m pip3'. +function python3 { + Invoke-WrappedCommand 'python3' $args +} + + +function Write-SafeChainWarning { + param([string]$Command) + + # PowerShell equivalent of ANSI color codes: yellow background, black text for "Warning:" + Write-Host "Warning:" -BackgroundColor Yellow -ForegroundColor Black -NoNewline + Write-Host " safe-chain is not available to protect you from installing malware. $Command will run without it." + + # Cyan text for the install command + Write-Host "Install safe-chain by using " -NoNewline + Write-Host "npm install -g @aikidosec/safe-chain" -ForegroundColor Cyan -NoNewline + Write-Host "." +} + +function Test-CommandAvailable { + param([string]$Command) + + try { + Get-Command $Command -ErrorAction Stop | Out-Null + return $true + } + catch { + return $false + } +} + +function Invoke-RealCommand { + param( + [string]$Command, + [string[]]$Arguments + ) + + # Find the real executable to avoid calling our wrapped functions + $realCommand = Get-Command -Name $Command -CommandType Application | Select-Object -First 1 + if ($realCommand) { + & $realCommand.Source @Arguments + } +} + +function Invoke-WrappedCommand { + param( + [string]$OriginalCmd, + [string[]]$Arguments + ) + + if (Test-CommandAvailable "safe-chain") { + & safe-chain $OriginalCmd @Arguments + } + else { + Write-SafeChainWarning $OriginalCmd + Invoke-RealCommand $OriginalCmd $Arguments + } +} 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 29d6bf3..b18ff96 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,3 +1,44 @@ +set -gx PATH $PATH $HOME/.safe-chain/bin + +function npx + wrapSafeChainCommand "npx" $argv +end + +function yarn + wrapSafeChainCommand "yarn" $argv +end + +function pnpm + wrapSafeChainCommand "pnpm" $argv +end + +function pnpx + wrapSafeChainCommand "pnpx" $argv +end + +function bun + wrapSafeChainCommand "bun" $argv +end + +function bunx + wrapSafeChainCommand "bunx" $argv +end + +function npm + # If args is just -v or --version and nothing else, just run the `npm -v` command + # This is because nvm uses this to check the version of npm + set argc (count $argv) + if test $argc -eq 1 + switch $argv[1] + case "-v" "--version" + command npm $argv + return + end + end + + wrapSafeChainCommand "npm" $argv +end + function printSafeChainWarning set original_cmd $argv[1] @@ -17,54 +58,14 @@ end function wrapSafeChainCommand set original_cmd $argv[1] - set aikido_cmd $argv[2] - set cmd_args $argv[3..-1] - - if type -q $aikido_cmd - # If the aikido command is available, just run it with the provided arguments - $aikido_cmd $cmd_args + set cmd_args $argv[2..-1] + + if type -q safe-chain + # If the safe-chain command is available, just run it with the provided arguments + safe-chain $original_cmd $cmd_args else - # If the aikido command is not available, print a warning and run the original command + # If the safe-chain command is not available, print a warning and run the original command printSafeChainWarning $original_cmd command $original_cmd $cmd_args end end - -function npx - wrapSafeChainCommand "npx" "aikido-npx" $argv -end - -function yarn - wrapSafeChainCommand "yarn" "aikido-yarn" $argv -end - -function pnpm - wrapSafeChainCommand "pnpm" "aikido-pnpm" $argv -end - -function pnpx - wrapSafeChainCommand "pnpx" "aikido-pnpx" $argv -end - -function bun - wrapSafeChainCommand "bun" "aikido-bun" $argv -end - -function bunx - wrapSafeChainCommand "bunx" "aikido-bunx" $argv -end - -function npm - # If args is just -v or --version and nothing else, just run the `npm -v` command - # This is because nvm uses this to check the version of npm - set argc (count $argv) - if test $argc -eq 1 - switch $argv[1] - case "-v" "--version" - command npm $argv - return - end - end - - wrapSafeChainCommand "npm" "aikido-npm" $argv -end diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index 353c6c0..5c32143 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,3 +1,39 @@ +export PATH="$PATH:$HOME/.safe-chain/bin" + +function npx() { + wrapSafeChainCommand "npx" "$@" +} + +function yarn() { + wrapSafeChainCommand "yarn" "$@" +} + +function pnpm() { + wrapSafeChainCommand "pnpm" "$@" +} + +function pnpx() { + wrapSafeChainCommand "pnpx" "$@" +} + +function bun() { + wrapSafeChainCommand "bun" "$@" +} + +function bunx() { + wrapSafeChainCommand "bunx" "$@" +} + +function npm() { + if [[ "$1" == "-v" || "$1" == "--version" ]] && [[ $# -eq 1 ]]; then + # If args is just -v or --version and nothing else, just run the npm version command + # This is because nvm uses this to check the version of npm + command npm "$@" + return + fi + + wrapSafeChainCommand "npm" "$@" +} function printSafeChainWarning() { # \033[43;30m is used to set the background color to yellow and text color to black @@ -9,15 +45,10 @@ function printSafeChainWarning() { function wrapSafeChainCommand() { local original_cmd="$1" - local aikido_cmd="$2" - # Remove the first 2 arguments (original_cmd and aikido_cmd) from $@ - # so that "$@" now contains only the arguments passed to the original command - shift 2 - - if command -v "$aikido_cmd" > /dev/null 2>&1; then + if command -v safe-chain > /dev/null 2>&1; then # If the aikido command is available, just run it with the provided arguments - "$aikido_cmd" "$@" + safe-chain "$@" else # If the aikido command is not available, print a warning and run the original command printSafeChainWarning "$original_cmd" @@ -25,38 +56,3 @@ function wrapSafeChainCommand() { command "$original_cmd" "$@" fi } - -function npx() { - wrapSafeChainCommand "npx" "aikido-npx" "$@" -} - -function yarn() { - wrapSafeChainCommand "yarn" "aikido-yarn" "$@" -} - -function pnpm() { - wrapSafeChainCommand "pnpm" "aikido-pnpm" "$@" -} - -function pnpx() { - wrapSafeChainCommand "pnpx" "aikido-pnpx" "$@" -} - -function bun() { - wrapSafeChainCommand "bun" "aikido-bun" "$@" -} - -function bunx() { - wrapSafeChainCommand "bunx" "aikido-bunx" "$@" -} - -function npm() { - if [[ "$1" == "-v" || "$1" == "--version" ]] && [[ $# -eq 1 ]]; then - # If args is just -v or --version and nothing else, just run the npm version command - # This is because nvm uses this to check the version of npm - command npm "$@" - return - fi - - wrapSafeChainCommand "npm" "aikido-npm" "$@" -} diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index a449405..78228a0 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -1,3 +1,43 @@ +# Use cross-platform path separator (: on Unix, ; on Windows) +$pathSeparator = if ($IsWindows) { ';' } else { ':' } +$safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin' +$env:PATH = "$env:PATH$pathSeparator$safeChainBin" + +function npx { + Invoke-WrappedCommand "npx" $args +} + +function yarn { + Invoke-WrappedCommand "yarn" $args +} + +function pnpm { + Invoke-WrappedCommand "pnpm" $args +} + +function pnpx { + Invoke-WrappedCommand "pnpx" $args +} + +function bun { + Invoke-WrappedCommand "bun" $args +} + +function bunx { + Invoke-WrappedCommand "bunx" $args +} + +function npm { + # If args is just -v or --version and nothing else, just run the npm version command + # This is because nvm uses this to check the version of npm + if (($args.Length -eq 1) -and (($args[0] -eq "-v") -or ($args[0] -eq "--version"))) { + Invoke-RealCommand "npm" $args + return + } + + Invoke-WrappedCommand "npm" $args +} + function Write-SafeChainWarning { param([string]$Command) @@ -39,50 +79,14 @@ function Invoke-RealCommand { function Invoke-WrappedCommand { param( [string]$OriginalCmd, - [string]$AikidoCmd, [string[]]$Arguments ) - if (Test-CommandAvailable $AikidoCmd) { - & $AikidoCmd @Arguments + if (Test-CommandAvailable "safe-chain") { + & safe-chain $OriginalCmd @Arguments } else { Write-SafeChainWarning $OriginalCmd Invoke-RealCommand $OriginalCmd $Arguments } } - -function npx { - Invoke-WrappedCommand "npx" "aikido-npx" $args -} - -function yarn { - Invoke-WrappedCommand "yarn" "aikido-yarn" $args -} - -function pnpm { - Invoke-WrappedCommand "pnpm" "aikido-pnpm" $args -} - -function pnpx { - Invoke-WrappedCommand "pnpx" "aikido-pnpx" $args -} - -function bun { - Invoke-WrappedCommand "bun" "aikido-bun" $args -} - -function bunx { - Invoke-WrappedCommand "bunx" "aikido-bunx" $args -} - -function npm { - # If args is just -v or --version and nothing else, just run the npm version command - # This is because nvm uses this to check the version of npm - if (($args.Length -eq 1) -and (($args[0] -eq "-v") -or ($args[0] -eq "--version"))) { - Invoke-RealCommand "npm" $args - return - } - - Invoke-WrappedCommand "npm" "aikido-npm" $args -} diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 6038f95..a2a3739 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -15,6 +15,11 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } +/** + * @param {import("../helpers.js").AikidoTool[]} tools + * + * @returns {boolean} + */ function teardown(tools) { const startupFile = getStartupFile(); @@ -57,13 +62,18 @@ function getStartupFile() { }).trim(); return windowsFixPath(path); - } catch (error) { + } catch (/** @type {any} */ error) { throw new Error( `Command failed: ${startupFileCommand}. Error: ${error.message}` ); } } +/** + * @param {string} path + * + * @returns {string} + */ function windowsFixPath(path) { try { if (os.platform() !== "win32") { @@ -93,6 +103,11 @@ function hasCygpath() { } } +/** + * @param {string} path + * + * @returns {string} + */ function cygpathw(path) { try { var result = spawnSync("cygpath", ["-w", path], { @@ -108,6 +123,9 @@ function cygpathw(path) { } } +/** + * @type {import("../shellDetection.js").Shell} + */ export default { name: shellName, isInstalled, 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 4c39ba6..0af6ae3 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -14,6 +14,11 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } +/** + * @param {import("../helpers.js").AikidoTool[]} tools + * + * @returns {boolean} + */ function teardown(tools) { const startupFile = getStartupFile(); @@ -54,13 +59,16 @@ function getStartupFile() { encoding: "utf8", shell: executableName, }).trim(); - } catch (error) { + } catch (/** @type {any} */ error) { throw new Error( `Command failed: ${startupFileCommand}. Error: ${error.message}` ); } } +/** + * @type {import("../shellDetection.js").Shell} + */ export default { name: shellName, isInstalled, 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 47524c2..8cec258 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -13,6 +13,11 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } +/** + * @param {import("../helpers.js").AikidoTool[]} tools + * + * @returns {boolean} + */ function teardown(tools) { const startupFile = getStartupFile(); @@ -50,13 +55,16 @@ function getStartupFile() { encoding: "utf8", shell: executableName, }).trim(); - } catch (error) { + } catch (/** @type {any} */ error) { throw new Error( `Command failed: ${startupFileCommand}. Error: ${error.message}` ); } } +/** + * @type {import("../shellDetection.js").Shell} + */ export default { name: shellName, isInstalled, 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 03ff7f8..e554a32 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -13,6 +13,11 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } +/** + * @param {import("../helpers.js").AikidoTool[]} tools + * + * @returns {boolean} + */ function teardown(tools) { const startupFile = getStartupFile(); @@ -50,13 +55,16 @@ function getStartupFile() { encoding: "utf8", shell: executableName, }).trim(); - } catch (error) { + } catch (/** @type {any} */ error) { throw new Error( `Command failed: ${startupFileCommand}. Error: ${error.message}` ); } } +/** + * @type {import("../shellDetection.js").Shell} + */ export default { name: shellName, isInstalled, 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 b90f769..fc2b807 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -14,6 +14,11 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } +/** + * @param {import("../helpers.js").AikidoTool[]} tools + * + * @returns {boolean} + */ function teardown(tools) { const startupFile = getStartupFile(); @@ -54,7 +59,7 @@ function getStartupFile() { encoding: "utf8", shell: executableName, }).trim(); - } catch (error) { + } catch (/** @type {any} */ error) { throw new Error( `Command failed: ${startupFileCommand}. Error: ${error.message}` ); diff --git a/packages/safe-chain/src/shell-integration/teardown.js b/packages/safe-chain/src/shell-integration/teardown.js index d6b1277..bc83b48 100644 --- a/packages/safe-chain/src/shell-integration/teardown.js +++ b/packages/safe-chain/src/shell-integration/teardown.js @@ -3,6 +3,9 @@ import { ui } from "../environment/userInteraction.js"; import { detectShells } from "./shellDetection.js"; import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; +/** + * @returns {Promise} + */ export async function teardown() { ui.writeInformation( chalk.bold("Removing shell aliases.") + @@ -52,7 +55,7 @@ export async function teardown() { ui.emptyLine(); ui.writeInformation(`Please restart your terminal to apply the changes.`); } - } catch (error) { + } catch (/** @type {any} */ error) { ui.writeError( `Failed to remove shell aliases: ${error.message}. Please check your shell configuration.` ); diff --git a/packages/safe-chain/src/utils/safeSpawn.js b/packages/safe-chain/src/utils/safeSpawn.js index c5cd913..e17bdb5 100644 --- a/packages/safe-chain/src/utils/safeSpawn.js +++ b/packages/safe-chain/src/utils/safeSpawn.js @@ -1,27 +1,109 @@ -import { spawnSync, spawn } from "child_process"; +import { spawn, execSync } from "child_process"; +import os from "os"; -function escapeArg(arg) { - // If argument contains spaces or quotes, wrap in double quotes and escape double quotes - if (arg.includes(" ") || arg.includes('"') || arg.includes("'")) { - return '"' + arg.replaceAll('"', '\\"') + '"'; +/** + * @param {string} arg + * + * @returns {string} + */ +function sanitizeShellArgument(arg) { + // If argument contains shell metacharacters, wrap in double quotes + // and escape characters that are special even inside double quotes + if (hasShellMetaChars(arg)) { + // Inside double quotes, we need to escape: " $ ` \ + return '"' + escapeDoubleQuoteContent(arg) + '"'; } return arg; } +/** + * @param {string} arg + * + * @returns {boolean} + */ +function hasShellMetaChars(arg) { + // Shell metacharacters that need escaping + // These characters have special meaning in shells and need to be quoted + // Whenever one of these characters is present, we should quote the argument + // Characters: space, ", &, ', |, ;, <, >, (, ), $, `, \, !, *, ?, [, ], {, }, ~, # + const shellMetaChars = /[ "&'|;<>()$`\\!*?[\]{}~#]/; + return shellMetaChars.test(arg); +} + +/** + * @param {string} arg + * + * @returns {string} + */ +function escapeDoubleQuoteContent(arg) { + // Escape special characters for shell safety + // This escapes ", $, `, and \ by prefixing them with a backslash + return arg.replace(/(["`$\\])/g, "\\$1"); +} + +/** + * @param {string} command + * @param {string[]} args + * + * @returns {string} + */ function buildCommand(command, args) { - const escapedArgs = args.map(escapeArg); + if (args.length === 0) { + return command; + } + + const escapedArgs = args.map(sanitizeShellArgument); + return `${command} ${escapedArgs.join(" ")}`; } -export function safeSpawnSync(command, args, options = {}) { - const fullCommand = buildCommand(command, args); - return spawnSync(fullCommand, { ...options, shell: true }); +/** + * @param {string} command + * + * @returns {string} + */ +function resolveCommandPath(command) { + // command will be "npm", "yarn", etc. + // Use 'command -v' to find the full path + const fullPath = execSync(`command -v ${command}`, { + encoding: "utf8", + }).trim(); + + if (!fullPath) { + throw new Error(`Command not found: ${command}`); + } + + return fullPath; } +/** + * @param {string} command + * @param {string[]} args + * @param {import("child_process").SpawnOptions} options + * + * @returns {Promise<{status: number, stdout: string, stderr: string}>} + */ export async function safeSpawn(command, args, options = {}) { - const fullCommand = buildCommand(command, args); + // The command is always one of our supported package managers. + // It should always be alphanumeric or _ or - + // Reject any command names with suspicious characters + if (!/^[a-zA-Z0-9_-]+$/.test(command)) { + throw new Error(`Invalid command name: ${command}`); + } + return new Promise((resolve, reject) => { - const child = spawn(fullCommand, { ...options, shell: true }); + // Windows requires shell: true because .bat and .cmd files are not executable + // without a terminal. On Unix/macOS, we resolve the full path first, then use + // array args (safer, no escaping needed). + // See: https://nodejs.org/api/child_process.html#child_processspawncommand-args-options + let child; + if (os.platform() === "win32") { + const fullCommand = buildCommand(command, args); + child = spawn(fullCommand, { ...options, shell: true }); + } else { + const fullPath = resolveCommandPath(command); + child = spawn(fullPath, args, options); + } // When stdio is piped, we need to collect the output let stdout = ""; @@ -36,6 +118,11 @@ export async function safeSpawn(command, args, options = {}) { }); child.on("close", (code) => { + // Code is null if it terminated by a signal. This should never + // happen in our code. If this happens, return 1 error code. + + code = code ?? 1; + resolve({ status: code, stdout: stdout, diff --git a/packages/safe-chain/src/utils/safeSpawn.spec.js b/packages/safe-chain/src/utils/safeSpawn.spec.js index d325f8a..cbc5583 100644 --- a/packages/safe-chain/src/utils/safeSpawn.spec.js +++ b/packages/safe-chain/src/utils/safeSpawn.spec.js @@ -2,40 +2,52 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert"; describe("safeSpawn", () => { - let safeSpawnSync, safeSpawn; + let safeSpawn; let spawnCalls = []; + let os; beforeEach(async () => { spawnCalls = []; + os = "win32"; // Test Windows behavior by default // Mock child_process module to capture what command string gets built mock.module("child_process", { namedExports: { - spawnSync: (command, options) => { - spawnCalls.push({ command, options }); - return { - status: 0, - stdout: Buffer.from(""), - stderr: Buffer.from(""), - }; - }, - spawn: (command, options) => { - spawnCalls.push({ command, options }); + spawn: (command, argsOrOptions, options) => { + // Handle both signatures: spawn(cmd, {opts}) and spawn(cmd, [args], {opts}) + if (Array.isArray(argsOrOptions)) { + spawnCalls.push({ command, args: argsOrOptions, options: options || {} }); + } else { + spawnCalls.push({ command, options: argsOrOptions || {} }); + } return { on: (event, callback) => { - if (event === 'close') { + if (event === "close") { // Simulate immediate success setTimeout(() => callback(0), 0); } - } + }, }; }, + execSync: (cmd) => { + // Simulate 'command -v' returning full path + const match = cmd.match(/command -v (.+)/); + if (match) { + return `/usr/bin/${match[1]}\n`; + } + return ""; + }, + }, + }); + + mock.module("os", { + namedExports: { + platform: () => os, }, }); // Import after mocking const safeSpawnModule = await import("./safeSpawn.js"); - safeSpawnSync = safeSpawnModule.safeSpawnSync; safeSpawn = safeSpawnModule.safeSpawn; }); @@ -43,67 +55,204 @@ describe("safeSpawn", () => { mock.reset(); }); - // Helper to run either sync or async variant - async function runSafeSpawn(variant, command, args, options) { - if (variant === "sync") { - return safeSpawnSync(command, args, options); - } else { - return await safeSpawn(command, args, options); - } - } + it("should pass basic command and arguments correctly", async () => { + await safeSpawn("echo", ["hello"]); - for (let variant of ["sync", "async"]) { - it(`should pass basic command and arguments correctly (${variant})`, async () => { - await runSafeSpawn(variant, "echo", ["hello"]); + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, "echo hello"); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); - assert.strictEqual(spawnCalls.length, 1); - assert.strictEqual(spawnCalls[0].command, "echo hello"); - assert.strictEqual(spawnCalls[0].options.shell, true); + it("should escape arguments containing spaces", async () => { + await safeSpawn("echo", ["hello world"]); + + assert.strictEqual(spawnCalls.length, 1); + // Argument should be escaped to prevent shell interpretation + assert.strictEqual(spawnCalls[0].command, 'echo "hello world"'); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); + + it("should prevent shell injection attacks", async () => { + await safeSpawn("ls", ["; rm test123.txt"]); + + assert.strictEqual(spawnCalls.length, 1); + // Malicious command should be escaped to prevent execution + assert.strictEqual(spawnCalls[0].command, 'ls "; rm test123.txt"'); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); + + it("should escape single quotes in arguments", async () => { + await safeSpawn("echo", ["don't break"]); + + assert.strictEqual(spawnCalls.length, 1); + // Single quote should be properly escaped with double quotes + assert.strictEqual(spawnCalls[0].command, 'echo "don\'t break"'); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); + + it("should handle double quotes with simpler escaping", async () => { + await safeSpawn("echo", ['say "hello"']); + + assert.strictEqual(spawnCalls.length, 1); + // If we switch to double quotes, this should be: "say \"hello\"" + assert.strictEqual(spawnCalls[0].command, 'echo "say \\"hello\\""'); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); + + it("should not escape arguments with only safe characters", async () => { + await safeSpawn("npm", ["install", "axios", "--save"]); + + assert.strictEqual(spawnCalls.length, 1); + // Safe arguments (alphanumeric, dash, underscore, dot, slash) shouldn't be quoted + assert.strictEqual(spawnCalls[0].command, "npm install axios --save"); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); + + it(`should escape ampersand character`, async () => { + await safeSpawn("npx", ["cypress", "run", "--env", "password=foo&bar"]); + + assert.strictEqual(spawnCalls.length, 1); + // & should be escaped by wrapping the arg in quotes + assert.strictEqual( + spawnCalls[0].command, + 'npx cypress run --env "password=foo&bar"' + ); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); + + it("should escape dollar signs to prevent variable expansion", async () => { + await safeSpawn("echo", ["$HOME/test"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'echo "\\$HOME/test"'); + }); + + it("should escape backticks to prevent command substitution", async () => { + await safeSpawn("echo", ["file`whoami`.txt"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'echo "file\\`whoami\\`.txt"'); + }); + + it("should escape backslashes properly", async () => { + await safeSpawn("echo", ["path\\with\\backslash"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual( + spawnCalls[0].command, + 'echo "path\\\\with\\\\backslash"' + ); + }); + + it("should handle multiple special characters in one argument", async () => { + await safeSpawn("cmd", ['test "quoted" $var `cmd` & more']); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual( + spawnCalls[0].command, + 'cmd "test \\"quoted\\" \\$var \\`cmd\\` & more"' + ); + }); + + it("should handle pipe character", async () => { + await safeSpawn("echo", ["foo|bar"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'echo "foo|bar"'); + }); + + it("should handle parentheses", async () => { + await safeSpawn("echo", ["(test)"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'echo "(test)"'); + }); + + it("should handle angle brackets for redirection", async () => { + await safeSpawn("echo", ["foo>output.txt"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'echo "foo>output.txt"'); + }); + + it("should handle wildcard characters", async () => { + await safeSpawn("echo", ["*.txt"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'echo "*.txt"'); + }); + + it("should handle multiple arguments with mixed escaping needs", async () => { + await safeSpawn("cmd", ["safe", "needs space", "$dangerous", "also-safe"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual( + spawnCalls[0].command, + 'cmd safe "needs space" "\\$dangerous" also-safe' + ); + }); + + it("should reject command names with special characters", async () => { + await assert.rejects(async () => await safeSpawn("npm; echo hacked", []), { + message: "Invalid command name: npm; echo hacked", }); + }); - it(`should escape arguments containing spaces (${variant})`, async () => { - await runSafeSpawn(variant, "echo", ["hello world"]); - - assert.strictEqual(spawnCalls.length, 1); - // Argument should be escaped to prevent shell interpretation - assert.strictEqual(spawnCalls[0].command, 'echo "hello world"'); - assert.strictEqual(spawnCalls[0].options.shell, true); + it("should reject command names with spaces", async () => { + await assert.rejects(async () => await safeSpawn("npm install", []), { + message: "Invalid command name: npm install", }); + }); - it(`should prevent shell injection attacks (${variant})`, async () => { - await runSafeSpawn(variant, "ls", ["; rm test123.txt"]); - - assert.strictEqual(spawnCalls.length, 1); - // Malicious command should be escaped to prevent execution - assert.strictEqual(spawnCalls[0].command, 'ls "; rm test123.txt"'); - assert.strictEqual(spawnCalls[0].options.shell, true); + it("should reject command names with slashes", async () => { + await assert.rejects(async () => await safeSpawn("../../malicious", []), { + message: "Invalid command name: ../../malicious", }); + }); - it(`should escape single quotes in arguments (${variant})`, async () => { - await runSafeSpawn(variant, "echo", ["don't break"]); + it("should accept valid command names with letters, numbers, underscores and hyphens", async () => { + await safeSpawn("valid_command-123", []); - assert.strictEqual(spawnCalls.length, 1); - // Single quote should be properly escaped with double quotes - assert.strictEqual(spawnCalls[0].command, 'echo "don\'t break"'); - assert.strictEqual(spawnCalls[0].options.shell, true); - }); + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, "valid_command-123"); + }); - it(`should handle double quotes with simpler escaping (${variant})`, async () => { - await runSafeSpawn(variant, "echo", ['say "hello"']); + it("should handle Python version specifiers with comparison operators on Windows", async () => { + os = "win32"; + await safeSpawn("pip3", ["install", "Jinja2>=3.1,<3.2"]); - assert.strictEqual(spawnCalls.length, 1); - // If we switch to double quotes, this should be: "say \"hello\"" - assert.strictEqual(spawnCalls[0].command, 'echo "say \\"hello\\""'); - assert.strictEqual(spawnCalls[0].options.shell, true); - }); + assert.strictEqual(spawnCalls.length, 1); + // On Windows, args are built into a command string with proper escaping + assert.strictEqual(spawnCalls[0].command, 'pip3 install "Jinja2>=3.1,<3.2"'); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); - it(`should not escape arguments with only safe characters (${variant})`, async () => { - await runSafeSpawn(variant, "npm", ["install", "axios", "--save"]); + it("should handle Python version specifiers with comparison operators on Unix", async () => { + os = "darwin"; // or "linux" + await safeSpawn("pip3", ["install", "Jinja2>=3.1,<3.2"]); - assert.strictEqual(spawnCalls.length, 1); - // Safe arguments (alphanumeric, dash, underscore, dot, slash) shouldn't be quoted - assert.strictEqual(spawnCalls[0].command, "npm install axios --save"); - assert.strictEqual(spawnCalls[0].options.shell, true); - }); - } -}); \ No newline at end of file + assert.strictEqual(spawnCalls.length, 1); + // On Unix, resolves full path and passes args as array (no shell interpretation) + assert.strictEqual(spawnCalls[0].command, "/usr/bin/pip3"); + assert.deepStrictEqual(spawnCalls[0].args, ["install", "Jinja2>=3.1,<3.2"]); + assert.deepStrictEqual(spawnCalls[0].options, {}); + }); + + it("should handle Python not-equal version specifiers", async () => { + os = "win32"; + await safeSpawn("pip3", ["install", "idna!=3.5,>=3.0"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'pip3 install "idna!=3.5,>=3.0"'); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); + + it("should handle Python extras with square brackets", async () => { + os = "win32"; + await safeSpawn("pip3", ["install", "requests[socks]"]); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, 'pip3 install "requests[socks]"'); + assert.strictEqual(spawnCalls[0].options.shell, true); + }); +}); diff --git a/packages/safe-chain/tsconfig.json b/packages/safe-chain/tsconfig.json new file mode 100644 index 0000000..c357bb1 --- /dev/null +++ b/packages/safe-chain/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["es2023"], + "module": "node16", + "strict": true, + "skipLibCheck": true, + "moduleResolution": "node16", + "allowJs": true, + "checkJs": true, + "noEmit": true, + "resolveJsonModule": true + }, + "include": [ + "src/**/*.js", + "bin/**/*.js" + ], + "exclude": [ + "node_modules", + "src/**/*.spec.js" + ] +} diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index 483f03a..ec1af3c 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -60,6 +60,26 @@ export class DockerTestContainer { } } + dockerExec(command, daemon = false) { + if (!this.isRunning) { + throw new Error("Container is not running"); + } + + try { + const dockerExecCommand = `docker exec ${daemon ? "-d " : " "}${ + this.containerName + } bash -c "${command}"`; + const output = execSync(dockerExecCommand, { + encoding: "utf-8", + stdio: "pipe", + timeout: 10000, + }); + return output; + } catch (error) { + throw new Error(`Failed to execute command: ${error.message}`); + } + } + async openShell(shell) { let ptyProcess = pty.spawn( "docker", @@ -96,9 +116,11 @@ 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"); resolve({ allData, output: parseShellOutput(allData), command }); ptyProcess.removeListener("data", handleInput); - }, 10000); + }, 15000); function handleInput(data) { allData.push(data); diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index 484f5fe..8c3b0a5 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -25,6 +25,7 @@ ARG NODE_VERSION=latest ARG NPM_VERSION=latest ARG YARN_VERSION=latest ARG PNPM_VERSION=latest +ARG PYTHON_VERSION=3 SHELL ["/bin/bash", "-c"] ENV BASH_ENV=~/.bashrc @@ -49,6 +50,27 @@ RUN volta install pnpm@${PNPM_VERSION} # Install Bun RUN curl -fsSL https://bun.sh/install | bash +# Install Python and pip (pip3) +RUN apt-get update && apt-get install -y python${PYTHON_VERSION} python3-pip && \ + ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python3 && \ + ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python && \ + ln -sf /usr/bin/pip3 /usr/local/bin/pip3 && \ + cat <<'EOF' > /usr/lib/python3/dist-packages/pip3.py +""" +Shim module so 'python[3] -m pip3 …' resolves to pip's CLI entry point. +""" +try: + import pip._internal + pip._internal.main() +except Exception as exc: + print("pip3 module shim failed:", exc) + raise +EOF + +# Install uv +RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ + echo 'source $HOME/.local/bin/env' >> ~/.bashrc + # Copy and install Safe chain COPY --from=builder /app/*.tgz /pkgs/ RUN npm install -g /pkgs/*.tgz diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index 8dea93b..4f24b7d 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -31,7 +31,7 @@ describe("E2E: bun coverage", () => { const result = await shell.runCommand("bun i axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js index dc1c23f..18ee789 100644 --- a/test/e2e/npm-ci.e2e.spec.js +++ b/test/e2e/npm-ci.e2e.spec.js @@ -36,7 +36,7 @@ describe("E2E: npm coverage using PATH", () => { const result = await shell.runCommand("npm i axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index c744835..b2b7211 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -31,7 +31,7 @@ describe("E2E: npm coverage", () => { const result = await shell.runCommand("npm i axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -62,48 +62,24 @@ describe("E2E: npm coverage", () => { it(`safe-chain blocks download of malicious packages already in package.json`, async () => { const shell = await container.openShell("zsh"); - const npmVersion = (await shell.runCommand("npm --version")).output.trim(); - const majorVersion = parseInt(npmVersion.split(".")[0]); - const minorVersion = parseInt(npmVersion.split(".")[1]); - const isBelow10_4 = - majorVersion < 10 || (majorVersion === 10 && minorVersion < 4); await shell.runCommand( 'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json' ); var result = await shell.runCommand("npm install"); - if (isBelow10_4) { - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("- safe-chain-test"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes( - "Exiting without installing malicious packages." - ), - `Output did not include expected text. Output was:\n${result.output}` - ); - } else { - assert.ok( - result.output.includes("Malicious changes detected:"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("- safe-chain-test"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes( - "Exiting without installing malicious packages." - ), - `Output did not include expected text. Output was:\n${result.output}` - ); - } + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); }); it("safe-chain blocks npx from executing malicious packages", async () => { diff --git a/test/e2e/pip-ci.e2e.spec.js b/test/e2e/pip-ci.e2e.spec.js new file mode 100644 index 0000000..63bfd90 --- /dev/null +++ b/test/e2e/pip-ci.e2e.spec.js @@ -0,0 +1,203 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: safe-chain setup-ci command for pip/pip3", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + describe("E2E: pip CI support", () => { + it("does not intercept python3 --version", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("python3 --version"); + assert.ok( + result.output.match(/Python \d+\.\d+\.\d+/), + `Output was: ${result.output}` + ); + assert.ok( + !result.output.includes("Safe-chain"), + "Safe Chain should not intercept generic python3 command" + ); + }); + + it("does not intercept python3 -c 'print(\"hello\")'", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("python3 -c 'print(\"hello\")'"); + assert.ok( + result.output.includes("hello"), + `Output was: ${result.output}` + ); + assert.ok( + !result.output.includes("Safe-chain"), + "Safe Chain should not intercept generic python3 -c command" + ); + }); + + it("does not intercept python3 test.py", async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand("echo 'print(\"Hello from test.py!\")' > test.py"); + const result = await shell.runCommand("python3 test.py"); + assert.ok( + result.output.includes("Hello from test.py!"), + `Output was: ${result.output}` + ); + assert.ok( + !result.output.includes("Safe-chain"), + "Safe Chain should not intercept generic python3 script execution" + ); + }); + + it("does not intercept python test.py", async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand("echo 'print(\"Hello from test.py!\")' > test.py"); + const result = await shell.runCommand("python test.py"); + assert.ok( + result.output.includes("Hello from test.py!"), + `Output was: ${result.output}` + ); + assert.ok( + !result.output.includes("Safe-chain"), + "Safe Chain should not intercept generic python script execution" + ); + }); + }); + + for (let shell of ["bash", "zsh"]) { + it(`safe-chain setup-ci wraps pip3 command with PATH shim after installation for ${shell}`, async () => { + // Setup safe-chain CI shims + const installationShell = await container.openShell(shell); + await installationShell.runCommand( + "safe-chain setup-ci --include-python" + ); + + // Add $HOME/.safe-chain/shims to PATH for subsequent shells + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" + ); + + const projectShell = await container.openShell(shell); + // Use --break-system-packages to avoid Debian/Ubuntu external management restrictions + const result = await projectShell.runCommand( + "pip3 install --break-system-packages certifi" + ); + + const hasExpectedOutput = result.output.includes("no malware found."); + assert.ok( + hasExpectedOutput, + hasExpectedOutput + ? "Expected pip3 command to be wrapped by safe-chain" + : `Output did not contain \"no malware found.\": \n${result.output}` + ); + }); + + it(`setup-ci routes python -m pip through safe-chain for ${shell}`, async () => { + const installationShell = await container.openShell(shell); + await installationShell.runCommand( + "safe-chain setup-ci --include-python" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" + ); + + const projectShell = await container.openShell(shell); + const result = await projectShell.runCommand( + "python -m pip install --break-system-packages certifi" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not contain scan message. Output was:\n${result.output}` + ); + }); + + it(`setup-ci routes python3 -m pip through safe-chain for ${shell}`, async () => { + const installationShell = await container.openShell(shell); + await installationShell.runCommand( + "safe-chain setup-ci --include-python" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" + ); + + const projectShell = await container.openShell(shell); + const result = await projectShell.runCommand( + "python3 -m pip install --break-system-packages certifi" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not contain scan message. Output was:\n${result.output}` + ); + }); + + it(`setup-ci routes pip through safe-chain for ${shell}`, async () => { + const installationShell = await container.openShell(shell); + await installationShell.runCommand( + "safe-chain setup-ci --include-python" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" + ); + + const projectShell = await container.openShell(shell); + const result = await projectShell.runCommand( + "pip install --break-system-packages certifi" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not contain scan message. Output was:\n${result.output}` + ); + }); + + it(`setup-ci routes pip3 through safe-chain for ${shell}`, async () => { + const installationShell = await container.openShell(shell); + await installationShell.runCommand( + "safe-chain setup-ci --include-python" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" + ); + + const projectShell = await container.openShell(shell); + const result = await projectShell.runCommand( + "pip3 install --break-system-packages certifi" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not contain scan message. Output was:\n${result.output}` + ); + }); + } +}); diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js new file mode 100644 index 0000000..f4579ab --- /dev/null +++ b/test/e2e/pip.e2e.spec.js @@ -0,0 +1,816 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: pip coverage", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + // Run a new Docker container for each test + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup --include-python"); + }); + + afterEach(async () => { + // Stop and clean up the container after each test + if (container) { + await container.stop(); + container = null; + } + }); + + it(`successfully installs known safe packages with pip3`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "pip3 install --break-system-packages requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pip3 download`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("pip3 download requests"); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pip3 .whl`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("pip3 wheel requests"); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pip3 install --dry-run is respected by scanner`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "pip3 install --dry-run --break-system-packages requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pip3 install with extras such as requests[socks]`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + 'pip3 install --break-system-packages "requests[socks]==2.32.3"' + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pip3 install with range version specifier`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + 'pip3 install --break-system-packages "Jinja2>=3.1,<3.2"' + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`python3 -m pip install routes through safe-chain`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "python3 -m pip install --break-system-packages requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`python3 -m pip download routes through safe-chain`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("python3 -m pip download requests"); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks installation of malicious Python packages`, async () => { + const shell = await container.openShell("zsh"); + // Clear pip cache to ensure network download through proxy + await shell.runCommand("pip3 cache purge"); + + const result = await shell.runCommand( + "pip3 install --break-system-packages safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("safe_chain_pi_test@0.0.1"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + const listResult = await shell.runCommand("pip3 list"); + assert.ok( + !listResult.output.includes("safe-chain-pi-test"), + `Malicious package was installed despite safe-chain protection. Output of 'pip3 list' was:\n${listResult.output}` + ); + }); + + it(`python -m pip routes to aikido-pip (uses pip command)`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "python -m pip install --break-system-packages requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + // Verify it completed successfully (would fail if routing was incorrect) + assert.ok( + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), + `Installation did not succeed. Output was:\n${result.output}` + ); + }); + + it(`python -m pip3 routes to aikido-pip3 (uses pip3 command)`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "python -m pip3 install --break-system-packages requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + // Verify it completed successfully (would fail if routing was incorrect) + assert.ok( + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), + `Installation did not succeed. Output was:\n${result.output}` + ); + }); + + it(`python3 -m pip routes to aikido-pip3 (uses pip3 command)`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "python3 -m pip install --break-system-packages requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + // Verify it completed successfully (would fail if routing was incorrect) + assert.ok( + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), + `Installation did not succeed. Output was:\n${result.output}` + ); + }); + + it(`python3 -m pip3 routes to aikido-pip3 (uses pip3 command)`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "python3 -m pip3 install --break-system-packages requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + // Verify it completed successfully (would fail if routing was incorrect) + assert.ok( + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), + `Installation did not succeed. Output was:\n${result.output}` + ); + }); + + it(`pip3 can install from GitHub URL using the CA bundle`, async () => { + const shell = await container.openShell("zsh"); + // Install a simple package from GitHub - this should use TCP tunnel, not MITM + // Using a popular, small package for testing + const result = await shell.runCommand( + "pip3 install --break-system-packages git+https://github.com/psf/requests.git@v2.32.3" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + // Verify installation succeeded (would fail if certificate validation via env CA bundle broke) + assert.ok( + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), + `Installation from GitHub failed - CA bundle may not be working. Output was:\n${result.output}` + ); + + // Verify package was actually installed + const listResult = await shell.runCommand("pip3 list"); + assert.ok( + listResult.output.includes("requests"), + `Package from GitHub was not installed. Output was:\n${listResult.output}` + ); + }); + + it(`pip3 successfully validates certificates for HTTPS downloads`, async () => { + const shell = await container.openShell("zsh"); + // Clear cache to force network download through proxy + await shell.runCommand("pip3 cache purge"); + + const result = await shell.runCommand( + "pip3 install --break-system-packages certifi" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + // Verify successful installation (would fail with SSL/certificate errors if the env CA bundle wasn't working) + assert.ok( + result.output.includes("Successfully installed"), + `Installation should succeed with proper certificate validation. Output was:\n${result.output}` + ); + + // Should NOT contain SSL or certificate errors + assert.ok( + !result.output.match( + /SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i + ), + `Should not have SSL/certificate errors. Output was:\n${result.output}` + ); + }); + + it(`pip3 handles external HTTPS correctly (e.g., downloading from CDN)`, async () => { + const shell = await container.openShell("zsh"); + // Test installing from a direct HTTPS URL (not a registry) + // This validates that non-registry HTTPS traffic works with our env-provided CA bundle + const result = await shell.runCommand( + "pip3 install --break-system-packages https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + // Since this is from pythonhosted.org, it should be MITM'd by safe-chain + // But the certificate validation should still work + assert.ok( + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), + `Installation from direct HTTPS URL failed. Output was:\n${result.output}` + ); + }); + + it(`pip3 can install from alternate PyPI mirror (tunneled, not MITM)`, async () => { + const shell = await container.openShell("zsh"); + // Use Test PyPI which is NOT in knownPipRegistries + // This tests tunneled HTTPS with our env-provided CA bundle (Safe Chain CA + Mozilla + Node roots) + // If the CA bundle doesn't include public roots, this will fail with CERTIFICATE_VERIFY_FAILED + const result = await shell.runCommand( + "pip3 install --break-system-packages --index-url https://test.pypi.org/simple certifi" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + // Should succeed if CA bundle properly handles tunneled hosts + assert.ok( + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), + `Installation from Test PyPI failed. This may indicate the CA bundle lacks public roots. Output was:\n${result.output}` + ); + + // Should NOT contain certificate verification errors + assert.ok( + !result.output.match( + /SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i + ), + `Should not have SSL/certificate errors for tunneled hosts. Output was:\n${result.output}` + ); + }); + + it(`pip3 install requests with --safe-chain-logging=verbose`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "pip3 install --break-system-packages requests --safe-chain-logging=verbose" + ); + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pip3 config set should work and persist configuration`, async () => { + const shell = await container.openShell("zsh"); + + // Set a config value + const setResult = await shell.runCommand( + "pip3 config set global.timeout 60" + ); + + assert.ok( + setResult.output.includes("Writing to"), + `pip3 config set should write config. Output was:\n${setResult.output}` + ); + + // Verify it was persisted by reading it back + const getResult = await shell.runCommand( + "pip3 config get global.timeout" + ); + + assert.ok( + getResult.output.includes("60"), + `Config value should be 60. Output was:\n${getResult.output}` + ); + }); + + it(`pip3 config list should show user configuration`, async () => { + const shell = await container.openShell("zsh"); + + // Set a value first + await shell.runCommand("pip3 config set global.timeout 90"); + + // List config + const listResult = await shell.runCommand("pip3 config list"); + + assert.ok( + listResult.output.includes("timeout") && listResult.output.includes("90"), + `Config list should show timeout=90. Output was:\n${listResult.output}` + ); + }); + + it(`pip3 config unset should remove configuration`, async () => { + const shell = await container.openShell("zsh"); + + // Set a value + await shell.runCommand("pip3 config set global.timeout 120"); + + // Verify it exists + const getResult = await shell.runCommand("pip3 config get global.timeout"); + assert.ok(getResult.output.includes("120")); + + // Unset it + const unsetResult = await shell.runCommand("pip3 config unset global.timeout"); + assert.ok( + unsetResult.output.includes("Writing to"), + `pip3 config unset should write config. Output was:\n${unsetResult.output}` + ); + }); + + it(`pip3 cache dir should return cache directory path`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand("pip3 cache dir"); + + // Should output a directory path + assert.ok( + result.output.includes("/") && result.output.includes("cache"), + `Should output a cache directory path. Output was:\n${result.output}` + ); + }); + + it(`pip3 cache info should show cache information`, async () => { + const shell = await container.openShell("zsh"); + + // Install something first to populate cache + await shell.runCommand("pip3 install --break-system-packages certifi"); + + const result = await shell.runCommand("pip3 cache info"); + + // Output should contain cache-related information + assert.ok( + result.output.match(/cache|wheel|http/i), + `Should output cache information. Output was:\n${result.output}` + ); + }); + + it(`pip3 cache list should list cached packages`, async () => { + const shell = await container.openShell("zsh"); + + // Download a package to ensure something is in cache + await shell.runCommand("pip3 download certifi"); + + const result = await shell.runCommand("pip3 cache list certifi"); + + // Should show either cached wheels or "No locally built wheels" + assert.ok( + result.output.includes("certifi") || result.output.includes("No locally built"), + `Should output cache list information. Output was:\n${result.output}` + ); + }); + + it(`pip3 debug should output debug information`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand("pip3 debug"); + + // Should contain debug information about pip environment + assert.ok( + result.output.match(/pip version|sys\.version|sys\.executable/i), + `Should output debug information. Output was:\n${result.output}` + ); + + // Should NOT show safe-chain's temporary config file in the debug output + assert.ok( + !result.output.includes("safe-chain-pip-"), + `Debug output should not reference safe-chain temp config. Output was:\n${result.output}` + ); + }); + + it(`pip3 completion should generate shell completion script`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand("pip3 completion --zsh"); + + // Should output shell completion code + assert.ok( + result.output.includes("compdef") || result.output.includes("_pip") || result.output.includes("pip completion"), + `Should output completion code. Output was:\n${result.output}` + ); + }); + + it(`pip3 install still works after config operations`, async () => { + const shell = await container.openShell("zsh"); + + // Perform config operations + await shell.runCommand("pip3 config set global.timeout 60"); + await shell.runCommand("pip3 cache dir"); + + // Now install should still work with malware protection + const result = await shell.runCommand( + "pip3 install --break-system-packages certifi" + ); + + assert.ok( + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), + `Install should succeed after config operations. Output was:\n${result.output}` + ); + + assert.ok( + result.output.includes("no malware found."), + `Should still scan for malware. Output was:\n${result.output}` + ); + }); + + it(`pip3 download works after configuring pip settings`, async () => { + const shell = await container.openShell("zsh"); + + // Configure pip with timeout and extra index URL + const configTimeout = await shell.runCommand("pip3 config set global.timeout 60"); + assert.ok( + configTimeout.output.includes("Writing to"), + `Config set should succeed. Output was:\n${configTimeout.output}` + ); + + const configIndex = await shell.runCommand( + "pip3 config set global.extra-index-url https://pypi.org/simple" + ); + assert.ok( + configIndex.output.includes("Writing to"), + `Config set should succeed. Output was:\n${configIndex.output}` + ); + + // Verify config persisted + const listConfig = await shell.runCommand("pip3 config list"); + assert.ok( + listConfig.output.includes("timeout") && listConfig.output.includes("60"), + `Config should show timeout=60. Output was:\n${listConfig.output}` + ); + assert.ok( + listConfig.output.includes("extra-index-url") && listConfig.output.includes("pypi.org"), + `Config should show extra-index-url. Output was:\n${listConfig.output}` + ); + + // Now download packages with the configured settings + const downloadResult = await shell.runCommand( + "pip3 download -d /tmp/packages requests certifi" + ); + + assert.ok( + downloadResult.output.includes("no malware found."), + `Should scan for malware. Output was:\n${downloadResult.output}` + ); + + // Verify downloads succeeded + assert.ok( + downloadResult.output.includes("Saved") || downloadResult.output.includes("requests"), + `Download should succeed with configured settings. Output was:\n${downloadResult.output}` + ); + assert.ok( + downloadResult.output.includes("certifi"), + `Should download certifi. Output was:\n${downloadResult.output}` + ); + }); + + // Tests for python/python3 bypass (non-pip invocations should go directly without safe-chain) + + it(`python3 --version should bypass safe-chain and work normally`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("python3 --version"); + + // Should output Python version + assert.ok( + result.output.match(/Python 3\.\d+\.\d+/), + `Should output Python version. Output was:\n${result.output}` + ); + + // Should NOT go through safe-chain proxy + assert.ok( + !result.output.includes("Safe-chain"), + `python3 --version should not go through safe-chain. Output was:\n${result.output}` + ); + }); + + it(`python --version should bypass safe-chain and work normally`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("python --version"); + + // Should output Python version + assert.ok( + result.output.match(/Python \d+\.\d+\.\d+/), + `Should output Python version. Output was:\n${result.output}` + ); + + // Should NOT go through safe-chain + assert.ok( + !result.output.includes("Safe-chain"), + `python --version should not go through safe-chain. Output was:\n${result.output}` + ); + }); + + it(`python3 -c "print('hello')" should bypass safe-chain and execute code`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("python3 -c \"print('hello world')\""); + + // Should execute Python code + assert.ok( + result.output.includes("hello world"), + `Should execute Python code. Output was:\n${result.output}` + ); + + // Should NOT go through safe-chain + assert.ok( + !result.output.includes("Safe-chain"), + `python3 -c should not go through safe-chain. Output was:\n${result.output}` + ); + }); + + it(`python -c should bypass safe-chain and execute code`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("python -c \"import sys; print(sys.version)\""); + + // Should execute Python code and print version + assert.ok( + result.output.match(/\d+\.\d+\.\d+/), + `Should execute Python code. Output was:\n${result.output}` + ); + + // Should NOT go through safe-chain + assert.ok( + !result.output.includes("Safe-chain"), + `python -c should not go through safe-chain. Output was:\n${result.output}` + ); + }); + + it(`python3 script.py should bypass safe-chain and execute script`, async () => { + const shell = await container.openShell("zsh"); + + // Create a simple Python script + await shell.runCommand("echo \"print('script executed')\" > /tmp/test_script.py"); + + const result = await shell.runCommand("python3 /tmp/test_script.py"); + + // Should execute the script + assert.ok( + result.output.includes("script executed"), + `Should execute Python script. Output was:\n${result.output}` + ); + + // Should NOT go through safe-chain + assert.ok( + !result.output.includes("Safe-chain"), + `python3 script.py should not go through safe-chain. Output was:\n${result.output}` + ); + }); + + it(`python script.py should bypass safe-chain and execute script`, async () => { + const shell = await container.openShell("zsh"); + + // Create a simple Python script + await shell.runCommand("echo \"print('python2/3 compatible')\" > /tmp/test_script2.py"); + + const result = await shell.runCommand("python /tmp/test_script2.py"); + + // Should execute the script + assert.ok( + result.output.includes("python2/3 compatible"), + `Should execute Python script. Output was:\n${result.output}` + ); + + // Should NOT go through safe-chain + assert.ok( + !result.output.includes("Safe-chain"), + `python script.py should not go through safe-chain. Output was:\n${result.output}` + ); + }); + + it(`python3 -m json.tool should bypass safe-chain (module other than pip)`, async () => { + const shell = await container.openShell("zsh"); + + // json.tool is a built-in Python module for formatting JSON + const result = await shell.runCommand("echo '{\"test\": 123}' | python3 -m json.tool"); + + // Should format JSON + assert.ok( + result.output.includes('"test"') && result.output.includes('123'), + `Should format JSON. Output was:\n${result.output}` + ); + + // Should NOT go through safe-chain + assert.ok( + !result.output.includes("Safe-chain"), + `python3 -m json.tool should not go through safe-chain. Output was:\n${result.output}` + ); + }); + + it(`python3 -m http.server should bypass safe-chain (module other than pip)`, async () => { + const shell = await container.openShell("zsh"); + + // Start http.server in background and kill it immediately + // We just want to verify it starts without safe-chain interference + const result = await shell.runCommand("timeout 1 python3 -m http.server 8999 || true"); + + // Should NOT go through safe-chain + assert.ok( + !result.output.includes("Safe-chain"), + `python3 -m http.server should not go through safe-chain. Output was:\n${result.output}` + ); + + // Should either start the server or timeout (both are success for bypass test) + assert.ok( + result.output.includes("Serving HTTP") || result.output === "" || result.exitCode !== undefined, + `Should attempt to start server. Output was:\n${result.output}` + ); + }); + + it(`python3 interactive mode should bypass safe-chain`, async () => { + const shell = await container.openShell("zsh"); + + // Run python3 with a command piped to stdin to simulate interactive mode + const result = await shell.runCommand("echo 'print(2+2)' | python3"); + + // Should execute the command + assert.ok( + result.output.includes("4"), + `Should execute Python interactively. Output was:\n${result.output}` + ); + + // Should NOT go through safe-chain + assert.ok( + !result.output.includes("Safe-chain"), + `python3 interactive should not go through safe-chain. Output was:\n${result.output}` + ); + }); + + it(`python3 with no arguments should bypass safe-chain`, async () => { + const shell = await container.openShell("zsh"); + + // Python with no args goes to interactive REPL, pipe exit command + const result = await shell.runCommand("echo 'exit()' | python3"); + + // Should NOT go through safe-chain + assert.ok( + !result.output.includes("Safe-chain"), + `python3 with no args should not go through safe-chain. Output was:\n${result.output}` + ); + }); + + it(`python3 -m venv should bypass safe-chain (venv module)`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand("python3 -m venv /tmp/test_venv"); + + // Should create venv without safe-chain + assert.ok( + !result.output.includes("Safe-chain"), + `python3 -m venv should not go through safe-chain. Output was:\n${result.output}` + ); + + // Verify venv was created + const checkVenv = await shell.runCommand("test -f /tmp/test_venv/bin/python3 && echo 'exists'"); + assert.ok( + checkVenv.output.includes("exists"), + `venv should be created. Output was:\n${checkVenv.output}` + ); + }); + + it(`python3 -m pytest should bypass safe-chain (pytest module)`, async () => { + const shell = await container.openShell("zsh"); + + // pytest may not be installed, but the bypass should work regardless + const result = await shell.runCommand("python3 -m pytest --version 2>&1 || true"); + + // Should NOT go through safe-chain + assert.ok( + !result.output.includes("Safe-chain"), + `python3 -m pytest should not go through safe-chain. Output was:\n${result.output}` + ); + }); + + it(`python3 -m site should bypass safe-chain (site module)`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand("python3 -m site"); + + // Should output site information + assert.ok( + result.output.includes("sys.path") || result.output.includes("USER_BASE"), + `Should output site information. Output was:\n${result.output}` + ); + + // Should NOT go through safe-chain + assert.ok( + !result.output.includes("Safe-chain"), + `python3 -m site should not go through safe-chain. Output was:\n${result.output}` + ); + }); + + // Verify that -m pip* still goes through safe-chain (sanity check) + + it(`python3 -m pip DOES go through safe-chain (sanity check)`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "python3 -m pip install --break-system-packages certifi" + ); + + // SHOULD go through safe-chain + assert.ok( + result.output.includes("Safe-chain") || result.output.includes("no malware found"), + `python3 -m pip SHOULD go through safe-chain. Output was:\n${result.output}` + ); + }); + + it(`python3 -m pip3 DOES go through safe-chain (sanity check)`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "python3 -m pip3 install --break-system-packages certifi" + ); + + // SHOULD go through safe-chain + assert.ok( + result.output.includes("Safe-chain") || result.output.includes("no malware found"), + `python3 -m pip3 SHOULD go through safe-chain. Output was:\n${result.output}` + ); + }); + + it(`python -m pip DOES go through safe-chain (sanity check)`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "python -m pip install --break-system-packages certifi" + ); + + // SHOULD go through safe-chain + assert.ok( + result.output.includes("Safe-chain") || result.output.includes("no malware found"), + `python -m pip SHOULD go through safe-chain. Output was:\n${result.output}` + ); + }); +}); diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js index 339a5e0..6b92399 100644 --- a/test/e2e/pnpm-ci.e2e.spec.js +++ b/test/e2e/pnpm-ci.e2e.spec.js @@ -36,7 +36,7 @@ describe("E2E: pnpm coverage", () => { const result = await shell.runCommand("pnpm add axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index c0187d7..944530c 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -31,7 +31,7 @@ describe("E2E: pnpm coverage", () => { const result = await shell.runCommand("pnpm add axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/safe-chain-cli-python.e2e.spec.js b/test/e2e/safe-chain-cli-python.e2e.spec.js new file mode 100644 index 0000000..5c84945 --- /dev/null +++ b/test/e2e/safe-chain-cli-python.e2e.spec.js @@ -0,0 +1,104 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: safe-chain CLI python/pip support", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + // Note: We do NOT run 'safe-chain setup' here. + // We want to test the 'safe-chain' CLI command directly. + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + it("safe-chain pip3 install routes through proxy", async () => { + const shell = await container.openShell("zsh"); + // Invoke safe-chain directly with pip3 command + const result = await shell.runCommand( + "safe-chain pip3 install --break-system-packages requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), + `Installation failed. Output was:\n${result.output}` + ); + }); + + it("safe-chain python3 -m pip install routes through proxy", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "safe-chain python3 -m pip install --break-system-packages requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it("safe-chain python3 script.py bypasses proxy", async () => { + const shell = await container.openShell("zsh"); + + // Create a simple script + await shell.runCommand("echo \"print('direct execution')\" > /tmp/test.py"); + + const result = await shell.runCommand("safe-chain python3 /tmp/test.py"); + + // Should execute the script + assert.ok( + result.output.includes("direct execution"), + `Script execution failed. Output was:\n${result.output}` + ); + + // Should NOT show safe-chain logs + assert.ok( + !result.output.includes("Safe-chain"), + `Should have bypassed safe-chain. Output was:\n${result.output}` + ); + }); + + it("safe-chain python3 --version bypasses proxy", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("safe-chain python3 --version"); + + assert.ok( + result.output.match(/Python 3\.\d+\.\d+/), + `Should show python version. Output was:\n${result.output}` + ); + assert.ok( + !result.output.includes("Safe-chain"), + `Should have bypassed safe-chain. Output was:\n${result.output}` + ); + }); + + it("safe-chain blocks malicious package via pip3", async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand("pip3 cache purge"); + + const result = await shell.runCommand( + "safe-chain pip3 install --break-system-packages safe-chain-pi-test" + ); + + 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 9356f88..f22f884 100644 --- a/test/e2e/setup-ci.e2e.spec.js +++ b/test/e2e/setup-ci.e2e.spec.js @@ -41,9 +41,7 @@ describe("E2E: safe-chain setup-ci command", () => { const projectShell = await container.openShell(shell); const result = await projectShell.runCommand("npm i axios"); - const hasExpectedOutput = result.output.includes( - "Scanning for malicious packages..." - ); + const hasExpectedOutput = result.output.includes("Safe-chain: Scanned"); assert.ok( hasExpectedOutput, hasExpectedOutput diff --git a/test/e2e/setup.teardown.e2e.spec.js b/test/e2e/setup.teardown.e2e.spec.js index c4a0c49..b5c58bb 100644 --- a/test/e2e/setup.teardown.e2e.spec.js +++ b/test/e2e/setup.teardown.e2e.spec.js @@ -31,9 +31,7 @@ describe("E2E: safe-chain setup command", () => { await projectShell.runCommand("cd /testapp"); const result = await projectShell.runCommand("npm i axios"); - const hasExpectedOutput = result.output.includes( - "Scanning for malicious packages..." - ); + const hasExpectedOutput = result.output.includes("Safe-chain: Scanned"); assert.ok( hasExpectedOutput, hasExpectedOutput diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js new file mode 100644 index 0000000..eae7c12 --- /dev/null +++ b/test/e2e/uv.e2e.spec.js @@ -0,0 +1,561 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: uv coverage", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + // Run a new Docker container for each test + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup --include-python"); + }); + + afterEach(async () => { + // Stop and clean up the container after each test + if (container) { + await container.stop(); + container = null; + } + }); + + it(`successfully installs known safe packages with uv pip install`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with specific version`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages requests==2.32.3" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with version specifiers (>=)`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + 'uv pip install --system --break-system-packages "Jinja2>=3.1"' + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with extras such as requests[socks]`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + 'uv pip install --system --break-system-packages "requests[socks]==2.32.3"' + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install multiple packages`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages requests certifi urllib3" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install from requirements file`, async () => { + const shell = await container.openShell("zsh"); + + // Create a requirements.txt file + await shell.runCommand("echo 'requests==2.32.3' > requirements.txt"); + await shell.runCommand("echo 'certifi>=2024.0.0' >> requirements.txt"); + + const result = await shell.runCommand( + "uv pip install --system --break-system-packages -r requirements.txt" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip sync with requirements file`, async () => { + const shell = await container.openShell("zsh"); + + // Create a requirements.txt file + await shell.runCommand("echo 'requests==2.32.3' > requirements-sync.txt"); + + const result = await shell.runCommand( + "uv pip sync --system --break-system-packages requirements-sync.txt" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks installation of malicious Python packages via uv`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand( + "uv pip install --system --break-system-packages safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("safe_chain_pi_test@0.0.1"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + const listResult = await shell.runCommand("uv pip list --system"); + assert.ok( + !listResult.output.includes("safe-chain-pi-test"), + `Malicious package was installed despite safe-chain protection. Output of 'uv pip list' was:\n${listResult.output}` + ); + }); + + it(`uv pip install from GitHub URL using the CA bundle`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages git+https://github.com/psf/requests.git@v2.32.3" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + // Verify installation succeeded (would fail if certificate validation via env CA bundle broke) + assert.ok( + result.output.includes("Installed") || + result.output.includes("installed"), + `Installation from GitHub failed - CA bundle may not be working. Output was:\n${result.output}` + ); + }); + + it(`uv pip successfully validates certificates for HTTPS downloads`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand( + "uv pip install --system --break-system-packages certifi" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + // Verify successful installation (would fail with SSL/certificate errors if the env CA bundle wasn't working) + assert.ok( + result.output.includes("Installed") || + result.output.includes("installed"), + `Installation should succeed with proper certificate validation. Output was:\n${result.output}` + ); + + // Should NOT contain SSL or certificate errors + assert.ok( + !result.output.match( + /SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i + ), + `Should not have SSL/certificate errors. Output was:\n${result.output}` + ); + }); + + it(`uv pip install from direct HTTPS wheel URL`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + assert.ok( + result.output.includes("Installed") || + result.output.includes("installed"), + `Installation from direct HTTPS URL failed. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --upgrade flag`, async () => { + const shell = await container.openShell("zsh"); + + // First install a package + await shell.runCommand("uv pip install --system --break-system-packages requests==2.31.0"); + + // Then upgrade it + const result = await shell.runCommand( + "uv pip install --system --break-system-packages --upgrade requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --no-deps flag`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages --no-deps requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --editable flag from local directory`, async () => { + const shell = await container.openShell("zsh"); + + // Create a simple package structure + await shell.runCommand("mkdir -p /tmp/test-pkg"); + await shell.runCommand("echo 'from setuptools import setup' > /tmp/test-pkg/setup.py"); + await shell.runCommand("echo \"setup(name='test-pkg', version='0.1.0')\" >> /tmp/test-pkg/setup.py"); + + const result = await shell.runCommand( + "uv pip install --system --break-system-packages -e /tmp/test-pkg" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip compile creates locked requirements`, async () => { + const shell = await container.openShell("zsh"); + + // Create an input requirements file + await shell.runCommand("echo 'requests' > requirements.in"); + + const result = await shell.runCommand( + "uv pip compile requirements.in" + ); + + // uv pip compile doesn't install packages, just resolves dependencies + // It should complete successfully and output resolved requirements + assert.ok( + result.output.includes("requests==") || result.output.includes("# via"), + `Output did not include compiled requirements. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --index-url for alternate registry`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages --index-url https://test.pypi.org/simple certifi" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + // Should succeed if CA bundle properly handles tunneled hosts + assert.ok( + result.output.includes("Installed") || + result.output.includes("installed"), + `Installation from Test PyPI failed. This may indicate the CA bundle lacks public roots. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --safe-chain-logging=verbose`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages requests --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with version range constraint`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + 'uv pip install --system --break-system-packages "requests>=2.31.0,<2.33.0"' + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip list shows installed packages`, async () => { + const shell = await container.openShell("zsh"); + + // Install a package first + await shell.runCommand("uv pip install --system --break-system-packages requests"); + + // Then list packages - this shouldn't trigger safe-chain scanning + const result = await shell.runCommand("uv pip list --system"); + + // List command should work without malware scanning + assert.ok( + result.output.includes("requests") || result.output.length > 0, + `Output did not show package list. Output was:\n${result.output}` + ); + }); + + it(`uv add installs package and updates project`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project and add package in same command + const result = await shell.runCommand( + "uv init test-project && cd test-project && uv add requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add with specific version`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-project-version"); + + const result = await shell.runCommand( + "cd test-project-version && uv add requests==2.32.3" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add --dev for development dependencies`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-project-dev"); + + const result = await shell.runCommand( + "cd test-project-dev && uv add --dev pytest" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add multiple packages at once`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-project-multi"); + + const result = await shell.runCommand( + "cd test-project-multi && uv add requests certifi urllib3" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks malicious packages via uv add`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-project-malware"); + + const result = await shell.runCommand( + "cd test-project-malware && uv add safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("safe_chain_pi_test@0.0.1"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv tool install installs a global tool`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv tool install ruff" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installed"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks malicious packages via uv tool install`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv tool install safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("safe_chain_pi_test@0.0.1"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv run --with installs ephemeral dependency`, async () => { + const shell = await container.openShell("zsh"); + + // Create a simple Python script + await shell.runCommand("echo 'import requests; print(requests.__version__)' > test_script.py"); + + const result = await shell.runCommand( + "uv run --with requests test_script.py" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks malicious packages via uv run --with`, async () => { + const shell = await container.openShell("zsh"); + + // Create a simple Python script + await shell.runCommand("echo 'print(\"test\")' > test_script2.py"); + + const result = await shell.runCommand( + "uv run --with safe-chain-pi-test test_script2.py" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv sync syncs project dependencies`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project, add a dependency, remove venv, and sync in one command chain + const result = await shell.runCommand( + "uv init test-sync-project && cd test-sync-project && uv add requests && rm -rf .venv && uv sync" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add from git URL`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-git-add"); + + const result = await shell.runCommand( + "cd test-git-add && uv add git+https://github.com/psf/requests.git@v2.32.3" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add with --optional group`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-optional"); + + const result = await shell.runCommand( + "cd test-optional && uv add --optional dev pytest" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv run --with-requirements installs from requirements file`, async () => { + const shell = await container.openShell("zsh"); + + // Create requirements file and script + await shell.runCommand("echo 'requests' > run_requirements.txt"); + await shell.runCommand("echo 'import requests; print(requests.__version__)' > run_script.py"); + + const result = await shell.runCommand( + "uv run --with-requirements run_requirements.txt run_script.py" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv sync --all-extras syncs all optional dependencies`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize project with optional dependency and sync in one command chain + const result = await shell.runCommand( + "uv init test-extras && cd test-extras && uv add --optional dev pytest && uv sync --all-extras" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); +}); + diff --git a/test/e2e/yarn-ci.e2e.spec.js b/test/e2e/yarn-ci.e2e.spec.js index 33ef4f2..8aac426 100644 --- a/test/e2e/yarn-ci.e2e.spec.js +++ b/test/e2e/yarn-ci.e2e.spec.js @@ -36,7 +36,7 @@ describe("E2E: yarn coverage", () => { const result = await shell.runCommand("yarn add axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index 3909318..32a8114 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -31,7 +31,7 @@ describe("E2E: yarn coverage", () => { const result = await shell.runCommand("yarn add axios"); assert.ok( - result.output.includes("no malicious packages found."), + result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}` ); });