+1
-1
@@ -622,3 +622,3 @@ #!/usr/bin/env node | ||
| // CLEAR a package. A quiet run is not a safe package. | ||
| if (behavioral.verdict === "safe") { | ||
| if (behavioral.verdict === "not-observed") { | ||
| lines.push( | ||
@@ -625,0 +625,0 @@ "> No malicious behavior was OBSERVED in this run. This does NOT clear the package:", |
+1
-1
| { | ||
| "name": "pkgxray", | ||
| "version": "1.0.0", | ||
| "version": "1.0.1", | ||
| "description": "Zero-dep local CLI and MCP server that scans npm packages for supply-chain risk. OSV vuln pre-check, sandboxed quarantine, tarball-integrity verification, calibrated static heuristics, GitHub provenance cross-check.", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+317
-345
@@ -5,7 +5,15 @@ <div align="center"> | ||
| **Analyze packages before you install them.** | ||
| **Supply-chain security for AI agents, npm packages, and Model Context Protocol (MCP) servers.** | ||
| Local supply-chain security for AI agents & npm packages. | ||
| Zero-dependency Node, runs entirely on your machine, never executes untrusted code. | ||
| Analyze packages *before* you install them. Zero-dependency Node, runs | ||
| entirely on your machine, never executes untrusted code. | ||
| [](https://www.npmjs.com/package/pkgxray) | ||
| [](https://github.com/adamsjack711-ux/pkgxray/actions/workflows/pkgxray-test.yml) | ||
| [](https://github.com/adamsjack711-ux/pkgxray/actions/workflows/pkgxray-benchmark.yml) | ||
| [](LICENSE) | ||
| **Static analysis** · **Supply-chain intelligence** · **Prompt-injection detection** · | ||
| **MCP security** · **Zero dependencies** · **Evidence-based verdicts** · `SAFE` / `REVIEW` / `BLOCK` | ||
| <img src="docs/banner.png" alt="pkgxray — a package under an x-ray scan beam next to the SAFE / REVIEW / BLOCK verdict chips" width="820"> | ||
@@ -15,429 +23,391 @@ | ||
| ## Quick start | ||
| ```bash | ||
| npm install -g pkgxray | ||
| npm install -g pkgxray # or zero-install: npx pkgxray … | ||
| pkgxray guard npm:some-package@1.2.3 | ||
| pkgxray guard npm:express@4.21.0 | ||
| ``` | ||
| Point it at a package, get a `SAFE` / `REVIEW` / `BLOCK` verdict with cited | ||
| evidence — before a single line of that package runs. | ||
| ```text | ||
| Decision: **SAFE** | ||
| --- | ||
| Verdict: **SAFE** | ||
| Grade: **A+** (99/100) | ||
| ## Why pkgxray exists | ||
| No high- or medium-risk indicators were found in the provided evidence. | ||
| AI coding assistants increasingly install packages automatically, often without | ||
| a human ever reading the code. Traditional antivirus inspects what *executes*; | ||
| **pkgxray inspects what gets *installed*** — evidence-based static analysis on a | ||
| package's metadata, source, provenance, and published artifact before it reaches | ||
| your machine. | ||
| Notes: | ||
| - **INFO npm-vs-github-clean** — npm tarball matches the linked GitHub repo | ||
| at the published version. (15/16 files match GitHub @4.21.0) | ||
| It's intentionally conservative: it only reports evidence it can cite, and | ||
| stages everything in a sandboxed quarantine that never runs install scripts or | ||
| package code. Triage takes ~1 s/package with no execution risk. | ||
| Parameter grades: | ||
| - `knownVulnerabilities`: A+ (100/100) - `provenance`: A+ (100/100) | ||
| - `dataAccess`: A+ (100/100) - `persistence`: A+ (100/100) | ||
| - `obfuscation`: A+ (100/100) - `injectionResistance`: A+ (100/100) | ||
| … | ||
| ``` | ||
| --- | ||
| <sub>Real output, abridged. A `BLOCK` verdict instead lists every finding with | ||
| the file and evidence that produced it.</sub> | ||
| ## Detection Engine | ||
| Point it at a package, get a `SAFE` / `REVIEW` / `BLOCK` verdict with cited | ||
| evidence — before a single line of that package runs. The guard flow stages | ||
| the package in a sandboxed quarantine, audits the staged copy, and only | ||
| promotes it when policy allows. It never runs `npm install`, lifecycle | ||
| scripts, build steps, or package code. | ||
| **Supply-chain intelligence** — known CVEs (OSV, blocks *before* download), | ||
| sigstore/SLSA provenance, npm↔GitHub artifact divergence, registry metadata. | ||
| <img src="docs/screenshots/cli-guard-block.png" alt="pkgxray guard blocking a malicious sample: BLOCK verdict, grade F, HIGH credential-access finding citing a wallet read exfiltrated to an attacker endpoint, exit code 2" width="820"> | ||
| **Static code analysis** — credential/secret access (`.ssh`, `.aws`, `.npmrc`, | ||
| `.env`, keychains, wallets), persistence writes (shell rc, cron, launch agents), | ||
| obfuscation + execution (a packed blob decoded into `eval`/`new Function`/`vm`, | ||
| split-string paths), Trojan Source (bidi/zero-width Unicode), and **tiered | ||
| prompt-injection** detection in docs, *code comments*, and *`package.json` | ||
| metadata* (`description`/`keywords`/`author`) — reworded steering, chat/role | ||
| scaffolding (`<|im_start|>`, `<<SYS>>`, `[INST]`), and identity reassignment, not | ||
| just verbatim phrases. | ||
| <sub>`guard` blocking a malicious sample from the [calibration corpus](benchmark/) | ||
| (modeled on the 2024 `@solana/web3.js` compromise) — the HIGH finding cites the | ||
| exact wallet-read + exfiltration code. Real run; see | ||
| [how each screenshot was made](docs/screenshots/README.md).</sub> | ||
| **Concealment & encoding** — injection is unsolvable by matching the attacker's | ||
| *wording* (paraphrase defeats it), but it has to be *delivered*, and the | ||
| delivery tells are high-signal and low-FP. pkgxray detects instructions | ||
| **smuggled in invisible characters** (the Unicode tag block — "ASCII smuggling") | ||
| and **base64-encoded prompts** hidden in docs/comments that a human can't read | ||
| but an agent decodes. It detects the envelope, not the message — so it | ||
| generalizes past rewording. (Emoji subdivision flags and benign/binary base64 | ||
| are excluded.) See [solving prompt injection](#on-prompt-injection). | ||
| ## Why pkgxray? | ||
| **Behavioral correlation** — cross-file exfiltration, stage-2 loaders, download→ | ||
| execute (`curl | sh`), `process.env` harvesting near a network sink, **on-chain | ||
| command channels** (EtherHiding — a chain-read like `eth_getTransactionByHash` / | ||
| TronGrid / Aptos co-located with a code executor, so the payload lives on a | ||
| blockchain the repo never has to change), and **hidden self-`node -e` execution** | ||
| (a detached, `windowsHide`, `stdio:'ignore'` inline-eval subprocess — stage-2 | ||
| that outlives and escapes the process being scanned). | ||
| AI coding assistants increasingly install packages automatically, often | ||
| without a human ever reading the code. Traditional antivirus inspects what | ||
| *executes*; **pkgxray inspects what gets *installed***. | ||
| Every signal resolves to one verdict: | ||
| Vulnerability scanners like `npm audit` and OSV-Scanner answer an essential | ||
| question — *does this package have a known CVE?* — and pkgxray asks it too | ||
| (via OSV, before anything downloads). But a freshly trojaned package has no | ||
| CVE yet. So pkgxray also analyzes **trust**: | ||
| | Verdict | Meaning | | ||
| |---|---| | ||
| | 🟢 `safe` | no high- or medium-risk indicators | | ||
| | 🟡 `review` | incomplete evidence or a privileged capability needing a human | | ||
| | 🔴 `block` | high-severity (prompt injection, credential access, persistence, obfuscation + execution, likely exfiltration) | | ||
| - What does the code actually *do* — read credentials? persist? phone home? | ||
| - Does the published npm artifact match the tagged GitHub source? | ||
| - Is the provenance attestation consistent with the claimed repository? | ||
| - Is there a prompt-injection payload aimed at the AI agent reading the docs? | ||
| --- | ||
| It is intentionally conservative: it only reports evidence it can cite, its | ||
| verdicts come from deterministic heuristics (no LLM in the verdict path, so | ||
| injected text can't steer them), and its zero-false-block calibration is | ||
| [regression-gated in CI](docs/benchmark.md). | ||
| ## Architecture | ||
| > [!NOTE] | ||
| > pkgxray is designed to run *alongside* `npm audit` and OSV-Scanner, not | ||
| > replace them. See the [comparison table](#-comparison) below. | ||
| <img src="docs/architecture.svg" alt="pkgxray architecture: inputs flow through the acquisition, quarantine, static-analysis and policy engines to a SAFE / REVIEW / BLOCK verdict" width="820"> | ||
| ## Key features | ||
| **Design principles:** never execute untrusted code · report only citable | ||
| evidence · explainability over black-box scoring · minimize false positives · | ||
| operate offline whenever possible · zero runtime dependencies. | ||
| ### Supply-chain intelligence | ||
| --- | ||
| - **Known-CVE pre-check** — batch OSV query that blocks *before* download | ||
| - **Provenance verification** — sigstore / SLSA attestations, cross-checked | ||
| against the claimed repository | ||
| - **Artifact divergence** — the published npm tarball diffed against the | ||
| tagged GitHub source | ||
| - **Registry metadata signals** — nonexistent or mismatched repos, | ||
| attestation/repo inconsistencies (typosquat and impersonation indicators) | ||
| - **Continuous monitoring** — [`pkgxray recheck`](docs/reference.md#monitoring-pkgxray-recheck) | ||
| diffs installed deps against a stored verdict baseline and pre-vets newer | ||
| versions, catching the maintainer-takeover / trojaned-update case | ||
| ## Threat model | ||
| ### Static behavior analysis | ||
| Malicious npm packages · compromised maintainer accounts · typosquatting & | ||
| dependency confusion · credential theft · malicious lifecycle scripts · | ||
| supply-chain tampering (npm artifact ≠ tagged source) · provenance spoofing · | ||
| AI prompt injection in package docs. | ||
| - **Credential & secret access** — `.ssh`, `.aws`, `.npmrc`, `.env`, | ||
| keychains, wallets — including paths assembled from split fragments | ||
| (`".s"+"sh"`) | ||
| - **Persistence** — writes to shell rc files, cron, launch agents | ||
| - **Obfuscation + execution** — a packed blob decoded into `eval` / | ||
| `new Function` / `vm` | ||
| - **Behavioral correlation** — cross-file exfiltration, stage-2 loaders, | ||
| download→execute (`curl | sh`), `process.env` harvesting near a network | ||
| sink, on-chain command channels (EtherHiding), hidden self-`node -e` | ||
| - **Trojan Source** — bidi / zero-width Unicode attacks | ||
| - **Opt-in behavioral canary** — [`pkgxray canary`](docs/canary-threat-model.md) | ||
| executes a package's lifecycle scripts in an OS sandbox with decoy | ||
| credentials. It can *confirm* malice; by design it never *clears* a package. | ||
| **Known blind spot:** pkgxray reasons about bytes in the tarball. A package that | ||
| downloads and runs its real payload *after* install can ship a clean tree. | ||
| pkgxray flags the *capability* when its shape is unambiguous, but pair it with | ||
| runtime/install-time sandboxing when that risk matters. | ||
| ### Prompt-injection detection | ||
| **Why few false positives:** validated against the 47 most-installed npm | ||
| packages with **0 false blocks**. READMEs run only the prompt-injection check | ||
| (never read as code); test/fixture/example files downgrade to `review`; | ||
| npm↔GitHub divergence is `review`, not auto-block (can't tell a build step from | ||
| tampering); URL shorteners count only when co-located with a capability. And | ||
| **minification is not obfuscation** — `eval`/`new Function` on a *string | ||
| literal* (a bundler's `eval-source-map` module wrapper, a `new Function("return | ||
| this")` globalThis probe) is recorded as info, not flagged; only `eval` on a | ||
| *computed* argument (`eval(atob(blob))`) gates. That keeps heavily-bundled | ||
| frontend packages out of the review pile. | ||
| - **Tiered detection** in docs, code comments, and `package.json` metadata | ||
| - **Delivery-envelope matching** — instructions smuggled in invisible Unicode | ||
| tag characters ("ASCII smuggling") or base64-encoded in docs/comments. | ||
| Detecting the *envelope*, not the wording, generalizes past rewording. | ||
| - **Injection-proof by construction** — verdicts are deterministic; no model | ||
| reads the package, so injected text can't steer the scanner. Full stance: | ||
| [threat model — on prompt injection](docs/threat-model.md#on-prompt-injection). | ||
| ### On prompt injection | ||
| ### MCP security | ||
| Prompt injection isn't "solved" by a scanner, and pkgxray doesn't claim to. The | ||
| durable defense is *architectural*, and pkgxray's design reflects three honest | ||
| layers: | ||
| - **MCP server** — `pkgxray-mcp` gives any MCP-capable agent four audit tools | ||
| - **Connect-time vetting** — `pkgxray mcp` performs a read-only handshake and | ||
| audits the tool manifest: injection in tool descriptions, concealed | ||
| envelopes, and **capability-surface mismatch** (a `get_weather` that also | ||
| takes a `command`) | ||
| - **Pin & recheck** — `--pin` fingerprints an approved manifest; | ||
| `--recheck` catches the rug-pull | ||
| 1. **pkgxray is injection-proof by construction.** Its verdict is computed by | ||
| deterministic heuristics, not by an LLM reading the package — so injected text | ||
| *cannot steer a pkgxray verdict*. There is no model in the decision path to | ||
| hijack. | ||
| 2. **Detection targets the delivery, not the wording.** Matching an attacker's | ||
| phrasing is a treadmill (paraphrase wins). Matching *how injection is | ||
| delivered* — concealed in invisible characters, base64-encoded, hidden in a | ||
| code comment — generalizes past rewording and has near-zero false positives, | ||
| because legitimate package text doesn't smuggle. The tiered phrase matcher | ||
| catches the rest and routes uncertainty to `review`, never a false `block`. | ||
| 3. **The real fix lives in the consuming agent.** An agent is only harmed by | ||
| injection if it can also act (install, exfiltrate) on what it read — the | ||
| "lethal trifecta." pkgxray's job is to **quarantine and label** the untrusted | ||
| package so the agent's *capability controls*, the actual security boundary, | ||
| can do theirs. pkgxray reduces exposure; it does not replace least-privilege. | ||
| ### Runtime protection | ||
| --- | ||
| - **Per-call gate** — [`pkgxray mcp-proxy`](docs/mcp.md#per-call-runtime-gate-pkgxray-mcp-proxy) | ||
| wraps a live MCP server on the wire: denied tools stripped from listings, | ||
| ~0.05 µs per-call verdict lookup, immediate re-audit on manifest change, | ||
| injection scan of tool *results*, drift-after-pin denial | ||
| - **Install gate** — a [hookshot](https://github.com/CorridorSecurity/hookshot) | ||
| hook binary intercepts an agent's shell command and runs `pkgxray guard` on | ||
| every package about to be installed, across Claude Code, Cursor, Windsurf | ||
| Cascade, Factory Droid, and OpenAI Codex | ||
| ([`examples/hookshot/`](examples/hookshot/)) | ||
| ## Quick start | ||
| ### Policy engine | ||
| ```bash | ||
| # Guard an npm package before it reaches your machine | ||
| pkgxray guard npm:some-package@1.2.3 | ||
| pkgxray guard npm:some-mcp-server@1.2.3 --format json | ||
| - **One policy file, every surface** — the CLI, MCP server, and proxy read the | ||
| same `.pkgxray.json` through the same loader; policy can't drift | ||
| - **Tighten freely, loosen loudly** — stricter without limit; every loosening | ||
| is explicit and printed in the report | ||
| - **Enforced invariants** — an `allow` must be pinned to `name@version` + | ||
| `sha256`; a published CVE can never be muted or allowed away | ||
| - **Fail closed** — zero config means maximum strictness; a scan that errors | ||
| becomes `review`, never `safe` | ||
| # Guard a local extension and promote it only if policy allows | ||
| pkgxray guard ./ext --promote-to ./approved/ext | ||
| ## Architecture | ||
| # Audit a whole project's lockfile (batch OSV query) | ||
| pkgxray audit package-lock.json # also: yarn.lock, pnpm-lock.yaml, package.json | ||
| pkgxray audit package-lock.json --deep # full static/GitHub layer on each blocked dep | ||
| <img src="docs/architecture.svg" alt="pkgxray architecture: inputs flow through the acquisition, quarantine, static-analysis and policy engines to a SAFE / REVIEW / BLOCK verdict" width="820"> | ||
| # Audit supplied evidence directly | ||
| pkgxray --file examples/evidence.json --format json | ||
| <!-- Architecture diagram --> | ||
| # Re-check already-installed deps against *current* intelligence (monitoring) | ||
| pkgxray recheck package-lock.json # diff verdicts vs. stored baseline | ||
| pkgxray recheck package-lock.json --format json # machine-readable, for CI cron | ||
| ``` | ||
| Acquisition (OSV pre-check → fetch) → sandboxed quarantine → static analysis → | ||
| policy → verdict. The same engine backs every surface: CLI, MCP server, | ||
| runtime proxy, install hook, browser extension, and CI cache server. | ||
| The guard flow stages the extension in a private quarantine, audits the staged | ||
| copy, and only promotes it when policy allows — it never runs `npm install`, | ||
| lifecycle scripts, build steps, or extension code. For npm references: resolve | ||
| metadata → query OSV → block before download if vulnerable → otherwise extract | ||
| into quarantine and run the static audit. | ||
| **Design principles:** never execute untrusted code · report only citable | ||
| evidence · explainability over black-box scoring · minimize false positives · | ||
| operate offline whenever possible · zero runtime dependencies. | ||
| Decisions: `allow` (promotion ok), `review` (inspect quarantine first), `block` | ||
| (do not install). Only `safe` promotes by default; `--policy allow-review` also | ||
| promotes review-grade. Exit codes: `0` safe/allow, `2` block, `3` review. | ||
| Details: [docs/architecture.md](docs/architecture.md) · | ||
| [docs/design.md](docs/design.md) | ||
| --- | ||
| ## Verdicts | ||
| ## Configuration: `.pkgxray.json` | ||
| Every signal resolves to one of three verdicts: | ||
| Every surface — the CLI, the MCP server, and the proxy — reads **one** optional | ||
| policy file, `.pkgxray.json`, through the same loader, so your policy can never | ||
| drift between them. Zero config is fully safe: an absent file means maximum | ||
| strictness. You never have to write one. | ||
| | Verdict | Meaning | You should | | ||
| |---|---|---| | ||
| | 🟢 `SAFE` | No high- or medium-risk indicators. | Install. (Only `safe` promotes out of quarantine by default.) | | ||
| | 🟡 `REVIEW` | Incomplete evidence, or a privileged capability that needs a human — install scripts, computed `eval`, a lone callback domain, npm↔GitHub divergence. | Inspect the quarantined copy before promoting. `--policy allow-review` promotes review-grade if you accept that. | | ||
| | 🔴 `BLOCK` | High-severity, cited evidence — prompt injection, credential access, persistence, obfuscation + execution, likely exfiltration, or a known CVE. | Do not install. Every finding names the file and evidence. | | ||
| The governing rule is **tighten freely, loosen loudly**. You may make the policy | ||
| stricter without limit; you may loosen it (allow a package, mute a check) only | ||
| *explicitly*, and every loosening is printed in the report — never silent. Two | ||
| rules are enforced in code, not by convention: | ||
| Exit codes are stable and CI-friendly: **`0`** safe/allow · **`2`** block · | ||
| **`3`** review. The exact mapping of every signal to `block` / `review` / | ||
| `info` is specified in the [severity policy](docs/reference.md#severity-policy-what-lands-in-block--review--info). | ||
| 1. **An `allow` entry must be pinned** to `name@version` **and** a `sha256`. A | ||
| bare name would blanket-trust every future version — exactly how a trojaned | ||
| update gets in — so un-pinned allows are dropped with a warning, and a pin | ||
| only applies when the scanned artifact's digest matches. | ||
| 2. **A published vulnerability can never be muted or allowed away.** OSV | ||
| `known-vulnerability` findings always surface. You can vouch for a package's | ||
| code; you cannot vouch away a CVE. | ||
| ## Who is this for? | ||
| ```jsonc | ||
| { | ||
| "policy": "safe-only", // or "allow-review" (a loosening — warns) | ||
| "failOn": "review", // CI exit threshold: fail at review (exit 3) / block (exit 2) | ||
| "scanErrorPolicy": "fail-closed", // a scan that errors → review, never silently safe | ||
| - **AI developers** — building agents that install packages or connect to MCP | ||
| servers | ||
| - **Security engineers** — vetting third-party code with citable evidence | ||
| - **DevSecOps** — enforcing supply-chain policy in CI with stable exit codes | ||
| and additive-only JSON | ||
| - **Open-source maintainers** — verifying their own dependency trees and | ||
| release provenance | ||
| - **Organizations adopting AI coding assistants** — putting a deterministic | ||
| gate between the agent and the registry | ||
| // loosen ONLY explicitly — each is shown in every report | ||
| "allow": [ | ||
| { "pkg": "left-pad@1.3.0", "sha256": "e0b0…", | ||
| "reason": "reviewed 2026-07", "expires": "2026-10-01" } | ||
| ], | ||
| "mute": [ | ||
| { "check": "lonely-maintainer", "scope": "@myorg/*", "reason": "internal registry" } | ||
| ], | ||
| ## Use cases | ||
| // the MCP server reads the same file, but the agent deployment starts stricter | ||
| "mcp": { "tools": ["audit", "recheck"], "packageScanFirst": true, "timeoutMs": 15000 } | ||
| } | ||
| ### Vet an npm package before installing | ||
| ```bash | ||
| pkgxray guard npm:some-package@1.2.3 | ||
| pkgxray guard npm:some-package@1.2.3 --format json | ||
| # Guard a local extension and promote it only if policy allows | ||
| pkgxray guard ./ext --promote-to ./approved/ext | ||
| ``` | ||
| Precedence (lowest → highest): built-in safe defaults → project `.pkgxray.json` | ||
| → local `.pkgxray.local.json` (gitignored, personal) → `PKGXRAY_*` env vars → | ||
| CLI flags. `allow`/`mute` lists concatenate across layers. See | ||
| [`.pkgxray.example.json`](.pkgxray.example.json) and [`docs/config.md`](docs/config.md) | ||
| for the full schema. | ||
| ### Vet an MCP server before connecting | ||
| --- | ||
| ```bash | ||
| # Static package scan FIRST, then read-only manifest audit | ||
| pkgxray mcp --package npm:some-mcp-server@1.4.2 npx some-mcp-server | ||
| ## Monitoring: `pkgxray recheck` | ||
| pkgxray mcp https://mcp.example.com/mcp # HTTP server | ||
| pkgxray mcp --pin --package npm:some-mcp-server@1.4.2 npx some-mcp-server | ||
| pkgxray mcp --recheck npx some-mcp-server # catch the rug-pull | ||
| ``` | ||
| `guard` and `audit` give a point-in-time verdict *at install*. `recheck` answers | ||
| the follow-up they can't: **has anything I already depend on become unsafe since | ||
| I installed it?** — the maintainer-takeover / trojaned-update case. | ||
| Full MCP guide (server, adapter, runtime proxy): [docs/mcp.md](docs/mcp.md) | ||
| It walks a lockfile, re-runs the guard evaluation (OSV / provenance / divergence) | ||
| for each pinned `name@version`, and diffs the fresh verdict against the baseline | ||
| stored in `.pkgxray.lock` (written by `triage`/`guard`). It reports a **diff, not | ||
| a full report**: | ||
| ### Enforce in CI/CD | ||
| - **regressed** — verdict got worse since `checkedAt` (`allow/safe → review/block`). | ||
| The actionable signal: you may already be exposed. | ||
| - **improved** — verdict got better (informational). | ||
| - **unchanged** — hidden unless `--verbose`. | ||
| - **no-baseline** / **unknown** — never-vetted, or the recheck itself errored (its | ||
| stored verdict is left untouched — never a false allow). | ||
| ```bash | ||
| pkgxray audit package-lock.json # also: yarn.lock, pnpm-lock.yaml, package.json | ||
| pkgxray audit package-lock.json --deep # full static/GitHub layer on each blocked dep | ||
| ```bash | ||
| pkgxray recheck package-lock.json # human diff | ||
| pkgxray recheck package-lock.json --verbose # also list unchanged deps | ||
| pkgxray recheck package-lock.json --no-write # don't update stored baselines | ||
| pkgxray recheck package-lock.json --format json # machine-readable diff | ||
| # Scheduled: has anything I already depend on become unsafe since install? | ||
| npx pkgxray recheck package-lock.json --format json | ||
| ``` | ||
| **Exit codes** key off the worst *regression*, so CI cron jobs consume `recheck` | ||
| exactly as they do `guard`: `0` nothing regressed, `2` a dep regressed to | ||
| **block**, `3` a dep regressed to **review**. A dep that was `block` at install | ||
| and is *still* `block` is **not a new regression** and does not fail the run. | ||
| Available newer-version updates never affect the exit code on their own (see | ||
| version drift below) unless you pass `--fail-on-available-updates`. | ||
| `recheck` exits non-zero only on a *regression* (a dep whose verdict got | ||
| worse), which makes it a clean scheduled job — a ready-made GitHub Actions | ||
| workflow is in the [reference](docs/reference.md#monitoring-pkgxray-recheck). | ||
| Point `PKGXRAY_CACHE_URL` at the | ||
| [self-hostable cache server](docs/reference.md#self-hostable-cache-server) to | ||
| collapse duplicate fetches across runners. | ||
| Set `PKGXRAY_CACHE_URL` so a large tree shares `guard`'s warm cache instead of | ||
| re-fetching everything cold. | ||
| ### Guard AI coding agents | ||
| ### Version drift — pre-vet newer versions before you upgrade | ||
| ```json | ||
| { "mcpServers": { "pkgxray": { "command": "pkgxray-mcp" } } } | ||
| ``` | ||
| Alongside verdict drift, `recheck` also asks the registry whether a **newer | ||
| version** exists for each dep and guards it, so you see the security verdict | ||
| *before* upgrading — the trojaned-update catch: | ||
| Give the agent the audit tools directly (above), gate its installs with the | ||
| [hookshot integration](examples/hookshot/), and wrap its MCP servers with | ||
| [`pkgxray mcp-proxy`](docs/mcp.md#per-call-runtime-gate-pkgxray-mcp-proxy). | ||
| - **update-available-safe** — a newer version exists and guards clean. | ||
| - **update-available-flagged** — a newer version exists but is `review`/`block`. | ||
| Don't blind-upgrade into it. | ||
| ### Security reviews | ||
| To keep registry/OSV cost sane, at most two candidates are vetted per dep: the | ||
| **latest** published stable version and the **latest within the pinned major** | ||
| (an approximation of your install range), when they differ. Prereleases are | ||
| skipped unless the pinned version is itself a prerelease. | ||
| ```bash | ||
| pkgxray --file examples/evidence.json --format json # audit supplied evidence | ||
| ``` | ||
| Version drift is **informational** — an available flagged update you haven't | ||
| installed isn't an active exposure, so it never changes the exit code on its | ||
| own. Pass `--fail-on-available-updates` to make a flagged update count, or | ||
| `--no-version-drift` to skip the registry pass entirely. | ||
| Every verdict is a structured, citable report (`schemaVersion: 1`, | ||
| additive-only — [schema](docs/json-schema.md)), and the quarantined copy is | ||
| left on disk for manual inspection on `review`. | ||
| ### Scheduled CI job (GitHub Actions) | ||
| ## Configuration | ||
| Run `recheck` against the committed lockfile on a schedule; the job fails the | ||
| moment a dependency you already ship regresses: | ||
| One optional `.pkgxray.json`, read by every surface. Zero config is fully | ||
| safe — an absent file means maximum strictness. | ||
| ```yaml | ||
| # .github/workflows/pkgxray-recheck.yml | ||
| name: pkgxray recheck | ||
| on: | ||
| schedule: | ||
| - cron: "0 6 * * *" # daily 06:00 UTC | ||
| workflow_dispatch: | ||
| jobs: | ||
| recheck: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: "20" | ||
| - run: npx pkgxray recheck package-lock.json --format json | ||
| # exit 2 (regressed→block) or 3 (regressed→review) fails the build; | ||
| # commit the updated .pkgxray.lock back if you want the baseline to move. | ||
| ```jsonc | ||
| { | ||
| "policy": "safe-only", // or "allow-review" (a loosening — warns) | ||
| "failOn": "review", // CI exit threshold | ||
| "scanErrorPolicy": "fail-closed", // a scan that errors → review, never safe | ||
| "allow": [ | ||
| { "pkg": "left-pad@1.3.0", "sha256": "e0b0…", | ||
| "reason": "reviewed 2026-07", "expires": "2026-10-01" } | ||
| ] | ||
| } | ||
| ``` | ||
| --- | ||
| Precedence, the `mute` / `mcp` blocks, and the enforced invariants: | ||
| [docs/configuration.md](docs/configuration.md) · | ||
| [`.pkgxray.example.json`](.pkgxray.example.json) | ||
| ## MCP Server | ||
| ## Screenshots | ||
| Use the stdio server from any MCP-capable agent: | ||
| All captures are real runs — reproduction steps for each are in | ||
| [`docs/screenshots/`](docs/screenshots/README.md). | ||
| ```json | ||
| { "mcpServers": { "pkgxray": { "command": "pkgxray-mcp" } } } | ||
| ``` | ||
| **CLI — `pkgxray guard` clearing `express`, with the npm↔GitHub cross-check** | ||
| Tools: `audit_agent_extension_supply_chain` (static heuristics on supplied | ||
| evidence), `guard_agent_extension_install` (stage + vuln-check + audit a real | ||
| package, auto-fetches provenance), `audit_lockfile_supply_chain` (batch OSV scan | ||
| a lockfile), `triage_lockfile_supply_chain` (record each flagged dep as | ||
| `allow`/`block` into a sibling `.pkgxray.lock`). | ||
| <img src="docs/screenshots/cli-guard-safe.png" alt="pkgxray guard on express@4.21.0: SAFE verdict, grade A+, npm tarball matches the linked GitHub repo, per-parameter grades" width="820"> | ||
| --- | ||
| **MCP proxy — a live session against a malicious demo server** | ||
| ## MCP servers it connects to: `pkgxray mcp` | ||
| <img src="docs/screenshots/mcp-proxy.png" alt="pkgxray mcp-proxy stripping two tools from tools/list (capability mismatch and injection in the description) and denying a tools/call with an isError result" width="820"> | ||
| An agent pulls untrusted things in from outside two ways — packages it installs | ||
| (covered by guard/hook/proxy) and MCP servers it connects to. `pkgxray mcp` | ||
| covers the second with the same engine: it connects to a server (stdio or | ||
| streamable HTTP), performs the read-only handshake, enumerates the tool | ||
| manifest via `tools/list` — and never calls a tool, reads a resource, or | ||
| invokes a prompt. | ||
| <sub>Two tools stripped at `tools/list` — one for capability-surface mismatch, | ||
| one for injection in its description; the denied `tools/call` never reaches the | ||
| server.</sub> | ||
| ```bash | ||
| # Vet the server package statically FIRST, then connect and audit the manifest | ||
| pkgxray mcp --package npm:some-mcp-server@1.4.2 npx some-mcp-server | ||
| **hookshot install gate — an agent's `npm install` denied with cited evidence** | ||
| # An HTTP server | ||
| pkgxray mcp https://mcp.example.com/mcp | ||
| <img src="docs/screenshots/hookshot.png" alt="the hookshot guard hook answering a Claude Code PreToolUse event for npm install lodash@4.17.11 with permissionDecision deny and pkgxray's cited known-vulnerability evidence" width="820"> | ||
| # Approve what you just reviewed (pins per-tool fingerprints into .pkgxray.lock) | ||
| pkgxray mcp --pin --package npm:some-mcp-server@1.4.2 npx some-mcp-server | ||
| **Browser extension — the local MV3 popup blocking a risky sample** | ||
| # Later / in CI: catch the rug-pull — descriptions, tools, or schemas that | ||
| # changed since approval. Exits 3 on unapproved drift, 2 on a verdict regression. | ||
| pkgxray mcp --recheck npx some-mcp-server | ||
| ``` | ||
| <img src="docs/screenshots/browser-extension.png" alt="the Supply Chain Auditor extension popup showing a BLOCK verdict, grade F, per-parameter grades, and HIGH injection-attempt and network-exfil-or-loader findings" width="640"> | ||
| What the manifest audit looks for, all with the existing engine: prompt | ||
| injection in tool descriptions and the server's `instructions` blurb (the same | ||
| tiered matcher used on READMEs), instructions concealed in invisible Unicode | ||
| tag characters or base64 envelopes, and one MCP-specific check — | ||
| **capability-surface mismatch**, a tool whose stated purpose is narrow but | ||
| whose input schema takes a general-execution parameter (a `get_weather` that | ||
| accepts a `command`). Calibrated like the rest of pkgxray: a file reader | ||
| taking a `path`, an HTTP tool taking a `url`, a DB tool taking a `query`, or | ||
| an honest `execute_shell` tool are not findings. | ||
| ## Comparison | ||
| **The one caveat, stated plainly:** everything else pkgxray does is static — | ||
| it never executes what it inspects. Enumerating an MCP server is not. There | ||
| is no manifest without a connection, and for a **stdio server that means | ||
| spawning and running it**. `pkgxray mcp` narrows the risk the way guard | ||
| isolates a tarball — the child gets an allowlist-scrubbed environment (no | ||
| inherited secrets), a hard timeout, bounded output, and its process group is | ||
| killed after the listing — but the safe order is **package-scan first**: pass | ||
| `--package <ref>` so the static, no-execution scan clears the server before | ||
| anything connects to it. A `block` halts the connect step (`--force` to | ||
| override); skipping the scan entirely requires the explicit | ||
| `--no-package-scan`. | ||
| `npm audit` and [OSV-Scanner](https://google.github.io/osv-scanner/) are | ||
| excellent at what they target — matching your dependencies against known | ||
| vulnerabilities. pkgxray overlaps with them on that layer and adds the layers | ||
| they don't attempt: | ||
| ### Per-call runtime gate: `pkgxray mcp-proxy` | ||
| | Capability | npm audit | OSV-Scanner | pkgxray | | ||
| |---|:-:|:-:|:-:| | ||
| | Known-CVE lookup | ✅ | ✅ | ✅ (OSV, blocks before download) | | ||
| | Lockfile / project scanning | ✅ | ✅ | ✅ | | ||
| | Registry signature / provenance verification | ✅ (`npm audit signatures`) | — | ✅ (sigstore/SLSA + repo cross-check) | | ||
| | Static analysis of package code behavior | — | — | ✅ | | ||
| | Prompt-injection & Unicode-smuggling detection | — | — | ✅ | | ||
| | npm ↔ GitHub artifact divergence | — | — | ✅ | | ||
| | Pre-install quarantine of a single package | — | — | ✅ | | ||
| | Verdict-drift monitoring vs. a stored baseline | — | — | ✅ | | ||
| | MCP server vetting & per-call runtime gating | — | — | ✅ | | ||
| `pkgxray mcp` is connect-time: it answers "should this server be registered?" | ||
| and then gets out of the request path. Two attacks only exist *inside* a live | ||
| session, where a connect-time check can never see them: a manifest that | ||
| changes after approval (`notifications/tools/list_changed` — the rug-pull | ||
| moving in real time), and poisoned tool **output** steering the model. And a | ||
| separate probe can't watch a running stdio server either — it would spawn a | ||
| different instance than the one the agent is talking to. The only seam that | ||
| sees the actual session is the wire itself, so `mcp-proxy` sits on it: point | ||
| the host's server config at the proxy and it launches the real server as its | ||
| child, relaying every JSON-RPC frame through the gate. | ||
| <sub>Scoped to npm supply-chain vetting; based on each tool's public | ||
| documentation at time of writing. OSV-Scanner covers many ecosystems beyond | ||
| npm, which pkgxray does not.</sub> | ||
| ```jsonc | ||
| // .mcp.json — wrap the real launcher | ||
| { | ||
| "mcpServers": { | ||
| "some-server": { | ||
| "command": "pkgxray", | ||
| "args": ["mcp-proxy", "--", "npx", "some-mcp-server"] | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| ## Threat coverage | ||
| What the gate does, and what each piece costs: | ||
| | Threat | Coverage | How pkgxray sees it | | ||
| |---|:-:|---| | ||
| | Credential theft | ✅ | reads of `.ssh` / `.aws` / `.npmrc` / `.env` / keychains / wallets, incl. split-fragment paths | | ||
| | Prompt injection | ✅ | tiered detection in docs, comments, metadata; deterministic verdict path can't be steered by injected text | | ||
| | Unicode smuggling | ✅ | invisible tag-block characters ("ASCII smuggling") + Trojan Source bidi / zero-width | | ||
| | Base64 payloads | ✅ | encoded envelopes in docs/comments; blobs decoded into computed-arg `eval` / `new Function` / `child_process` | | ||
| | Persistence | ✅ | writes to shell rc files, cron, launch agents | | ||
| | Obfuscation | ✅ | packed blob + computed-arg execution; minification alone is deliberately *not* flagged | | ||
| | Known CVEs | ✅ | OSV batch pre-check before download; never mutable by config | | ||
| | Trojaned updates / maintainer takeover | ✅ | `recheck` verdict-drift + version-drift monitoring | | ||
| | Artifact divergence | ✅ | published npm tarball diffed against the tagged GitHub source | | ||
| | Dependency confusion | ◑ | the out-of-band callback beacons confusion payloads use are flagged; registry resolution itself belongs to your package manager | | ||
| | Typosquatting | ◑ | surfaced via repo-mismatch (package.json → nonexistent/mismatched repo) and provenance-mismatch signals; no name-similarity heuristic | | ||
| | MCP capability abuse | ✅ | capability-surface mismatch in the manifest audit | | ||
| | Runtime tool drift | ✅ | `mcp-proxy` re-audits on `tools/list_changed`; pinned-manifest drift is denied | | ||
| | Moment | Check | Cost | | ||
| |---|---|---| | ||
| | first `tools/list` | full static manifest audit (same engine as `pkgxray mcp`), tools that would be denied are **stripped from the listing** so the model never reads their descriptions | ~1 ms per 30 tools, no network | | ||
| | every `tools/call` | in-memory verdict lookup against the last verified manifest; unknown / blocked tools denied | **~0.05 µs** (p95 ~0.1 µs) | | ||
| | `tools/list_changed` | immediate re-list + re-audit through the same session; calls arriving mid-verification are **held**, then decided against the fresh manifest (denied if the server won't answer — fail closed) | one manifest audit | | ||
| | every `tools/call` result | doc-typed injection scan of the result text (tiered prompt-injection, unicode-tag smuggling, base64 envelopes), capped at 512 KiB | ~0.06 ms for a 2 KB result, ~13 ms worst-case at the cap; `--no-scan-results` to disable | | ||
| | after `--pin` | fresh manifest diffed against the pinned per-tool fingerprints; **drifted tools are denied under strict/balanced** until re-approved with `pkgxray mcp --pin` | one lock-file read per verification | | ||
| <sub>✅ detected · ◑ partial / indirect</sub> | ||
| Policies mirror the hookshot gate: `block` denies everywhere; `review` denies | ||
| under `--policy strict`, passes with a logged warning under `balanced` | ||
| (default) and `permissive`. A denied call never reaches the server — the | ||
| agent gets an `isError` tool result naming the reason, so the model can | ||
| explain instead of hanging. The proxy's own diagnostics go to stderr only; | ||
| stdout stays protocol-clean. On session close it prints a summary | ||
| (`N calls gated, M denied, per-call gate p50/p95`). | ||
| > [!IMPORTANT] | ||
| > **Known blind spot:** pkgxray reasons about bytes in the tarball. A package | ||
| > that downloads its real payload *after* install can ship a clean tree. | ||
| > pkgxray flags the *capability* when its shape is unambiguous, but pair it | ||
| > with runtime/install-time sandboxing when that risk matters. Full analysis: | ||
| > [docs/threat-model.md](docs/threat-model.md). | ||
| One deliberate difference from `pkgxray mcp`: the proxy is the production | ||
| conduit, not an enumerator — the child inherits the full environment the host | ||
| configured for it. Trust decisions here are about frames, not the child's env. | ||
| HTTP servers aren't wrapped (the host connects to them directly); vet those | ||
| with connect-time `pkgxray mcp <url>` + `--pin`/`--recheck`. | ||
| ## Performance | ||
| --- | ||
| - **Local static analysis: ~25 ms.** Almost all of `guard`'s wall-clock is | ||
| network round-trips — a full guard of `express` / `chalk` / `commander` is | ||
| **~1.3–1.5 s** cold-cache (Apple M1, Node 26). | ||
| - **Known-vulnerable packages block at the OSV pre-check**, before download. | ||
| - **`mcp-proxy` overhead:** ~0.05 µs per `tools/call` decision; a full | ||
| manifest re-audit (~1 ms per 30 tools) runs only when the manifest changes. | ||
| - Calibration — precision, recall, and the **0-false-block** gate — is | ||
| measured by a committed benchmark corpus that fails CI when it regresses. | ||
| ## Integrations | ||
| Full numbers: [docs/reference.md#performance](docs/reference.md#performance) · | ||
| methodology: [docs/benchmark.md](docs/benchmark.md) | ||
| **hookshot** — a [hookshot](https://github.com/CorridorSecurity/hookshot) hook | ||
| binary that guards installs across Claude Code, Cursor, Windsurf Cascade, Factory | ||
| Droid, and OpenAI Codex: it intercepts an agent's shell command, runs | ||
| `pkgxray guard` on every package about to be installed, and denies on a `BLOCK` | ||
| verdict with pkgxray's cited evidence returned to the agent. See | ||
| [`examples/hookshot/`](examples/hookshot/). | ||
| ## Roadmap | ||
| --- | ||
| - [ ] List the MCP server in the public MCP registries | ||
| - [ ] Ship a reusable GitHub Action wrapping `audit` / `recheck` | ||
| - [ ] Publish the browser extension to the Chrome Web Store (today it loads | ||
| unpacked) | ||
| - [ ] Replay documented known-malicious npm corpora against the engine and | ||
| publish the results | ||
| - [ ] A `--report` evidence bundle for one-command false-block / missed-threat | ||
| reports | ||
| ## Reference | ||
| <!-- Roadmap: additional planned work is tracked in GitHub issues --> | ||
| Detailed reference lives in [`docs/reference.md`](docs/reference.md): | ||
| The longer-form plan lives in the [adoption playbook](docs/adoption.md). | ||
| - **[Severity policy](docs/reference.md#severity-policy-what-lands-in-block--review--info)** — exactly what lands in `block` / `review` / `info`. | ||
| - **[Performance](docs/reference.md#performance)** — `guard` timings and `mcp-proxy` gate overhead. | ||
| - **[JSON output](docs/reference.md#json-output)** — top-level fields per command (full schema: [json-schema.md](docs/json-schema.md)). | ||
| - **[Browser extension](docs/reference.md#browser-extension)** — the local MV3 unpacked extension. | ||
| - **[Self-hostable cache server](docs/reference.md#self-hostable-cache-server)** — collapse duplicate CI fetches. | ||
| ## 📖 Documentation | ||
| Other docs: **[compatibility & stability tiers](docs/compatibility.md)** · | ||
| **[JSON schema](docs/json-schema.md)** · **[configuration schema](docs/config.md)** · | ||
| **[canary threat model](docs/canary-threat-model.md)** · **[design notes](docs/design/)** · | ||
| **[adoption playbook](docs/adoption.md)**. | ||
| | Doc | What it covers | | ||
| |---|---| | ||
| | [docs/architecture.md](docs/architecture.md) | Pipeline, surfaces, design principles, repo layout | | ||
| | [docs/threat-model.md](docs/threat-model.md) | Scope, the known blind spot, false-positive philosophy, prompt-injection stance | | ||
| | [docs/mcp.md](docs/mcp.md) | MCP server, connect-time vetting, per-call runtime proxy | | ||
| | [docs/configuration.md](docs/configuration.md) | `.pkgxray.json` schema, precedence, invariants | | ||
| | [docs/reference.md](docs/reference.md) | Severity policy, `recheck` monitoring, performance, JSON output, browser extension, cache server | | ||
| | [docs/benchmark.md](docs/benchmark.md) | Calibration benchmark & real-world validation | | ||
| | [docs/compatibility.md](docs/compatibility.md) | The 1.0 compatibility contract & stability tiers | | ||
| | [docs/json-schema.md](docs/json-schema.md) | Full `--format json` schema | | ||
| | [docs/canary-threat-model.md](docs/canary-threat-model.md) | Threat model for the opt-in `canary` surface | | ||
| | [docs/design.md](docs/design.md) · [docs/design/](docs/design/) | Design principles & internal working notes | | ||
| --- | ||
| Start at the [documentation index](docs/README.md). | ||
@@ -449,15 +419,17 @@ ## Development | ||
| npm run benchmark # calibration corpus: precision/recall + 0-false-block gate | ||
| npm run build:browser | ||
| npm run build:browser # build the MV3 browser extension | ||
| npm run audit:evidence -- --file examples/evidence.json | ||
| ``` | ||
| The [calibration benchmark](benchmark/) runs a labelled corpus of malicious and | ||
| benign fixtures through the real engine and fails on a false block or a missed | ||
| detection — the reproducible form of the "0 false blocks" claim. See | ||
| [`benchmark/README.md`](benchmark/README.md). | ||
| The [calibration benchmark](benchmark/) runs a labelled corpus of malicious | ||
| and benign fixtures through the real engine and fails on a false block or a | ||
| missed detection. Repo layout is described in | ||
| [docs/architecture.md](docs/architecture.md#repository-layout). | ||
| ``` | ||
| src/ analysis engines bin/ CLI entrypoints browser-extension/ MV3 ext | ||
| docs/ architecture examples/ sample evidence test/ node --test suites | ||
| benchmark/ calibration corpus + runner | ||
| ``` | ||
| ## Security & license | ||
| Releases are published to npm with provenance (SLSA attestation), gated on the | ||
| test suite, the calibration benchmark, and pkgxray's own supply-chain guard. | ||
| To report a vulnerability in pkgxray itself, see [SECURITY.md](SECURITY.md). | ||
| [MIT](LICENSE) |
+193
-34
@@ -113,7 +113,22 @@ "use strict"; | ||
| // Which canary tokens appear anywhere in a captured blob. | ||
| // Encoded forms a naive exfil path might apply to a stolen secret before | ||
| // sending it. A payload that base64/hex/url-encodes the token would defeat a | ||
| // plain substring match, so we also probe the common REVERSIBLE encodings and | ||
| // still attribute the leak to the original token. (Compression or encryption of | ||
| // the body still defeats this — reported honestly in the result `limits`.) | ||
| function tokenVariants(token) { | ||
| const variants = [token]; | ||
| try { variants.push(Buffer.from(token, "utf8").toString("base64")); } catch { /* noop */ } | ||
| try { variants.push(Buffer.from(token, "utf8").toString("base64url")); } catch { /* noop */ } | ||
| try { variants.push(Buffer.from(token, "utf8").toString("hex")); } catch { /* noop */ } | ||
| try { variants.push(encodeURIComponent(token)); } catch { /* noop */ } | ||
| return Array.from(new Set(variants.filter(Boolean))); | ||
| } | ||
| // Which canary tokens appear — verbatim OR in a common reversible encoding — | ||
| // anywhere in a captured blob. | ||
| function matchTokens(haystack, tokenSet) { | ||
| const seen = []; | ||
| for (const token of tokenSet) { | ||
| if (haystack.includes(token)) seen.push(token); | ||
| if (tokenVariants(token).some((v) => haystack.includes(v))) seen.push(token); | ||
| } | ||
@@ -138,12 +153,38 @@ return seen; | ||
| const hits = []; | ||
| // Track live sockets so teardown can never hang. server.close()'s callback | ||
| // only fires once EVERY connection has ended; a payload that opens a | ||
| // keep-alive socket to the proxy and never closes it would otherwise wedge | ||
| // the run forever in the teardown await. We force-destroy any lingering | ||
| // sockets on a short timer so close() is guaranteed to complete. | ||
| const sockets = new Set(); | ||
| // Precompute each token's encoded variants ONCE — they depend only on the | ||
| // token, never the request — so the capture hot path (every HTTP request and | ||
| // every CONNECT) doesn't re-encode all decoys on every hit. | ||
| const tokenIndex = Array.from(tokenSet, (token) => ({ token, variants: tokenVariants(token) })); | ||
| const scan = (haystack) => { | ||
| const seen = []; | ||
| for (const { token, variants } of tokenIndex) { | ||
| if (variants.some((v) => haystack.includes(v))) seen.push(token); | ||
| } | ||
| return seen; | ||
| }; | ||
| const server = http.createServer((req, res) => { | ||
| let body = ""; | ||
| const chunks = []; | ||
| let bodyBytes = 0; | ||
| let truncated = false; | ||
| req.on("data", (chunk) => { | ||
| if (body.length < MAX_CAPTURED_BODY) body += chunk; | ||
| else truncated = true; | ||
| const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); | ||
| if (bodyBytes < MAX_CAPTURED_BODY) { | ||
| chunks.push(buf); | ||
| bodyBytes += buf.length; | ||
| } else { | ||
| truncated = true; | ||
| } | ||
| }); | ||
| req.on("end", () => { | ||
| // latin1 preserves bytes 1:1, so an ASCII-encoded (base64/hex/url) token | ||
| // inside an otherwise-binary body survives intact for matchTokens. | ||
| const body = Buffer.concat(chunks).toString("latin1"); | ||
| const haystack = `${req.url}\n${JSON.stringify(req.headers)}\n${body}`; | ||
| const tokensSeen = matchTokens(haystack, tokenSet); | ||
| const tokensSeen = scan(haystack); | ||
| let host = req.headers.host || "?"; | ||
@@ -155,3 +196,3 @@ try { | ||
| } | ||
| hits.push({ transport: "http", method: req.method, host, url: req.url, tokensSeen, bodyBytes: body.length, truncated }); | ||
| hits.push({ transport: "http", method: req.method, host, url: req.url, tokensSeen, bodyBytes, truncated }); | ||
| res.writeHead(204); | ||
@@ -163,5 +204,12 @@ res.end(); | ||
| server.on("connection", (socket) => { | ||
| sockets.add(socket); | ||
| socket.on("close", () => sockets.delete(socket)); | ||
| }); | ||
| server.on("connect", (req, clientSocket) => { | ||
| sockets.add(clientSocket); | ||
| clientSocket.on("close", () => sockets.delete(clientSocket)); | ||
| const host = req.url; // host:port | ||
| const authTokens = matchTokens(host, tokenSet); | ||
| const authTokens = scan(host); | ||
| hits.push({ transport: "https-connect", method: "CONNECT", host, url: `https://${host}`, tokensSeen: authTokens, bodyBytes: 0 }); | ||
@@ -185,3 +233,18 @@ // No MITM: record the intended destination and refuse the tunnel so | ||
| hits, | ||
| close: () => new Promise((r) => server.close(() => r())) | ||
| close: () => | ||
| new Promise((r) => { | ||
| let done = false; | ||
| const finish = () => { if (!done) { done = true; r(); } }; | ||
| // After a short grace, force-destroy any socket still open so | ||
| // server.close() can fire its callback. Bounded so a lingering | ||
| // keep-alive connection can't wedge teardown. | ||
| const destroyTimer = setTimeout(() => { | ||
| for (const s of sockets) { try { s.destroy(); } catch { /* noop */ } } | ||
| }, 250); | ||
| if (destroyTimer.unref) destroyTimer.unref(); | ||
| // Absolute backstop: resolve regardless if close() never calls back. | ||
| const hardTimer = setTimeout(finish, 1500); | ||
| if (hardTimer.unref) hardTimer.unref(); | ||
| server.close(() => { clearTimeout(destroyTimer); clearTimeout(hardTimer); finish(); }); | ||
| }) | ||
| }); | ||
@@ -192,6 +255,15 @@ }); | ||
| // Escape a path for safe interpolation into an SBPL string literal so a path | ||
| // containing a quote or backslash can't break out of / corrupt the sandbox | ||
| // profile (a malformed profile makes sandbox-exec fail, which fails the run — | ||
| // still fail-closed, but this keeps the policy well-formed). | ||
| function sbplLiteral(p) { | ||
| return `"${String(p).replace(/(["\\])/g, "\\$1")}"`; | ||
| } | ||
| // Detect a best-effort OS sandbox wrapper. We never REQUIRE one (the decoy HOME | ||
| // + capture proxy are the primary controls), but if the platform ships one we | ||
| // use it to confine filesystem writes to the sandbox root while keeping | ||
| // loopback access to the proxy. | ||
| // use it to confine filesystem writes AND real network egress while keeping | ||
| // loopback access to the capture proxy. `netConfined` reports whether the OS | ||
| // boundary — not just the proxy env vars — blocks non-loopback egress. | ||
| function detectSandboxWrapper(sandboxRoot) { | ||
@@ -206,10 +278,17 @@ const has = (cmd) => { | ||
| if (process.platform === "darwin" && has("sandbox-exec")) { | ||
| // Deny writes outside the sandbox root; allow everything else (incl. | ||
| // loopback network to the proxy). Read is allowed so the payload can reach | ||
| // the decoy HOME. | ||
| // Deny writes outside the sandbox root, AND deny real network egress except | ||
| // loopback. Denying non-loopback network at the OS boundary means a payload | ||
| // that opens a raw socket / connects to a direct IP (bypassing the proxy | ||
| // env vars) is BLOCKED here instead of silently escaping — while the capture | ||
| // proxy on 127.0.0.1 stays reachable so proxy-respecting egress is still | ||
| // observed. Reads stay allowed so the payload can reach the decoy HOME. | ||
| const profile = | ||
| "(version 1)(allow default)" + | ||
| `(deny file-write* (subpath "${os.homedir()}"))` + | ||
| `(allow file-write* (subpath "${sandboxRoot}") (subpath "/private/tmp") (subpath "/tmp"))`; | ||
| return { level: "sandbox-exec", wrap: (argv) => ["sandbox-exec", "-p", profile, ...argv] }; | ||
| `(deny file-write* (subpath ${sbplLiteral(os.homedir())}))` + | ||
| `(allow file-write* (subpath ${sbplLiteral(sandboxRoot)}) (subpath "/private/tmp") (subpath "/tmp"))` + | ||
| "(deny network*)" + | ||
| '(allow network-outbound (remote ip "localhost:*"))' + | ||
| '(allow network-inbound (local ip "localhost:*"))' + | ||
| '(allow network-bind (local ip "localhost:*"))'; | ||
| return { level: "sandbox-exec", netConfined: true, wrap: (argv) => ["sandbox-exec", "-p", profile, ...argv] }; | ||
| } | ||
@@ -220,10 +299,29 @@ if (process.platform === "linux" && has("bwrap")) { | ||
| // works (the capture proxy, not the network namespace, is what denies real | ||
| // egress). --die-with-parent guarantees no sandbox process outlives pkgxray, | ||
| // and --new-session detaches the controlling terminal (blocks TIOCSTI | ||
| // input-injection back into the parent). All flags are long-standing. | ||
| // egress — so netConfined is false here; raw-socket egress can still leave). | ||
| // A tmpfs is stacked over the REAL home dir so the payload cannot read the | ||
| // operator's actual ~/.aws, ~/.npmrc, ~/.ssh, etc. through the ro-bind of / | ||
| // (HOME itself is repointed at the decoy tree via env). --die-with-parent | ||
| // guarantees no sandbox process outlives pkgxray, and --new-session detaches | ||
| // the controlling terminal (blocks TIOCSTI input-injection). All flags are | ||
| // long-standing. | ||
| // Mask the real home ONLY when it's a normal directory that does not contain | ||
| // the sandbox root. Guard the edge where os.homedir() is the filesystem root | ||
| // ("/", e.g. a misconfigured root account or a minimal container): `--tmpfs /` | ||
| // would shadow the ro-bind of everything — including the staged package — so | ||
| // the payload couldn't read its own package.json and the run would falsely | ||
| // read "not-observed" without executing anything. | ||
| const realHome = os.homedir(); | ||
| const resolvedHome = realHome ? path.resolve(realHome) : ""; | ||
| const resolvedRoot = path.resolve(sandboxRoot); | ||
| const homeIsFsRoot = resolvedHome !== "" && resolvedHome === path.parse(resolvedHome).root; | ||
| const sandboxUnderHome = | ||
| resolvedHome !== "" && (resolvedRoot === resolvedHome || resolvedRoot.startsWith(resolvedHome + path.sep)); | ||
| const maskRealHome = resolvedHome !== "" && !homeIsFsRoot && !sandboxUnderHome ? ["--tmpfs", realHome] : []; | ||
| return { | ||
| level: "bwrap", | ||
| netConfined: false, | ||
| wrap: (argv) => [ | ||
| "bwrap", | ||
| "--ro-bind", "/", "/", | ||
| ...maskRealHome, | ||
| "--bind", sandboxRoot, sandboxRoot, | ||
@@ -241,3 +339,3 @@ "--dev", "/dev", | ||
| } | ||
| return { level: "env-only", wrap: (argv) => argv }; | ||
| return { level: "env-only", netConfined: false, wrap: (argv) => argv }; | ||
| } | ||
@@ -249,3 +347,3 @@ | ||
| // use — and the part gated behind allowExecution. | ||
| async function runLifecycleScripts({ pkgDir, env, timeoutMs, wrapper }) { | ||
| async function runLifecycleScripts({ pkgDir, env, timeoutMs, wrapper, rlimits }) { | ||
| let pkg; | ||
@@ -262,3 +360,3 @@ try { | ||
| if (typeof command !== "string" || !command.trim()) continue; | ||
| const outcome = await execWithTimeout(command, { cwd: pkgDir, env, timeoutMs, wrapper }); | ||
| const outcome = await execWithTimeout(command, { cwd: pkgDir, env, timeoutMs, wrapper, rlimits }); | ||
| ran.push({ hook, command, ...outcome }); | ||
@@ -269,2 +367,44 @@ } | ||
| // Best-effort resource caps for the untrusted child, applied via `ulimit` in the | ||
| // spawned POSIX shell. The timeout + process-group SIGKILL bound TIME; these | ||
| // bound BLAST RADIUS during that window: CPU spin, disk-fill, fork-bomb, core | ||
| // dumps. `ulimit` can only LOWER a limit, so if the host's is already stricter | ||
| // the call is a harmless no-op (errors swallowed with `2>/dev/null`). We use | ||
| // `;` not `&&` so a limit the host refuses to set can't abort the payload run | ||
| // (that would turn hardening into a false "benign" verdict). Disable with | ||
| // rlimits:false. win32 has no ulimit and is skipped by the caller. | ||
| const DEFAULT_RLIMITS = { | ||
| cpuSeconds: null, // null → derived from timeoutMs (wall-clock) + headroom | ||
| fileSizeBlocks: 524288, // cap single-file writes (~256MB at 512B blocks) | ||
| // maxProcs (ulimit -u) is OFF by default. On macOS/BSD RLIMIT_NPROC is | ||
| // per-real-UID (it counts ALL the operator's processes, not just the sandbox | ||
| // subtree), so a low cap on a busy workstation can starve the PAYLOAD's own | ||
| // shell — no fork → no execution → a false "not-observed" that suppresses the | ||
| // very detection this sandbox exists for. dash also rejects `-u` entirely. | ||
| // The wall-clock timeout + process-group SIGKILL already bound a fork bomb in | ||
| // TIME, so the backstop isn't worth the false-negative risk. Opt in explicitly | ||
| // (rlimits:{maxProcs:N}) on a host where per-UID semantics are acceptable. | ||
| maxProcs: null, | ||
| coreDumps: 0 // no core dumps (they can leak the decoy HOME to disk) | ||
| }; | ||
| function buildRlimitPrefix(timeoutMs, rlimits) { | ||
| if (rlimits === false || process.platform === "win32") return ""; | ||
| const r = { ...DEFAULT_RLIMITS, ...(rlimits && typeof rlimits === "object" ? rlimits : {}) }; | ||
| const wall = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS; | ||
| const cpu = Number.isFinite(r.cpuSeconds) && r.cpuSeconds > 0 | ||
| ? Math.floor(r.cpuSeconds) | ||
| : Math.ceil(wall / 1000) + 10; | ||
| // Each limit is a SEPARATE, individually error-guarded `ulimit` call. Shells | ||
| // differ in which options they support — Ubuntu's `/bin/sh` is dash, whose | ||
| // `ulimit` rejects `-u` (max procs) — and a single combined `ulimit -t … -u …` | ||
| // aborts ALL limits on the first unsupported flag. Separate `; `-joined calls | ||
| // apply every supported limit and silently skip the rest. `ulimit` can only | ||
| // lower a limit, so a stricter host limit is preserved. | ||
| const stmts = [`ulimit -t ${cpu}`, `ulimit -c ${Math.max(0, Math.floor(r.coreDumps))}`]; | ||
| if (Number.isFinite(r.fileSizeBlocks) && r.fileSizeBlocks > 0) stmts.push(`ulimit -f ${Math.floor(r.fileSizeBlocks)}`); | ||
| if (Number.isFinite(r.maxProcs) && r.maxProcs > 0) stmts.push(`ulimit -u ${Math.floor(r.maxProcs)}`); | ||
| return `${stmts.map((s) => `${s} 2>/dev/null`).join("; ")}; `; | ||
| } | ||
| // Kill the whole process GROUP of a detached child, not just the direct shell. | ||
@@ -292,5 +432,10 @@ // `sh -c "(sleep 5; curl ...) &"` backgrounds a grandchild; killing only the | ||
| function execWithTimeout(command, { cwd, env, timeoutMs, wrapper }) { | ||
| function execWithTimeout(command, { cwd, env, timeoutMs, wrapper, rlimits }) { | ||
| return new Promise((resolve) => { | ||
| const baseArgv = process.platform === "win32" ? ["cmd", "/c", command] : ["sh", "-c", command]; | ||
| // On POSIX, prepend `ulimit` caps inside the shell so they bound the whole | ||
| // process tree (backgrounded grandchildren inherit them). win32 has no | ||
| // ulimit, so the command runs unwrapped there. | ||
| const shellCommand = | ||
| process.platform === "win32" ? command : `${buildRlimitPrefix(timeoutMs, rlimits)}${command}`; | ||
| const baseArgv = process.platform === "win32" ? ["cmd", "/c", command] : ["sh", "-c", shellCommand]; | ||
| const argv = wrapper ? wrapper(baseArgv) : baseArgv; | ||
@@ -478,3 +623,3 @@ let child; | ||
| const runner = options.runner || runLifecycleScripts; | ||
| execResult = await runner({ pkgDir, env, timeoutMs, wrapper: wrapperInfo.wrap, home, proxyPort: proxy.port }); | ||
| execResult = await runner({ pkgDir, env, timeoutMs, wrapper: wrapperInfo.wrap, home, proxyPort: proxy.port, rlimits: options.rlimits }); | ||
| } finally { | ||
@@ -501,2 +646,6 @@ // Keep the capture proxy alive for a short grace window after the runner | ||
| // "not-observed" (NOT "safe"): a clean behavioral run can never clear a | ||
| // package, only fail to catch it this time. The verdict vocabulary reflects | ||
| // that — callers compare against "block"/"review" and treat anything else as | ||
| // inconclusive, never as a pass. | ||
| const verdict = findings.some((f) => f.severity === "high") | ||
@@ -506,9 +655,16 @@ ? "block" | ||
| ? "review" | ||
| : "safe"; | ||
| : "not-observed"; | ||
| return { | ||
| schemaVersion: 1, | ||
| schemaVersion: 2, | ||
| runId, | ||
| isolation: wrapperInfo.level, | ||
| isolationRequired: options.requireSandbox === true, | ||
| netConfined: wrapperInfo.netConfined === true, | ||
| // Honest about OUTCOME, not just intent: the ulimit caps are injected only by | ||
| // the default lifecycle runner (`execWithTimeout`). A custom `options.runner` | ||
| // (the injectable seam) spawns the child itself and applies none, so we | ||
| // don't claim caps were installed then. Even when true, the caps are | ||
| // best-effort (a shell may silently reject an unsupported `ulimit`). | ||
| resourceLimited: !options.runner && options.rlimits !== false && process.platform !== "win32", | ||
| sandboxRoot: options.keepSandbox ? root : null, | ||
@@ -522,7 +678,7 @@ timeoutMs, | ||
| // NEVER clear a package. Sandbox-aware malware evades observation and fires | ||
| // only on a real developer's machine. Callers must treat a "safe" verdict as | ||
| // "nothing observed this run", not "safe". | ||
| // only on a real developer's machine. Callers must treat a "not-observed" | ||
| // verdict as "nothing observed this run", not "safe". | ||
| confirmsButCannotClear: true, | ||
| caveat: | ||
| verdict === "safe" | ||
| verdict === "not-observed" | ||
| ? "No malicious behavior was OBSERVED in this run. This does NOT clear the package. " + | ||
@@ -539,3 +695,4 @@ "Sandbox-aware malware stays dormant when it detects analysis and activates only on a real target, by fingerprinting: " + | ||
| limits: | ||
| "HTTPS bodies are not inspected (CONNECT destination host recorded, no MITM); raw-socket/dgram/non-proxied egress is not captured; " + | ||
| "HTTPS bodies are not inspected (CONNECT destination host recorded, no MITM); plaintext/base64/hex/url-encoded canary tokens are matched but compressed or encrypted exfil bodies are not; " + | ||
| `${wrapperInfo.netConfined !== true ? "raw-socket/dgram/non-proxied egress can still leave (net shared to keep the proxy reachable)" : "non-loopback egress is denied at the OS boundary (raw-socket egress blocked, not just unobserved)"}; ` + | ||
| `process isolation level: ${wrapperInfo.level}. Absence of a finding is not evidence of safety.` | ||
@@ -551,8 +708,10 @@ }; | ||
| matchTokens, | ||
| tokenVariants, | ||
| detectSandboxWrapper, | ||
| makeRunId, | ||
| DECOY_SPECS, | ||
| // exported for tests: process-group kill + timeout runner | ||
| // exported for tests: process-group kill + timeout runner + resource caps | ||
| killProcessGroup, | ||
| execWithTimeout | ||
| execWithTimeout, | ||
| buildRlimitPrefix | ||
| }; |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
604590
1.52%13272
1.17%433
-6.07%