+7
-3
@@ -36,4 +36,5 @@ #!/usr/bin/env node | ||
| " # --deps also OSV-scans the package's DIRECT dependencies (transitive worm entry point)", | ||
| " pkgxray canary <ref> --yes-run-untrusted-code [--timeout ms] [--keep-sandbox] # OPT-IN: run install scripts in a", | ||
| " # decoy-credential sandbox behind a capture proxy; confirms exfil behaviorally (cannot clear a pkg)", | ||
| " pkgxray canary <ref> --yes-run-untrusted-code [--timeout ms] [--keep-sandbox] [--require-sandbox] # OPT-IN: run install", | ||
| " # scripts in a decoy-credential sandbox behind a capture proxy; confirms exfil behaviorally (cannot clear a pkg).", | ||
| " # --require-sandbox fails closed without an OS sandbox (bwrap/sandbox-exec). See docs/canary-threat-model.md", | ||
| " pkgxray audit <package-lock.json|yarn.lock|pnpm-lock.yaml|package.json> # batch OSV scan of every dep", | ||
@@ -201,2 +202,4 @@ " pkgxray triage <lockfile> [--include-safe] [--auto allow|block] # interactive allow/block walkthrough", | ||
| options.keepSandbox = true; | ||
| } else if (arg === "--require-sandbox") { | ||
| options.requireSandbox = true; | ||
| } else if (arg === "--timeout") { | ||
@@ -349,3 +352,4 @@ options.timeoutMs = Number(argv[++i]); | ||
| timeoutMs: options.timeoutMs, | ||
| keepSandbox: options.keepSandbox | ||
| keepSandbox: options.keepSandbox, | ||
| requireSandbox: options.requireSandbox | ||
| }); | ||
@@ -352,0 +356,0 @@ if (options.format === "json") { |
@@ -16,3 +16,6 @@ #!/usr/bin/env node | ||
| const SERVER_VERSION = "0.12.0"; | ||
| // Always the published package version — a hardcoded string here drifted to | ||
| // 0.12.0 while the package shipped 0.17.0, so hosts (and pkgxray's own | ||
| // manifest pins) recorded a phantom version. | ||
| const SERVER_VERSION = require("../package.json").version; | ||
@@ -19,0 +22,0 @@ // --------------------------------------------------------------------------- |
+2
-1
| { | ||
| "name": "pkgxray", | ||
| "version": "0.17.0", | ||
| "version": "0.18.0", | ||
| "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.", | ||
@@ -22,2 +22,3 @@ "license": "MIT", | ||
| "test": "node --test", | ||
| "benchmark": "node ./benchmark/run.js", | ||
| "audit:evidence": "node ./bin/audit.js", | ||
@@ -24,0 +25,0 @@ "mcp": "node ./bin/mcp-server.js", |
+24
-101
@@ -63,3 +63,8 @@ <div align="center"> | ||
| **Behavioral correlation** — cross-file exfiltration, stage-2 loaders, download→ | ||
| execute (`curl | sh`), `process.env` harvesting near a network sink. | ||
| 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). | ||
@@ -436,104 +441,15 @@ Every signal resolves to one verdict: | ||
| <details> | ||
| <summary><b>Severity policy</b> (what lands in block / review / info)</summary> | ||
| Detailed reference lives in [`docs/reference.md`](docs/reference.md): | ||
| - **block** (HIGH) — verdict-forcing / rule-overriding prompt-injection text (in | ||
| docs *or* a code comment); credential reads near a filesystem-read primitive | ||
| (including paths assembled from split fragments — `".s"+"sh"` — folded by a | ||
| light de-obfuscation pass); persistence writes; execution/outbound-network plus | ||
| a hardcoded public IP / shortener / webhook; bulk `process.env` harvest in the | ||
| same file as outbound network (sinks include `sendBeacon` / `EventSource` / | ||
| `dns.*` / `dgram` / remote `import()`); a dynamic `require`/`import` of a | ||
| computed name co-located with an env harvest; a stage-2 loader that reads an | ||
| opaque blob and `eval`s it; a large encoded blob decoded into a **computed-arg** | ||
| `eval` / `new Function` / `child_process`; split token-exfil across files; | ||
| **concealed/encoded injection** — instructions smuggled in invisible Unicode | ||
| tag characters, or a base64 blob in docs/comments, that decode to a | ||
| verdict-forcing prompt. | ||
| - **review** (MEDIUM) — install/postinstall scripts; `eval` / `new Function` / | ||
| vm on a **computed** argument; weaker prompt-injection (reworded steering, | ||
| chat/role scaffolding like `<|im_start|>` / `<<SYS>>` / `[INST]`, identity | ||
| reassignment); a lone dynamic `require`/`import` by computed name; a lone bulk | ||
| `process.env` harvest; a path/domain assembled from split fragments; Trojan | ||
| Source Unicode; **invisible Unicode tag characters** (text-smuggling channel) | ||
| even when they don't decode to a known prompt; a geo/locale-gated destructive | ||
| op; download-then-execute; | ||
| clipboard access; a lone exfil/callback domain; npm↔GitHub divergence; missing | ||
| package.json or entrypoint. | ||
| - **info** — child_process/fetch/network in isolation; `eval` / `new Function` on | ||
| a **string literal** (bundler `eval-source-map` wrapper, feature-detection | ||
| probe — the executed text is in the artifact and scanned as code). Recorded, | ||
| does not gate. | ||
| - **[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. | ||
| `.d.ts`, `.map`, `.min.js`, `.lock` files are skipped. Tarballs up to 20,000 | ||
| entries / 256 MB uncompressed are scanned. | ||
| </details> | ||
| 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)**. | ||
| <details> | ||
| <summary><b>Performance</b></summary> | ||
| Local static analysis is ~25 ms; almost all of `guard`'s wall-clock is network | ||
| round-trips (registry, OSV, GitHub, provenance). Measured on an Apple M1 | ||
| (Node 26), cold cache: | ||
| | Package | Weekly downloads | `guard` time | | ||
| |---|--:|--:| | ||
| | `is-number@7.0.0` | ~170M | ~1.3 s | | ||
| | `express@4.21.0` | ~110M | ~1.4 s | | ||
| | `commander@12.1.0` | ~444M | ~1.5 s | | ||
| | `chalk@5.3.0` | ~451M | ~1.5 s | | ||
| A known-vulnerable package blocks at the OSV precheck, before download. Point CI | ||
| at the cache server to collapse repeated GitHub fetches across runners. | ||
| `mcp-proxy` runtime overhead (same machine): per-`tools/call` gate decision | ||
| p50 ~0.05 µs / p95 ~0.1 µs (in-memory verdict lookup, no IO); full manifest | ||
| re-audit ~1 ms per 30 tools, and it runs only when the manifest changes; | ||
| result scan ~0.06 ms for a typical 2 KB result, ~13 ms worst-case at the | ||
| 512 KiB cap. | ||
| </details> | ||
| <details> | ||
| <summary><b>JSON output</b></summary> | ||
| All JSON carries `schemaVersion: 1`; within `0.x` fields are additive only. Run | ||
| any command with `--format json`. Top-level fields: | ||
| - **audit / `--file`** — `verdict`, `grade`, `score`, `parameters`, `summary`, | ||
| `riskBands[]`, `findings[]` | ||
| - **guard** — `decision`, `resolved`, `githubMetadata`, `npmVsGithubDiff`, | ||
| `vulnerabilityPrecheck`, `timings`, `quarantinePath`, `promotedPath`, `report` | ||
| - **audit `<lockfile>`** — `file`, `format`, `totalDeps`, `uniqueDeps`, | ||
| `summary`, `worstDecision`, `results[]` | ||
| </details> | ||
| <details> | ||
| <summary><b>Browser extension</b></summary> | ||
| `browser-extension/` is a Chrome-compatible Manifest V3 unpacked extension that | ||
| runs entirely locally and requests no browser permissions. Load it via | ||
| `chrome://extensions` → Developer Mode → **Load unpacked** → select the folder. | ||
| </details> | ||
| <details> | ||
| <summary><b>Self-hostable cache server</b></summary> | ||
| Every `guard` / `audit --deep` fetches GitHub metadata and tarballs; in CI that | ||
| duplicates traffic. Run a shared cache to collapse it into one fetch per | ||
| (repo, ref) per TTL window: | ||
| ```bash | ||
| pkgxray-cache --port 8819 --cache-dir /var/cache/pkgxray | ||
| export PKGXRAY_CACHE_URL=http://cache.internal:8819 | ||
| ``` | ||
| Routes: `GET /github/repos/{owner}/{repo}` (1h), `GET | ||
| /github/tarball/{owner}/{repo}/{ref}` (24h, streamed), `GET /healthz`. With | ||
| `PKGXRAY_CACHE_URL` unset, clients run the default path with zero overhead. | ||
| > **Trust model:** the cache is a transparent proxy, **not** an auth boundary — | ||
| > no login or rate limit. Run it on a private network or behind a reverse proxy | ||
| > that enforces your own auth. Never put it on a public network. | ||
| </details> | ||
| --- | ||
@@ -544,3 +460,4 @@ | ||
| ```bash | ||
| npm test | ||
| npm test # zero-dep node --test suite | ||
| npm run benchmark # calibration corpus: precision/recall + 0-false-block gate | ||
| npm run build:browser | ||
@@ -550,5 +467,11 @@ 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). | ||
| ``` | ||
| src/ analysis engines bin/ CLI entrypoints browser-extension/ MV3 ext | ||
| docs/ architecture examples/ sample evidence test/ node --test suites | ||
| benchmark/ calibration corpus + runner | ||
| ``` |
+1
-1
@@ -6,3 +6,3 @@ "use strict"; | ||
| // | ||
| // The ordering is the point (see MCP_ADAPTER_PROMPT.md): enumerating a stdio | ||
| // The ordering is the point (see docs/design/mcp-adapter-prompt.md): enumerating a stdio | ||
| // server SPAWNS it, so when the server ships as a package/local ref the | ||
@@ -9,0 +9,0 @@ // static, no-execution scan (guardExtension — the same call `pkgxray guard` |
+75
-7
@@ -24,2 +24,3 @@ "use strict"; | ||
| const { spawn } = require("node:child_process"); | ||
| const fs = require("node:fs"); | ||
| const http = require("node:http"); | ||
@@ -29,2 +30,3 @@ const https = require("node:https"); | ||
| const net = require("node:net"); | ||
| const path = require("node:path"); | ||
@@ -52,7 +54,10 @@ // Newest protocol revision this client knows; servers negotiate down in the | ||
| // SECURITY: PATH is deliberately NOT inherited from the operator. The child's | ||
| // `command` is untrusted (the package we're inspecting picks it), and it is | ||
| // resolved against PATH with cwd=process.cwd(). If the operator's PATH contained | ||
| // a package-writable dir (e.g. `node_modules/.bin/` shims, or `.` early on the | ||
| // path), the "scrubbed" spawn would hand binary selection to the very package | ||
| // we're auditing. We pin a minimal, fixed system PATH instead (see MINIMAL_PATH). | ||
| // `command` is untrusted (the package we're inspecting picks it). If the | ||
| // operator's PATH contained a package-writable dir (e.g. `node_modules/.bin/` | ||
| // shims, or `.` early on the path), spawning against it would hand binary | ||
| // selection to the very package we're auditing. We pin a minimal, fixed system | ||
| // PATH in the child env instead (see MINIMAL_PATH), and resolve bare command | ||
| // names in the PARENT via resolveCommand(): fixed system dirs first, then the | ||
| // operator's PATH with relative + node_modules entries stripped (warned when | ||
| // used). The child's own subprocess resolution stays pinned to MINIMAL_PATH. | ||
| const ENV_ALLOWLIST = [ | ||
@@ -122,2 +127,60 @@ "HOME", | ||
| function isExecutableFile(candidate) { | ||
| try { | ||
| const st = fs.statSync(candidate); | ||
| if (!st.isFile()) return false; | ||
| if (process.platform !== "win32") fs.accessSync(candidate, fs.constants.X_OK); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| // Resolve a bare command name to an absolute path in the PARENT process, so | ||
| // binary selection never depends on the child's scrubbed environment. (spawn | ||
| // resolves bare names against the CHILD's env.PATH = MINIMAL_PATH, which made | ||
| // every launcher outside the fixed system dirs — /opt/homebrew/bin/node, | ||
| // ~/.local/bin shims — fail with ENOENT even when the operator's shell finds | ||
| // them.) Two tiers, strictest first: | ||
| // 1. MINIMAL_PATH — fixed system dirs a scanned package cannot write to. | ||
| // 2. The operator's PATH, minus the entries that break that guarantee: | ||
| // relative dirs ("." / "") and anything under node_modules (package- | ||
| // writable bin shims). A hit here is allowed — the operator typed the | ||
| // command and their shell would resolve it identically — but it is | ||
| // surfaced as a diagnostic warning naming the exact binary chosen. | ||
| // Explicit paths (anything containing a separator) pass through untouched, and | ||
| // an unresolved name passes through so spawn reports its usual ENOENT. | ||
| function resolveCommand(command, warnings) { | ||
| if (command.includes("/") || command.includes("\\")) return command; | ||
| const exts = | ||
| process.platform === "win32" | ||
| ? ["", ...(process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";")] | ||
| : [""]; | ||
| const findIn = (dirs) => { | ||
| for (const dir of dirs) { | ||
| for (const ext of exts) { | ||
| const candidate = path.join(dir, command + ext); | ||
| if (isExecutableFile(candidate)) return candidate; | ||
| } | ||
| } | ||
| return null; | ||
| }; | ||
| const minimalDirs = MINIMAL_PATH.split(path.delimiter); | ||
| const system = findIn(minimalDirs); | ||
| if (system) return system; | ||
| const operatorDirs = (process.env.PATH || "") | ||
| .split(path.delimiter) | ||
| .filter((dir) => dir && path.isAbsolute(dir) && !/node_modules/i.test(dir)); | ||
| const operator = findIn(operatorDirs); | ||
| if (operator) { | ||
| if (Array.isArray(warnings)) { | ||
| warnings.push( | ||
| `command "${command}" resolved outside the minimal system PATH: ${operator}` | ||
| ); | ||
| } | ||
| return operator; | ||
| } | ||
| return command; | ||
| } | ||
| function initializeRequest(id) { | ||
@@ -216,3 +279,3 @@ return { | ||
| const child = spawn(command, args || [], { | ||
| const child = spawn(resolveCommand(command, warnings), args || [], { | ||
| cwd: options.cwd || process.cwd(), | ||
@@ -375,3 +438,7 @@ env: scrubbedEnv(options.extraEnv), | ||
| child.on("error", (error) => { | ||
| failAll(rpcError(`failed to spawn server: ${error.message}`, "spawn")); | ||
| const hint = | ||
| error.code === "ENOENT" | ||
| ? ` — command not found on the minimal system PATH (${MINIMAL_PATH}) or the operator's PATH` | ||
| : ""; | ||
| failAll(rpcError(`failed to spawn server: ${error.message}${hint}`, "spawn")); | ||
| }); | ||
@@ -774,2 +841,3 @@ child.on("exit", () => { | ||
| parseSseMessages, | ||
| resolveCommand, | ||
| scrubbedEnv, | ||
@@ -776,0 +844,0 @@ // Exported for the SSRF-guard regression tests. |
+35
-4
@@ -210,7 +210,23 @@ "use strict"; | ||
| if (process.platform === "linux" && has("bwrap")) { | ||
| // bubblewrap: bind the sandbox root rw, everything else ro, keep net (so | ||
| // loopback→proxy works; the proxy is what actually denies real egress). | ||
| // bubblewrap: bind the sandbox root rw, everything else ro, and isolate the | ||
| // process/IPC/hostname namespaces. Net stays SHARED so loopback→proxy still | ||
| // 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. | ||
| return { | ||
| level: "bwrap", | ||
| wrap: (argv) => ["bwrap", "--ro-bind", "/", "/", "--bind", sandboxRoot, sandboxRoot, "--dev", "/dev", "--proc", "/proc", ...argv] | ||
| wrap: (argv) => [ | ||
| "bwrap", | ||
| "--ro-bind", "/", "/", | ||
| "--bind", sandboxRoot, sandboxRoot, | ||
| "--dev", "/dev", | ||
| "--proc", "/proc", | ||
| "--unshare-pid", | ||
| "--unshare-ipc", | ||
| "--unshare-uts", | ||
| "--die-with-parent", | ||
| "--new-session", | ||
| ...argv | ||
| ] | ||
| }; | ||
@@ -408,5 +424,19 @@ } | ||
| // Decide isolation up front so a caller that DEMANDS real OS-level confinement | ||
| // (--require-sandbox) fails closed BEFORE we seed decoys, start the proxy, or | ||
| // execute anything, rather than silently running with env-only isolation. | ||
| const wrapperInfo = detectSandboxWrapper(root); | ||
| if (options.requireSandbox === true && wrapperInfo.level === "env-only") { | ||
| await fsp.rm(root, { recursive: true, force: true }).catch(() => {}); | ||
| const err = new Error( | ||
| "canary --require-sandbox: no OS sandbox wrapper (bwrap / sandbox-exec) is available; " + | ||
| "refusing to execute untrusted install scripts with env-only isolation. " + | ||
| "Install bubblewrap (Linux) or run on macOS with sandbox-exec, or drop --require-sandbox to accept env-only." | ||
| ); | ||
| err.code = "SANDBOX_REQUIRED"; | ||
| throw err; | ||
| } | ||
| const canary = await seedCanaryFilesystem(home, runId); | ||
| const proxy = await startCaptureProxy(new Set(canary.tokens.keys())); | ||
| const wrapperInfo = detectSandboxWrapper(root); | ||
@@ -469,2 +499,3 @@ const proxyUrl = `http://127.0.0.1:${proxy.port}`; | ||
| isolation: wrapperInfo.level, | ||
| isolationRequired: options.requireSandbox === true, | ||
| sandboxRoot: options.keepSandbox ? root : null, | ||
@@ -471,0 +502,0 @@ timeoutMs, |
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
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.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
584590
2.74%12948
2.65%474
-13.97%39
5.41%