+31
| { | ||
| "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", | ||
| "name": "io.github.adamsjack711-ux/pkgxray", | ||
| "title": "pkgxray", | ||
| "description": "Pre-install security scans for npm packages, MCP servers, and AI agents with cited verdict evidence.", | ||
| "websiteUrl": "https://pkgxray.ca/", | ||
| "repository": { | ||
| "url": "https://github.com/adamsjack711-ux/pkgxray", | ||
| "source": "github", | ||
| "id": "1276320499" | ||
| }, | ||
| "version": "1.0.5", | ||
| "packages": [ | ||
| { | ||
| "registryType": "npm", | ||
| "registryBaseUrl": "https://registry.npmjs.org", | ||
| "identifier": "pkgxray", | ||
| "version": "1.0.5", | ||
| "runtimeHint": "npx", | ||
| "transport": { | ||
| "type": "stdio" | ||
| }, | ||
| "packageArguments": [ | ||
| { | ||
| "type": "positional", | ||
| "value": "mcp-server" | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } |
+45
-13
@@ -15,2 +15,3 @@ #!/usr/bin/env node | ||
| const cfg = require("../src/config"); | ||
| const { version: PACKAGE_VERSION } = require("../package.json"); | ||
@@ -31,16 +32,9 @@ // Map a config-adjusted verdict (safe|review|block, from applyConfig, which has | ||
| [ | ||
| "Usage:", | ||
| " pkgxray < evidence.json", | ||
| " pkgxray --format json < evidence.json", | ||
| " pkgxray --file evidence.json --format markdown", | ||
| "Usage: pkgxray <command> [options]", | ||
| "", | ||
| "Common commands — vet before you install or connect:", | ||
| " pkgxray guard <npm-package|npm:name@version|github:owner/repo[#ref]|./path> [--promote-to dir] [--no-source-scan] [--deps]", | ||
| " # vet a package before install (static; no package code runs).", | ||
| " # --deps also OSV-scans the package's DIRECT dependencies (transitive worm entry point)", | ||
| " 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", | ||
| " pkgxray triage <lockfile> [--include-safe] [--auto allow|block] # interactive allow/block walkthrough", | ||
| " pkgxray triage --resume # resume interrupted triage", | ||
| " pkgxray recheck <lockfile> [--verbose] [--no-write] # re-evaluate pinned deps; diff verdict vs. stored baseline", | ||
| " [--no-version-drift] [--fail-on-available-updates] # + pre-vet newer versions (informational unless --fail-on-...)", | ||
| " pkgxray mcp [flags] <https-url | command [args...]> # enumerate an MCP server's tool manifest (read-only handshake)", | ||
@@ -50,2 +44,18 @@ " [--package <ref>] [--no-package-scan] [--force] # package-scan-first: guard the ref BEFORE connecting; block halts", | ||
| " [--pin] [--recheck] [--lock <path>] # pin the approved manifest / diff live manifest vs. the pin (rug-pull)", | ||
| " pkgxray recheck <lockfile> [--verbose] [--no-write] # re-evaluate pinned deps; diff verdict vs. stored baseline", | ||
| " [--no-version-drift] [--fail-on-available-updates] # + pre-vet newer versions (informational unless --fail-on-...)", | ||
| "", | ||
| " Exit codes: 0 safe/allow · 2 block · 3 review.", | ||
| "", | ||
| "Evidence in / out (no acquisition, just render a verdict):", | ||
| " pkgxray < evidence.json", | ||
| " pkgxray --format json < evidence.json", | ||
| " pkgxray --file evidence.json --format markdown", | ||
| "", | ||
| "Advanced:", | ||
| " pkgxray canary <ref> --yes-run-untrusted-code [--timeout ms] [--keep-sandbox] [--require-sandbox] [--no-import-phase]", | ||
| " # OPT-IN: EXECUTES untrusted package code (install + import) inside the sandbox — the one path that", | ||
| " # runs the package. Decoy-credential HOME behind a capture proxy that never forwards egress; confirms", | ||
| " # exfil behaviorally (cannot clear a pkg). Requires --yes-run-untrusted-code (or PKGXRAY_ALLOW_EXECUTION=1).", | ||
| " # --require-sandbox fails closed without an OS sandbox (bwrap/sandbox-exec). See docs/canary-threat-model.md", | ||
| " pkgxray mcp-proxy [flags] [--] <command [args...]> # run a stdio MCP server behind a per-call runtime gate: every tools/call", | ||
@@ -55,4 +65,8 @@ " [--policy strict|balanced|permissive] # is checked in-memory (µs), the manifest is re-audited on every", | ||
| " [--no-scan-results] [--timing] # and tool RESULTS are scanned for injection (use in host config)", | ||
| " pkgxray triage <lockfile> [--include-safe] [--auto allow|block] # interactive allow/block walkthrough", | ||
| " pkgxray triage --resume # resume interrupted triage", | ||
| " pkgxray mcp-server # run pkgxray ITSELF as a stdio MCP server (for MCP hosts /", | ||
| " # the MCP Registry entry: `npx -y pkgxray mcp-server`) # exposes audit/guard/lockfile tools. cf. `mcp` above, which audits.", | ||
| " pkgxray serve-mcp # alias for mcp-server (run the local stdio MCP server)", | ||
| " pkgxray --version", | ||
| "", | ||
@@ -68,3 +82,9 @@ "Evidence JSON fields:", | ||
| const options = { command: "audit", format: "markdown", file: null }; | ||
| if (argv[0] === "guard") { | ||
| if (argv[0] === "--version" || argv[0] === "-V") { | ||
| options.command = "version"; | ||
| argv = []; | ||
| } else if (argv[0] === "serve-mcp") { | ||
| options.command = "serveMcp"; | ||
| argv = argv.slice(1); | ||
| } else if (argv[0] === "guard") { | ||
| options.command = "guard"; | ||
@@ -210,2 +230,4 @@ options.reference = argv[1]; | ||
| options.requireSandbox = true; | ||
| } else if (arg === "--no-import-phase") { | ||
| options.importPhase = false; | ||
| } else if (arg === "--timeout") { | ||
@@ -277,2 +299,10 @@ options.timeoutMs = Number(argv[++i]); | ||
| const options = parseArgs(process.argv.slice(2)); | ||
| if (options.command === "version") { | ||
| process.stdout.write(`${PACKAGE_VERSION}\n`); | ||
| return; | ||
| } | ||
| if (options.command === "serveMcp") { | ||
| require("./mcp-server").attachStdin(); | ||
| return; | ||
| } | ||
| if (options.help) { | ||
@@ -394,3 +424,4 @@ printUsage(); | ||
| keepSandbox: options.keepSandbox, | ||
| requireSandbox: options.requireSandbox | ||
| requireSandbox: options.requireSandbox, | ||
| importPhase: options.importPhase | ||
| }); | ||
@@ -661,2 +692,3 @@ if (options.format === "json") { | ||
| `Isolation: ${sanitizeForTerminal(behavioral.isolation)} · run ${sanitizeForTerminal(behavioral.runId)}`, | ||
| `Phases detonated: install${behavioral.executed && behavioral.executed.importPhase && behavioral.executed.importPhase.attempted ? " + import" : " only"}`, | ||
| "" | ||
@@ -663,0 +695,0 @@ ]; |
+136
-14
@@ -5,2 +5,4 @@ #!/usr/bin/env node | ||
| const fs = require("node:fs"); | ||
| const path = require("node:path"); | ||
| const os = require("node:os"); | ||
| const { auditEvidence, renderMarkdown } = require("../src/auditor"); | ||
@@ -66,4 +68,28 @@ const { guardExtension, decisionForReport } = require("../src/quarantine"); | ||
| const HOME_DIR = require("node:os").homedir(); | ||
| const HOME_DIR = os.homedir(); | ||
| const ALLOWED_ROOTS_ENV = "PKGXRAY_MCP_ALLOWED_ROOTS"; | ||
| // Filesystem-capable MCP tools are limited by the operator, never by a tool | ||
| // argument an LLM can set. The default is the server's startup directory. | ||
| // Multiple roots use the platform path delimiter (`:` on Unix, `;` on Windows). | ||
| function loadAllowedRoots() { | ||
| const configured = process.env[ALLOWED_ROOTS_ENV]; | ||
| const values = configured === undefined | ||
| ? [process.cwd()] | ||
| : configured.split(path.delimiter).filter(Boolean); | ||
| const roots = []; | ||
| for (const value of values) { | ||
| try { | ||
| roots.push(fs.realpathSync(path.resolve(value))); | ||
| } catch { | ||
| process.stderr.write( | ||
| `pkgxray: ignoring unreadable ${ALLOWED_ROOTS_ENV} entry\n` | ||
| ); | ||
| } | ||
| } | ||
| return [...new Set(roots)]; | ||
| } | ||
| const MCP_ALLOWED_ROOTS = loadAllowedRoots(); | ||
| function send(message) { | ||
@@ -80,4 +106,11 @@ process.stdout.write(`${JSON.stringify(message)}\n`); | ||
| name: AUDIT_TOOL_NAME, | ||
| title: "Audit supplied package evidence", | ||
| description: | ||
| "Audit evidence for an AI coding-agent extension, Codex plugin, Claude Code extension, or MCP server and return a conservative supply-chain security verdict. Pure static analysis — accepts caller-supplied npm metadata, GitHub metadata, source files, vulnerability list, and optional npm provenance attestation. Use when you already have evidence in hand; for live npm packages prefer guard_agent_extension_install.", | ||
| "Statically analyze caller-supplied package source and metadata. Returns a structured SAFE, REVIEW, or BLOCK report with cited findings; it does not read local files, install packages, or execute code. A SAFE result is defense in depth, not proof of harmlessness. For a live npm package, use guard_agent_extension_install.", | ||
| annotations: { | ||
| readOnlyHint: true, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: false | ||
| }, | ||
| inputSchema: { | ||
@@ -138,4 +171,11 @@ type: "object", | ||
| name: GUARD_TOOL_NAME, | ||
| title: "Guard a package before installation", | ||
| description: | ||
| "Stage an agent extension or npm package in a local quarantine directory, audit it without installing or running it, and optionally promote it if policy allows. Performs OSV vuln pre-check, downloads the tarball, runs static heuristics, cross-checks GitHub metadata, and automatically pulls the npm provenance attestation. Use this for a single live package; for a whole project use audit_lockfile_supply_chain.", | ||
| "Fetch and stage one package reference (pin an exact version for reproducibility), then return a structured SAFE, REVIEW, or BLOCK report without running lifecycle scripts or package code. It performs network requests to npm, OSV, and optionally GitHub. promoteTo writes files only after policy allows; force may replace an existing destination. Local references and caller-supplied paths are confined to operator-approved roots.", | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: true, | ||
| idempotentHint: false, | ||
| openWorldHint: true | ||
| }, | ||
| inputSchema: { | ||
@@ -148,7 +188,7 @@ type: "object", | ||
| description: | ||
| "Extension reference: npm package, npm:name@version, file:path, or local directory path." | ||
| "Exact package reference, preferably npm:name@version. Local file:path or directory references must resolve under an operator-approved root." | ||
| }, | ||
| quarantineRoot: { | ||
| type: "string", | ||
| description: "Optional quarantine root. Defaults to the OS temp directory." | ||
| description: "Optional staging root under an operator-approved filesystem root. Omit to use an internally selected OS temporary directory." | ||
| }, | ||
@@ -158,3 +198,3 @@ promoteTo: { | ||
| description: | ||
| "Optional destination directory. The staged package is copied here only when policy allows." | ||
| "Optional destination under an operator-approved root. Files are copied only when policy allows; force can replace an existing destination." | ||
| }, | ||
@@ -209,4 +249,11 @@ policy: { | ||
| name: LOCKFILE_AUDIT_TOOL_NAME, | ||
| title: "Audit a dependency manifest", | ||
| description: | ||
| "Batch-scan every dependency in a package-lock.json, yarn.lock, pnpm-lock.yaml, or package.json against OSV. Returns one decision (safe / review / block) per unique name@version. Pre-existing .pkgxray.lock triage decisions next to the lockfile are honored. Use this to audit a project's full dependency tree in one shot.", | ||
| "Read a dependency manifest under an operator-approved root and query OSV for each resolved dependency. Returns structured SAFE, REVIEW, or BLOCK decisions and honors a sibling .pkgxray.lock; it does not install dependencies or execute package code. deep/deepAll add npm and GitHub network scans.", | ||
| annotations: { | ||
| readOnlyHint: true, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: true | ||
| }, | ||
| inputSchema: { | ||
@@ -219,3 +266,3 @@ type: "object", | ||
| description: | ||
| "Absolute or relative path to a package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, or package.json. Must exist." | ||
| "Path to a package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, or package.json under an operator-approved root. Must be a readable regular file." | ||
| }, | ||
@@ -252,4 +299,11 @@ deep: { | ||
| name: LOCKFILE_TRIAGE_TOOL_NAME, | ||
| title: "Record dependency triage decisions", | ||
| description: | ||
| "Non-interactive triage of a lockfile — auto-mark every flagged dep as allow or block, persisted to a sibling .pkgxray.lock next to the lockfile. Subsequent audit_lockfile_supply_chain runs respect those decisions. Required for MCP because interactive TTY input is not available; choose mode='block' to record current OSV findings as suppressions or mode='allow' to accept them.", | ||
| "Read a manifest under an operator-approved root and write bulk allow or block decisions to its sibling .pkgxray.lock. This changes future audit policy for every selected dependency: auto=allow suppresses those findings, while auto=block preserves rejection. MCP has no interactive confirmation, so review the target and mode before calling.", | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: true, | ||
| idempotentHint: true, | ||
| openWorldHint: true | ||
| }, | ||
| inputSchema: { | ||
@@ -262,3 +316,3 @@ type: "object", | ||
| description: | ||
| "Absolute or relative path to a package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, or package.json. Must exist." | ||
| "Path to a package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, or package.json under an operator-approved root. A sibling .pkgxray.lock will be written." | ||
| }, | ||
@@ -320,2 +374,49 @@ auto: { | ||
| function localReferencePath(reference) { | ||
| let value = reference.startsWith("file:") ? reference.slice(5) : reference; | ||
| if (value === "~") value = HOME_DIR; | ||
| else if (value.startsWith("~/")) value = path.join(HOME_DIR, value.slice(2)); | ||
| try { | ||
| return decodeURIComponent(value); | ||
| } catch { | ||
| return value; | ||
| } | ||
| } | ||
| function isWithinAllowedRoot(candidate) { | ||
| return MCP_ALLOWED_ROOTS.some((root) => { | ||
| const relative = path.relative(root, candidate); | ||
| return relative === "" || | ||
| (relative !== ".." && | ||
| !relative.startsWith(`..${path.sep}`) && | ||
| !path.isAbsolute(relative)); | ||
| }); | ||
| } | ||
| // Resolve symlinks in the existing prefix. This also handles destinations that | ||
| // do not exist yet without letting a symlinked parent escape an approved root. | ||
| function canonicalPath(candidate, mustExist) { | ||
| const absolute = path.resolve(candidate); | ||
| if (mustExist) return fs.realpathSync(absolute); | ||
| let existing = absolute; | ||
| while (!fs.existsSync(existing)) { | ||
| const parent = path.dirname(existing); | ||
| if (parent === existing) throw new Error("no readable parent"); | ||
| existing = parent; | ||
| } | ||
| const resolvedParent = fs.realpathSync(existing); | ||
| return path.resolve(resolvedParent, path.relative(existing, absolute)); | ||
| } | ||
| function resolveOperatorPath(candidate, { mustExist = true } = {}) { | ||
| try { | ||
| const resolved = canonicalPath(candidate, mustExist); | ||
| if (!isWithinAllowedRoot(resolved)) return null; | ||
| return resolved; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| // SECURITY: error messages from auditor / quarantine paths can include | ||
@@ -352,5 +453,12 @@ // absolute filesystem paths. The MCP reply goes back to a possibly-hostile | ||
| } | ||
| if (isLocalReference(args.reference) && args.allowLocalReferences !== true) { | ||
| return "local-path references are disabled over MCP — set allowLocalReferences:true to opt in (intended for trusted CLI bridges only)"; | ||
| if (args.allowLocalReferences !== undefined) { | ||
| return "allowLocalReferences is not supported; only the server operator can grant filesystem roots"; | ||
| } | ||
| if (isLocalReference(args.reference)) { | ||
| const resolved = resolveOperatorPath(localReferencePath(args.reference)); | ||
| if (!resolved) { | ||
| return "local reference is unreadable or outside the operator-approved filesystem roots"; | ||
| } | ||
| args.reference = resolved; | ||
| } | ||
| for (const k of ["quarantineRoot", "promoteTo"]) { | ||
@@ -360,4 +468,11 @@ if (args[k] !== undefined && (typeof args[k] !== "string" || args[k].includes("\0"))) { | ||
| } | ||
| if (args[k] !== undefined) { | ||
| const resolved = resolveOperatorPath(args[k], { mustExist: false }); | ||
| if (!resolved) { | ||
| return `${k} is outside the operator-approved filesystem roots`; | ||
| } | ||
| args[k] = resolved; | ||
| } | ||
| } | ||
| for (const k of ["sourceScan", "vulnerabilityCheck", "githubMetadata", "githubDiff", "force", "deep", "allowLocalReferences"]) { | ||
| for (const k of ["sourceScan", "vulnerabilityCheck", "githubMetadata", "githubDiff", "force", "deep"]) { | ||
| if (args[k] !== undefined && typeof args[k] !== "boolean") { | ||
@@ -387,2 +502,7 @@ return `${k} must be a boolean`; | ||
| } | ||
| const resolved = resolveOperatorPath(args.lockfilePath); | ||
| if (!resolved) { | ||
| return "lockfilePath is unreadable or outside the operator-approved filesystem roots"; | ||
| } | ||
| args.lockfilePath = resolved; | ||
| if (toolName === LOCKFILE_TRIAGE_TOOL_NAME) { | ||
@@ -854,3 +974,5 @@ if (args.auto !== "allow" && args.auto !== "block") { | ||
| CONFIG_TOOL_KEY, | ||
| startStdioServer: attachStdin | ||
| startStdioServer: attachStdin, | ||
| attachStdin, | ||
| resolveOperatorPath | ||
| }; |
+17
-4
| { | ||
| "name": "pkgxray", | ||
| "version": "1.0.4", | ||
| "version": "1.0.5", | ||
| "mcpName": "io.github.adamsjack711-ux/pkgxray", | ||
| "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.", | ||
| "description": "pkgxray — pre-install security for npm packages, MCP servers, and AI agents. Zero-dependency local static analysis with cited SAFE, REVIEW, or BLOCK verdicts.", | ||
| "license": "MIT", | ||
@@ -17,2 +17,3 @@ "author": "Jack Adams-Lovell", | ||
| "src/", | ||
| "server.json", | ||
| "README.md", | ||
@@ -24,6 +25,9 @@ "LICENSE" | ||
| "test": "node --test", | ||
| "test:docs": "node --test ./test/docs-smoke.test.js", | ||
| "benchmark": "node ./benchmark/run.js", | ||
| "validate:website": "node ./website/stats/build.mjs && node ./website/validate.mjs", | ||
| "audit:evidence": "node ./bin/audit.js", | ||
| "mcp": "node ./bin/mcp-server.js", | ||
| "cache": "node ./bin/pkgxray-cache.js" | ||
| "cache": "node ./bin/pkgxray-cache.js", | ||
| "verify:netns": "node ./scripts/verify-netns-confinement.js" | ||
| }, | ||
@@ -34,3 +38,3 @@ "repository": { | ||
| }, | ||
| "homepage": "https://github.com/adamsjack711-ux/pkgxray#readme", | ||
| "homepage": "https://pkgxray.ca/", | ||
| "bugs": { | ||
@@ -40,5 +44,14 @@ "url": "https://github.com/adamsjack711-ux/pkgxray/issues" | ||
| "keywords": [ | ||
| "ai-agent-security", | ||
| "mcp", | ||
| "mcp-security", | ||
| "model-context-protocol", | ||
| "security", | ||
| "npm-security", | ||
| "package-security", | ||
| "package-scanner", | ||
| "pre-install", | ||
| "static-analysis", | ||
| "supply-chain", | ||
| "supply-chain-security", | ||
| "npm-audit", | ||
@@ -45,0 +58,0 @@ "agent-extension" |
+97
-224
| <div align="center"> | ||
| # pkgxray | ||
| # pkgxray — pre-install security for npm packages, MCP servers, and AI agents | ||
| **Supply-chain security for AI agents, npm packages, and Model Context Protocol (MCP) servers.** | ||
| **Inspect an npm package or MCP server before you install or connect to it, and | ||
| get a deterministic, evidence-backed `SAFE`, `REVIEW`, or `BLOCK` verdict.** | ||
| Local, zero-dependency static analysis — normal scans never execute package 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) | ||
@@ -15,247 +14,124 @@ [](https://github.com/adamsjack711-ux/pkgxray/actions/workflows/pkgxray-test.yml) | ||
| **Static analysis** · **Supply-chain intelligence** · **Prompt-injection detection** · | ||
| **MCP security** · `SAFE` / `REVIEW` / `BLOCK` | ||
| <img src="docs/demo/hero.gif" alt="pkgxray guard clearing express@4.21.0 with a SAFE A+ verdict, then blocking a trojaned sample with a BLOCK F verdict and a HIGH credential-access finding" width="820"> | ||
| <img src="docs/demo/hero.gif" alt="pkgxray guard clearing express@4.21.0 with a SAFE A+ verdict, then blocking a trojaned sample with a BLOCK F verdict and a HIGH credential-access finding citing the wallet-read and exfiltration code" width="820"> | ||
| <sub>Real runs: `guard` clears `express@4.21.0`, then blocks a sample modeled on | ||
| the 2024 `@solana/web3.js` compromise. **[▶ 60-second walkthrough](#demo)**</sub> | ||
| the 2024 `@solana/web3.js` compromise.</sub> | ||
| </div> | ||
| ## Why | ||
| AI coding assistants install packages and connect to MCP servers at machine | ||
| speed, often without a human reading the code. Sonatype identified **more than | ||
| 454,600 new malicious open-source packages across monitored ecosystems in | ||
| 2025**, over 99% of them on npm | ||
| ([Sonatype](https://www.sonatype.com/state-of-the-software-supply-chain/2026/open-source-malware)). | ||
| `npm audit` asks *does this have a known CVE?*; pkgxray also asks *what does the | ||
| code actually do* — before anything installs. | ||
| ## Quick start | ||
| **1. Scan a known-benign package** (no install of pkgxray needed): | ||
| ```bash | ||
| npm install -g pkgxray # or zero-install: npx pkgxray … | ||
| pkgxray guard npm:express@4.21.0 | ||
| npx --yes pkgxray@1.0.5 guard npm:express@4.21.0 | ||
| ``` | ||
| It stages the tarball in quarantine and runs the static and supply-chain checks | ||
| — no `npm install`, no lifecycle scripts, no package code executed. | ||
| ```text | ||
| Decision: **SAFE** | ||
| Grade: **A+** (99/100) | ||
| Decision: SAFE Grade: A+ (99/100) | ||
| No high- or medium-risk indicators were found in the provided evidence. | ||
| 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) | ||
| … | ||
| ``` | ||
| <sub>Real output, abridged. A `BLOCK` verdict instead lists every finding with | ||
| the file and evidence that produced it.</sub> | ||
| **2. Read the verdict:** | ||
| Point it at a package, get a verdict with cited evidence — before a single | ||
| line of that package runs. `guard` 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. | ||
| | Verdict | Exit | Meaning | | ||
| |---|---:|---| | ||
| | `SAFE` | `0` | No high- or medium-risk indicators; default policy permits promotion. | | ||
| | `REVIEW` | `3` | Evidence is incomplete or a privileged capability needs human review. | | ||
| | `BLOCK` | `2` | High-severity cited evidence — reject or investigate. | | ||
| ## Why pkgxray? | ||
| `SAFE` is not a proof that a package is harmless; static analysis cannot see a | ||
| payload downloaded only at runtime. See the [threat model](docs/threat-model.md). | ||
| AI coding assistants install packages and connect to MCP servers at machine | ||
| speed, often without a human ever reading the code — and the registry they | ||
| pull from is under industrial-scale attack: roughly **455,000 malicious npm | ||
| packages were published in 2025**, one every ~20 seconds by Q4 | ||
| ([Sonatype](https://www.sonatype.com/blog/open-source-malware-index-q4-2025-automation-overwhelms-ecosystems)). | ||
| Traditional antivirus inspects what *executes*; **pkgxray inspects what gets | ||
| *installed***. | ||
| **3. See a BLOCK on the supplied inert fixture:** | ||
| `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**: what the code actually does, whether the published npm | ||
| artifact matches the tagged GitHub source, whether the provenance attestation | ||
| is consistent with the claimed repository, and whether the docs carry a | ||
| prompt-injection payload aimed at the agent reading them. | ||
| ```bash | ||
| npx --yes pkgxray@1.0.5 --file examples/onboarding-malicious.json --format markdown | ||
| ``` | ||
| It is intentionally conservative: verdicts come from deterministic heuristics | ||
| (no LLM in the verdict path, so injected text can't steer them), only | ||
| citable evidence is reported, and the **zero-heuristic-false-block calibration | ||
| on the top-1000 most-downloaded packages** is | ||
| [regression-gated in CI](docs/benchmark.md). That claim is scoped to the | ||
| most-installed set — it is *not* a claim of zero false blocks on every package; | ||
| the newer MCP/agent-tooling ecosystem is over-blocked and being reconciled | ||
| per-case ([details](docs/benchmark.md#scope-of-the-claim-read-this-first)). | ||
| The fixture is inert source text modeling a split-string SSH-key read and | ||
| exfiltration — **it is never executed**. It returns `BLOCK` (exit `2`) with the | ||
| cited file and evidence. | ||
| ## What it catches | ||
| **4. Add it to your workflow** — [rechecks & CI](docs/reference.md#monitoring-pkgxray-recheck), | ||
| [MCP](docs/mcp.md#the-pkgxray-mcp-server), [Hookshot install gate](examples/hookshot/). | ||
| | Threat | Coverage | How pkgxray sees it | | ||
| |---|:-:|---| | ||
| | Credential theft | ✅ | reads of `.ssh` / `.aws` / `.npmrc` / `.env` / keychains / wallets, incl. split-fragment paths (`".s"+"sh"`) | | ||
| | Prompt injection | ✅ | tiered detection in docs, comments, metadata; deterministic verdict path can't be steered | | ||
| | Unicode smuggling | ✅ | invisible tag-block characters + Trojan Source bidi / zero-width | | ||
| | Base64 payloads | ✅ | encoded envelopes in docs/comments; blobs decoded into computed-arg `eval` / `new Function` / `child_process` | | ||
| | Exfiltration & loaders | ✅ | cross-file correlation: stage-2 loaders, `curl \| sh`, `process.env` harvesting near a network sink, EtherHiding | | ||
| | 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 | | ||
| | MCP capability abuse | ✅ | capability-surface mismatch in the manifest audit (a `get_weather` that also takes a `command`) | | ||
| | Runtime tool drift | ✅ | `mcp-proxy` re-audits on `tools/list_changed`; pinned-manifest drift is denied | | ||
| | Dependency confusion / typosquats | ◑ | callback beacons, repo-mismatch and provenance-mismatch signals; no name-similarity heuristic | | ||
| > **Two execution models.** Default `guard` and `audit` scans are **static** — | ||
| > package code is never executed. Enumerating an MCP server may spawn it and | ||
| > `mcp-proxy` runs it behind a gate; the opt-in | ||
| > [`canary`](docs/canary-threat-model.md) is the one deliberate exception that | ||
| > *executes* the package in a sandbox to confirm behavior — it can confirm | ||
| > malice but never prove a package safe. Full boundary: [SECURITY.md](SECURITY.md#scope). | ||
| <sub>✅ detected · ◑ partial / indirect</sub> | ||
| ## What it catches | ||
| **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 sandboxing when that risk matters. Full analysis: | ||
| [docs/threat-model.md](docs/threat-model.md). | ||
| Credential theft (incl. split-fragment paths), prompt injection, Unicode | ||
| smuggling, base64 payloads and stage-2 loaders, exfiltration, persistence, | ||
| obfuscated computed-arg execution, known CVEs (via OSV, before download), | ||
| npm↔GitHub artifact divergence, trojaned updates (`recheck`), and MCP | ||
| capability-surface abuse. Verdicts come from deterministic heuristics — no LLM | ||
| in the verdict path, so injected text can't steer them. Full matrix and the | ||
| known download-later blind spot: [docs/threat-model.md](docs/threat-model.md). | ||
| ## Beyond detection | ||
| - **Continuous monitoring** — [`pkgxray recheck`](docs/reference.md#monitoring-pkgxray-recheck) | ||
| diffs installed deps against a stored verdict baseline and pre-vets newer versions | ||
| - **MCP vetting** — `pkgxray mcp` audits a server's tool manifest before you | ||
| connect; `--pin` / `--recheck` catch the rug-pull; `pkgxray-mcp` gives any | ||
| agent the audit tools directly | ||
| - **Runtime 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, ~0.05 µs per-call | ||
| verdict, injection scan of tool results | ||
| - **Install gate** — a [hookshot](https://github.com/CorridorSecurity/hookshot) | ||
| hook runs `guard` on every package an agent tries to install, across Claude | ||
| Code, Cursor, Windsurf, Factory Droid, and Codex ([`examples/hookshot/`](examples/hookshot/)) | ||
| - **Policy engine** — one `.pkgxray.json` read by every surface; tighten | ||
| freely, every loosening is printed; CVEs can never be allowed away; fail closed | ||
| - **Opt-in behavioral canary** — [`pkgxray canary`](docs/canary-threat-model.md) | ||
| runs lifecycle scripts in an OS sandbox with decoy credentials; it can | ||
| *confirm* malice, never *clear* a package | ||
| ## Verdicts | ||
| | 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. | Inspect the quarantined copy before promoting. | | ||
| | 🔴 `BLOCK` | High-severity, cited evidence. | Do not install. Every finding names the file and evidence. | | ||
| Exit codes are stable and CI-friendly: **`0`** safe/allow · **`2`** block · | ||
| **`3`** review. The full signal-to-severity mapping is in the | ||
| [severity policy](docs/reference.md#severity-policy-what-lands-in-block--review--info). | ||
| ## Usage | ||
| **Vet an npm package before installing** | ||
| ```bash | ||
| pkgxray guard npm:some-package@1.2.3 [--format json] | ||
| pkgxray guard ./ext --promote-to ./approved/ext # local dir, promote if policy allows | ||
| pkgxray guard npm:some-package@1.2.3 [--format json] # vet a package before install | ||
| pkgxray mcp --package npm:some-mcp-server@1.4.2 npx some-mcp-server # vet an MCP server; --recheck catches the rug-pull | ||
| pkgxray audit package-lock.json [--deep] # also: yarn.lock, pnpm-lock.yaml, package.json | ||
| pkgxray recheck package-lock.json # scheduled: non-zero only on a regression | ||
| ``` | ||
| **Vet an MCP server before connecting** — full guide: [docs/mcp.md](docs/mcp.md) | ||
| Exit codes are stable and CI-friendly: **`0`** safe/allow · **`2`** block · | ||
| **`3`** review. | ||
| ```bash | ||
| pkgxray mcp --package npm:some-mcp-server@1.4.2 npx some-mcp-server | ||
| pkgxray mcp --recheck npx some-mcp-server # catch the rug-pull | ||
| ``` | ||
| ## Integrations | ||
| **Enforce in CI/CD** | ||
| One engine behind every entry point. "Works with" means a documented setup | ||
| guide, not a vendor-endorsed integration. | ||
| ```bash | ||
| pkgxray audit package-lock.json [--deep] # also: yarn.lock, pnpm-lock.yaml, package.json | ||
| npx pkgxray recheck package-lock.json # scheduled: exits non-zero only on a regression | ||
| ``` | ||
| | Where | What it does | Guide | | ||
| |---|---|---| | ||
| | Coding agents — Codex, Claude Code, Cursor, Windsurf | Gate installs and expose the audit tools to the agent | [coding-agents.md](docs/integrations/coding-agents.md) | | ||
| | MCP clients | Vet a server before connect; run pkgxray itself as an MCP server | [mcp.md](docs/mcp.md) | | ||
| | GitHub Actions / CI | Fail a build when a dependency crosses policy | [github-actions.md](docs/integrations/github-actions.md) | | ||
| | Install gate — Hookshot | Run `guard` on every package an agent tries to install | [examples/hookshot/](examples/hookshot/) | | ||
| | Runtime MCP gate | Proxy a live MCP server and gate every tool call | [`mcp-proxy`](docs/mcp.md#per-call-runtime-gate-pkgxray-mcp-proxy) | | ||
| | Dependency monitoring | Re-vet installed deps and pre-vet upgrades on a schedule | [`recheck`](docs/reference.md#monitoring-pkgxray-recheck) | | ||
| A ready-made GitHub Actions workflow and the self-hostable cache server | ||
| (`PKGXRAY_CACHE_URL`) are in the [reference](docs/reference.md#monitoring-pkgxray-recheck). | ||
| **Guard AI coding agents** | ||
| pkgxray is published on the [MCP Registry](https://registry.modelcontextprotocol.io) | ||
| as `io.github.adamsjack711-ux/pkgxray`. Add it to any MCP client — locally | ||
| installed (`pkgxray-mcp`) or zero-install via `npx`: | ||
| ```json | ||
| { "mcpServers": { "pkgxray": { "command": "pkgxray-mcp" } } } | ||
| ``` | ||
| ```json | ||
| { "mcpServers": { "pkgxray": { "command": "npx", "args": ["-y", "pkgxray", "mcp-server"] } } } | ||
| ``` | ||
| Gate installs with the [hookshot integration](examples/hookshot/) and wrap MCP | ||
| servers with [`pkgxray mcp-proxy`](docs/mcp.md#per-call-runtime-gate-pkgxray-mcp-proxy). | ||
| ## Configuration | ||
| One optional `.pkgxray.json`, read by every surface. Zero config means | ||
| maximum strictness. | ||
| ```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, `mute` / `mcp` blocks, and enforced invariants: | ||
| One optional `.pkgxray.json`, read by every surface; zero config means maximum | ||
| strictness. CVEs can never be allowed away, every loosening is printed, and a | ||
| scan that errors fails closed to `review`. Schema and invariants: | ||
| [docs/configuration.md](docs/configuration.md) · | ||
| [`.pkgxray.example.json`](.pkgxray.example.json) | ||
| [`.pkgxray.example.json`](.pkgxray.example.json). | ||
| ## Demo | ||
| ## Evidence | ||
| The 60-second walkthrough — the SAFE run, the blocked trojan with its exit | ||
| code, then a lockfile audit: | ||
| The **zero-heuristic-false-block calibration on the top-1000 most-downloaded | ||
| packages** is regression-gated in CI ([scope & methodology](docs/benchmark.md)), | ||
| and the published calibration runs live at <https://pkgxray.ca/stats>. That claim | ||
| is scoped to the most-installed set — not a claim of zero false blocks on every | ||
| package. | ||
| https://github.com/user-attachments/assets/b5a323b1-a9ec-4676-9601-1b284df81b6b | ||
| ## How it compares | ||
| <sub>All captures are real runs — reproduction steps in | ||
| [`docs/screenshots/`](docs/screenshots/README.md), which also shows the | ||
| MCP proxy, hookshot install gate, and browser extension in action.</sub> | ||
| Run pkgxray *alongside* `npm audit` / OSV-Scanner, not instead of them. The | ||
| full behavioral-vetting comparison (Socket.dev, OpenSSF Package Analysis, Cisco | ||
| MCP Scanner) is in [docs/comparison.md](docs/comparison.md). | ||
| ## Comparison | ||
| Designed to run *alongside* `npm audit` and | ||
| [OSV-Scanner](https://google.github.io/osv-scanner/), not replace them — they | ||
| match dependencies against known vulnerabilities; pkgxray adds the layers | ||
| they don't attempt: | ||
| | 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 | — | — | ✅ | | ||
| <sub>Scoped to npm supply-chain vetting, per each tool's public docs. | ||
| OSV-Scanner covers many ecosystems beyond npm, which pkgxray does not.</sub> | ||
| ## Architecture | ||
| <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"> | ||
| 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. | ||
| Principles: never execute untrusted code · citable evidence only · | ||
| minimize false positives · fail closed · zero runtime dependencies. | ||
| Details: [docs/architecture.md](docs/architecture.md) · | ||
| [docs/design.md](docs/design.md) | ||
| ## Performance | ||
| - **Local static analysis: ~25 ms** — a full guard of `express` is ~1.3–1.5 s | ||
| cold-cache, almost all network round-trips (Apple M1, Node 26) | ||
| - **Known-vulnerable packages block at the OSV pre-check**, before download | ||
| - **Calibration** (precision, recall, the 0-heuristic-false-block gate on the | ||
| top-1000 most-downloaded — [scope](docs/benchmark.md#scope-of-the-claim-read-this-first)) | ||
| is measured by a committed [benchmark corpus](benchmark/) that fails CI when it regresses | ||
| Full numbers: [docs/reference.md#performance](docs/reference.md#performance) · | ||
| methodology: [docs/benchmark.md](docs/benchmark.md) | ||
| ## Documentation | ||
@@ -265,13 +141,11 @@ | ||
| |---|---| | ||
| | [architecture.md](docs/architecture.md) | Pipeline, surfaces, repo layout | | ||
| | [architecture.md](docs/architecture.md) · [design.md](docs/design.md) | Pipeline, surfaces, principles | | ||
| | [threat-model.md](docs/threat-model.md) | Scope, blind spots, prompt-injection stance | | ||
| | [mcp.md](docs/mcp.md) | MCP server, connect-time vetting, runtime proxy | | ||
| | [configuration.md](docs/configuration.md) | `.pkgxray.json` schema and invariants | | ||
| | [reference.md](docs/reference.md) | Severity policy, `recheck`, JSON output, cache server | | ||
| | [benchmark.md](docs/benchmark.md) | Calibration benchmark & real-world validation | | ||
| | [compatibility.md](docs/compatibility.md) | The 1.0 compatibility contract | | ||
| | [json-schema.md](docs/json-schema.md) | Full `--format json` schema | | ||
| | [mcp.md](docs/mcp.md) · [mcp-registry.md](docs/mcp-registry.md) | MCP vetting, runtime proxy, registry entry | | ||
| | [canary-threat-model.md](docs/canary-threat-model.md) | The opt-in behavioral canary | | ||
| | [configuration.md](docs/configuration.md) · [reference.md](docs/reference.md) | `.pkgxray.json`, severity policy, `recheck`, cache server | | ||
| | [benchmark.md](docs/benchmark.md) · [comparison.md](docs/comparison.md) | Calibration and how it compares | | ||
| | [compatibility.md](docs/compatibility.md) · [json-schema.md](docs/json-schema.md) | 1.0 contract, `--format json` schema | | ||
| Start at the [documentation index](docs/README.md). Longer-term plans: | ||
| [adoption playbook](docs/adoption.md) and GitHub issues. | ||
| Start at the [documentation index](docs/README.md). | ||
@@ -283,11 +157,10 @@ ## Development | ||
| npm run benchmark # calibration corpus: precision/recall + 0-false-block gate | ||
| npm run build:browser # build the MV3 browser extension | ||
| npm run validate:website # regenerate + validate the calibration pages | ||
| ``` | ||
| ## Security & license | ||
| Contributions welcome — read [CONTRIBUTING.md](CONTRIBUTING.md) and the | ||
| [Code of Conduct](CODE_OF_CONDUCT.md). Report vulnerabilities per | ||
| [SECURITY.md](SECURITY.md). Releases publish to npm with provenance, gated on | ||
| tests, the calibration benchmark, and pkgxray's own supply-chain guard. | ||
| 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) |
+7
-0
@@ -45,4 +45,11 @@ "use strict"; | ||
| if (!entry || !entry.version) continue; | ||
| // Real registry deps are installed under a `node_modules/` path. A bare | ||
| // key like "packages/foo" or "apps/bar" is one of the project's OWN | ||
| // workspace packages (first-party source, not supply chain) — skip it | ||
| // like the root. This also avoids indexing an empty segment list, which | ||
| // crashed on `.replace` of undefined when such an entry carried no `name` | ||
| // (e.g. a workspace app whose package.json omits "name"). | ||
| // key looks like "node_modules/foo" or "node_modules/foo/node_modules/bar" | ||
| const segments = key.split("node_modules/").slice(1); | ||
| if (segments.length === 0) continue; // workspace/local package, not a registry dep | ||
| const name = entry.name || segments[segments.length - 1].replace(/\/$/, ""); | ||
@@ -49,0 +56,0 @@ add(deps, name, entry.version, [key]); |
+433
-81
@@ -146,17 +146,14 @@ "use strict"; | ||
| // Loopback HTTP/HTTPS capture proxy. Plaintext requests are read in full (URL, | ||
| // headers, body) and scanned for canary tokens; HTTPS CONNECTs record the | ||
| // target host only. NOTHING is forwarded — captured egress never leaves the | ||
| // machine, so the decoy tokens are safe even when the payload "sends" them. | ||
| function startCaptureProxy(tokenSet) { | ||
| 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. | ||
| // The capture HTTP/HTTPS server, factored out so the in-process proxy and the | ||
| // in-netns file-capture proxy (below) share ONE implementation of request | ||
| // parsing, token scanning, and CONNECT refusal. Plaintext requests are read in | ||
| // full (URL, headers, body) and scanned for canary tokens; HTTPS CONNECTs | ||
| // record the target host only. NOTHING is forwarded — captured egress never | ||
| // leaves the machine. `onHit(hit)` fires for every recorded hit; the socket set | ||
| // it returns lets the caller force-destroy lingering connections on teardown. | ||
| function createCaptureServer(tokenSet, onHit) { | ||
| 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. | ||
| // token, never the request — so the capture hot path doesn't re-encode all | ||
| // decoys on every hit. | ||
| const tokenIndex = Array.from(tokenSet, (token) => ({ token, variants: tokenVariants(token) })); | ||
@@ -170,2 +167,4 @@ const scan = (haystack) => { | ||
| }; | ||
| const record = (hit) => { try { onHit(hit); } catch { /* onHit must never break capture */ } }; | ||
| const server = http.createServer((req, res) => { | ||
@@ -196,3 +195,3 @@ const chunks = []; | ||
| } | ||
| hits.push({ transport: "http", method: req.method, host, url: req.url, tokensSeen, bodyBytes, truncated }); | ||
| record({ transport: "http", method: req.method, host, url: req.url, tokensSeen, bodyBytes, truncated }); | ||
| res.writeHead(204); | ||
@@ -214,3 +213,3 @@ res.end(); | ||
| const authTokens = scan(host); | ||
| hits.push({ transport: "https-connect", method: "CONNECT", host, url: `https://${host}`, tokensSeen: authTokens, bodyBytes: 0 }); | ||
| record({ transport: "https-connect", method: "CONNECT", host, url: `https://${host}`, tokensSeen: authTokens, bodyBytes: 0 }); | ||
| // No MITM: record the intended destination and refuse the tunnel so | ||
@@ -227,29 +226,92 @@ // nothing actually egresses. | ||
| return new Promise((resolve) => { | ||
| server.listen(0, "127.0.0.1", () => { | ||
| const { port } = server.address(); | ||
| resolve({ | ||
| port, | ||
| hits, | ||
| 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(); }); | ||
| }) | ||
| }); | ||
| // Bounded teardown: force-destroy any socket still open so server.close()'s | ||
| // callback (which only fires once EVERY connection ends) can't be wedged by a | ||
| // payload holding a keep-alive socket open. | ||
| const close = () => | ||
| new Promise((r) => { | ||
| let done = false; | ||
| const finish = () => { if (!done) { done = true; r(); } }; | ||
| const destroyTimer = setTimeout(() => { | ||
| for (const s of sockets) { try { s.destroy(); } catch { /* noop */ } } | ||
| }, 250); | ||
| if (destroyTimer.unref) destroyTimer.unref(); | ||
| const hardTimer = setTimeout(finish, 1500); | ||
| if (hardTimer.unref) hardTimer.unref(); | ||
| server.close(() => { clearTimeout(destroyTimer); clearTimeout(hardTimer); finish(); }); | ||
| }); | ||
| return { server, close }; | ||
| } | ||
| // Loopback HTTP/HTTPS capture proxy in the parent process. Listens on a TCP | ||
| // port (used by the env-only, sandbox-exec, and shared-net bwrap tiers, where | ||
| // the sandbox reaches the host loopback directly) and — when `unixPath` is | ||
| // given — ALSO on a Unix socket, which the netns tier's in-sandbox forwarder | ||
| // connects to across the network-namespace boundary (a path-based unix socket | ||
| // is filesystem-scoped, not netns-scoped, so it works where TCP loopback does | ||
| // not). Both listeners feed one shared `hits` array. | ||
| function startCaptureProxy(tokenSet, options = {}) { | ||
| const hits = []; | ||
| const tcp = createCaptureServer(tokenSet, (hit) => hits.push(hit)); | ||
| const unix = options.unixPath ? createCaptureServer(tokenSet, (hit) => hits.push(hit)) : null; | ||
| return new Promise((resolve, reject) => { | ||
| tcp.server.listen(0, "127.0.0.1", () => { | ||
| const { port } = tcp.server.address(); | ||
| const finish = () => | ||
| resolve({ | ||
| port, | ||
| unixPath: options.unixPath || null, | ||
| hits, | ||
| close: async () => { await tcp.close(); if (unix) await unix.close(); } | ||
| }); | ||
| if (!unix) return finish(); | ||
| unix.server.on("error", reject); | ||
| unix.server.listen(options.unixPath, finish); | ||
| }); | ||
| }); | ||
| } | ||
| // TCP→Unix forwarder, run INSIDE the sandbox's network namespace. It listens on | ||
| // loopback (127.0.0.1:innerPort) and pipes each connection, byte-for-byte, to | ||
| // the parent capture proxy's Unix socket. Because it is a dumb byte pipe, both | ||
| // plaintext HTTP and HTTPS CONNECT frames reach the capture proxy intact. The | ||
| // payload's HTTP(S)_PROXY points at innerPort; anything that bypasses the proxy | ||
| // and dials a real IP directly has no route out of the fresh netns and is | ||
| // refused by the kernel. Returns the server so a self-test can close it. | ||
| function startTcpToUnixForwarder(innerPort, sockPath, onReady) { | ||
| const net = require("node:net"); | ||
| const server = net.createServer((down) => { | ||
| const up = net.connect(sockPath); | ||
| down.on("error", () => up.destroy()); | ||
| up.on("error", () => down.destroy()); | ||
| down.pipe(up); | ||
| up.pipe(down); | ||
| }); | ||
| server.on("error", () => { /* surfaced to caller via not-ready */ }); | ||
| server.listen(innerPort, "127.0.0.1", () => { if (onReady) onReady(); }); | ||
| return server; | ||
| } | ||
| // Build the bootstrap shell that runs as the bwrap `--unshare-net` child: bring | ||
| // up loopback (a fresh netns starts with lo DOWN), start the forwarder and wait | ||
| // until it is listening, run the payload, then hold the forwarder open for the | ||
| // egress grace window so a delayed beacon is still forwarded before teardown. | ||
| // Pure string construction so it is unit-testable without a sandbox. | ||
| function buildNetnsBootstrap({ nodeBin, selfPath, innerPort, sockPath, readyFile, ipBin, graceMs, payloadCmd }) { | ||
| const q = shellQuote; | ||
| const graceS = Math.max(0, Math.ceil((Number(graceMs) || 0) / 1000)); | ||
| return [ | ||
| `${q(ipBin)} link set lo up 2>/dev/null`, | ||
| `${q(nodeBin)} ${q(selfPath)} __forwarder ${innerPort} ${q(sockPath)} ${q(readyFile)} &`, | ||
| `__fw=$!`, | ||
| // Wait (bounded ~5s) for the forwarder to signal ready, then run the payload. | ||
| `__i=0; while [ ! -f ${q(readyFile)} ] && [ $__i -lt 100 ]; do __i=$((__i+1)); sleep 0.05; done`, | ||
| payloadCmd, | ||
| `__status=$?`, | ||
| graceS > 0 ? `sleep ${graceS}` : `:`, | ||
| `kill $__fw 2>/dev/null`, | ||
| `exit $__status` | ||
| ].join("\n"); | ||
| } | ||
| // Escape a path for safe interpolation into an SBPL string literal so a path | ||
@@ -298,38 +360,8 @@ // containing a quote or backslash can't break out of / corrupt the sandbox | ||
| // 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] : []; | ||
| // The `bwrap+netns` tier (detectNetnsConfinement) upgrades this to | ||
| // netConfined:true when an unprivileged network namespace can be stood up. | ||
| return { | ||
| level: "bwrap", | ||
| netConfined: false, | ||
| wrap: (argv) => [ | ||
| "bwrap", | ||
| "--ro-bind", "/", "/", | ||
| ...maskRealHome, | ||
| "--bind", sandboxRoot, sandboxRoot, | ||
| "--dev", "/dev", | ||
| "--proc", "/proc", | ||
| "--unshare-pid", | ||
| "--unshare-ipc", | ||
| "--unshare-uts", | ||
| "--die-with-parent", | ||
| "--new-session", | ||
| ...argv | ||
| ] | ||
| wrap: (argv) => ["bwrap", ...bwrapBaseArgs(sandboxRoot), ...argv] | ||
| }; | ||
@@ -340,3 +372,191 @@ } | ||
| // The default (real) runner: execute the package's declared install lifecycle | ||
| // The bwrap arguments shared by the shared-net and netns tiers. 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; --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 — the `--tmpfs /` edge (os.homedir() === "/") would shadow | ||
| // the ro-bind of everything, including the staged package, making the payload | ||
| // unable to read its own package.json (a false "not-observed" without executing). | ||
| function bwrapBaseArgs(sandboxRoot) { | ||
| 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 [ | ||
| "--ro-bind", "/", "/", | ||
| ...maskRealHome, | ||
| "--bind", sandboxRoot, sandboxRoot, | ||
| "--dev", "/dev", | ||
| "--proc", "/proc", | ||
| "--unshare-pid", | ||
| "--unshare-ipc", | ||
| "--unshare-uts", | ||
| "--die-with-parent", | ||
| "--new-session" | ||
| ]; | ||
| } | ||
| function hasCmd(cmd) { | ||
| try { | ||
| return spawnSync("sh", ["-c", `command -v ${cmd}`], { encoding: "utf8" }).status === 0; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| // Resolve the loopback-up tool. A fresh network namespace starts with `lo` | ||
| // DOWN, so a loopback-only egress path needs `ip link set lo up`. iproute2's | ||
| // `ip` is the only dependency; absent it, the netns tier is unavailable and we | ||
| // fall back to shared-net bwrap. (busybox `ifconfig` is intentionally not used — | ||
| // keeping one well-known tool keeps the self-test's guarantee legible.) | ||
| function resolveIpBin() { | ||
| return hasCmd("ip") ? "ip" : null; | ||
| } | ||
| // Module-level cache: the netns capability is a property of the HOST (kernel + | ||
| // tooling), not of a given run, so the self-test runs at most once per process. | ||
| // undefined = not yet probed; false = unavailable; true = self-test passed. | ||
| let _netnsCapable; | ||
| const NETNS_INNER_PORT = 18080; // arbitrary; inside an isolated netns, no conflict | ||
| // Decide whether real network-namespace confinement is available, and if so | ||
| // return a per-run descriptor. Engages ONLY when bwrap + ip are present AND a | ||
| // live self-test proves, using the exact same machinery a real run uses, that | ||
| // (a) proxied egress is still captured and (b) a direct dial to a non-loopback | ||
| // IP is refused by the kernel (ENETUNREACH). Any failure → null → the caller | ||
| // falls back to shared-net bwrap. This is the safety contract: netConfined:true | ||
| // is asserted only after it has been demonstrated in THIS environment, never | ||
| // assumed. | ||
| async function detectNetnsConfinement(sandboxRoot) { | ||
| if (process.platform !== "linux") return null; | ||
| const ipBin = resolveIpBin(); | ||
| if (!hasCmd("bwrap") || !ipBin) return null; | ||
| if (_netnsCapable === undefined) { | ||
| _netnsCapable = await selfTestNetnsConfinement(ipBin).catch(() => false); | ||
| } | ||
| if (!_netnsCapable) return null; | ||
| const sockPath = path.join(sandboxRoot, "cap.sock"); | ||
| const readyFile = path.join(sandboxRoot, "fw.ready"); | ||
| return { | ||
| innerPort: NETNS_INNER_PORT, | ||
| sockPath, | ||
| // The final wrap depends on the run's grace window (the forwarder must | ||
| // outlive the payload long enough for the parent's egress-grace read), so | ||
| // the caller builds it once it knows graceMs. | ||
| build: (graceMs) => ({ | ||
| level: "bwrap+netns", | ||
| netConfined: true, | ||
| wrap: (argv) => netnsWrap(argv, { sandboxRoot, innerPort: NETNS_INNER_PORT, sockPath, readyFile, ipBin, graceMs }) | ||
| }) | ||
| }; | ||
| } | ||
| // Wrap the payload argv into a bwrap --unshare-net invocation whose child is the | ||
| // netns bootstrap (bring up lo, start the forwarder, run the payload). The base | ||
| // argv from execWithTimeout is ["sh","-c",<cmd>]; we rebuild <cmd> as the | ||
| // bootstrap wrapping the original command. | ||
| function netnsWrap(argv, opts) { | ||
| const cmd = argv[0] === "sh" && argv[1] === "-c" ? argv[2] : argv.join(" "); | ||
| const bootstrap = buildNetnsBootstrap({ | ||
| nodeBin: process.execPath, | ||
| selfPath: __filename, | ||
| innerPort: opts.innerPort, | ||
| sockPath: opts.sockPath, | ||
| readyFile: opts.readyFile, | ||
| ipBin: opts.ipBin, | ||
| graceMs: opts.graceMs, | ||
| payloadCmd: cmd | ||
| }); | ||
| return ["bwrap", ...bwrapBaseArgs(opts.sandboxRoot), "--unshare-net", "sh", "-c", bootstrap]; | ||
| } | ||
| // Spawn a raw argv (no shell), capture output, enforce a timeout with a | ||
| // process-group kill. Used by the netns self-test. | ||
| function spawnCapture(argv, { env, timeoutMs }) { | ||
| return new Promise((resolve) => { | ||
| let child; | ||
| try { | ||
| child = spawn(argv[0], argv.slice(1), { env, stdio: ["ignore", "pipe", "pipe"], detached: true }); | ||
| } catch (error) { | ||
| return resolve({ error: error.message, output: "", timedOut: false }); | ||
| } | ||
| let out = ""; | ||
| const cap = (c) => { if (out.length < 8192) out += c; }; | ||
| child.stdout.on("data", cap); | ||
| child.stderr.on("data", cap); | ||
| let timedOut = false; | ||
| const timer = setTimeout(() => { timedOut = true; killProcessGroup(child, "SIGKILL"); }, timeoutMs); | ||
| child.on("error", (e) => { clearTimeout(timer); resolve({ error: e.message, output: out, timedOut }); }); | ||
| child.on("close", (code) => { clearTimeout(timer); resolve({ exitCode: code, output: out, timedOut }); }); | ||
| }); | ||
| } | ||
| // The self-test: stand up the real machinery once and demand proof of BOTH | ||
| // confinement properties before ever reporting netConfined:true. | ||
| async function selfTestNetnsConfinement(ipBin) { | ||
| const root = await fsp.mkdtemp(path.join(os.tmpdir(), "npm-netns-selftest-")); | ||
| const token = `selftest-${crypto.randomBytes(8).toString("hex")}`; | ||
| let proxy; | ||
| try { | ||
| const sockPath = path.join(root, "cap.sock"); | ||
| const readyFile = path.join(root, "fw.ready"); | ||
| const hits = []; | ||
| const cap = createCaptureServer(new Set([token]), (h) => hits.push(h)); | ||
| await new Promise((res, rej) => { cap.server.on("error", rej); cap.server.listen(sockPath, res); }); | ||
| proxy = cap; | ||
| // Probe (runs inside the netns): (1) POST the token through the proxy — must | ||
| // be captured; (2) dial a non-loopback TEST-NET-3 (RFC5737) IP directly — | ||
| // the kernel must refuse it (ENETUNREACH/EHOSTUNREACH/ENETDOWN), proving | ||
| // there is no route out of the namespace. Prints markers we assert on. | ||
| const probeSrc = | ||
| "const http=require('node:http'),net=require('node:net');" + | ||
| "const p=new URL(process.env.HTTP_PROXY);" + | ||
| "const r=http.request({host:p.hostname,port:p.port,method:'POST',path:'http://selftest.local/x',headers:{host:'selftest.local'}});" + | ||
| "r.on('error',()=>{});r.end('t=' + process.env.SELFTEST_TOKEN);" + | ||
| "const s=net.connect({host:'203.0.113.1',port:80});" + | ||
| "s.setTimeout(2000);" + | ||
| "s.on('connect',()=>{console.log('DIRECT_OPEN');s.destroy();});" + | ||
| "s.on('timeout',()=>{console.log('DIRECT_TIMEOUT');s.destroy();});" + | ||
| "s.on('error',(e)=>{console.log('DIRECT_ERR_'+e.code);});"; | ||
| const payloadCmd = `${shellQuote(process.execPath)} -e ${shellQuote(probeSrc)}`; | ||
| const bootstrap = buildNetnsBootstrap({ | ||
| nodeBin: process.execPath, selfPath: __filename, innerPort: NETNS_INNER_PORT, | ||
| sockPath, readyFile, ipBin, graceMs: 500, payloadCmd | ||
| }); | ||
| const argv = ["bwrap", ...bwrapBaseArgs(root), "--unshare-net", "sh", "-c", bootstrap]; | ||
| const env = { ...process.env, HTTP_PROXY: `http://127.0.0.1:${NETNS_INNER_PORT}`, SELFTEST_TOKEN: token }; | ||
| const res = await spawnCapture(argv, { env, timeoutMs: 10000 }); | ||
| const captured = hits.some((h) => (h.tokensSeen || []).includes(token)); | ||
| // Only a KERNEL-level refusal (unreachable / net down) proves isolation; | ||
| // a timeout would also occur on a shared net where the IP just doesn't | ||
| // answer, so it does NOT count as confinement. | ||
| const directBlocked = /DIRECT_ERR_(ENETUNREACH|EHOSTUNREACH|ENETDOWN|EADDRNOTAVAIL)/.test(res.output || ""); | ||
| return captured && directBlocked; | ||
| } catch { | ||
| return false; | ||
| } finally { | ||
| if (proxy) await proxy.close().catch(() => {}); | ||
| await fsp.rm(root, { recursive: true, force: true }).catch(() => {}); | ||
| } | ||
| } | ||
| // __forwarder / __capture entrypoints, invoked as `node sandbox.js <mode> …` | ||
| // from inside the sandbox. Kept at module scope so bwrap's ro-bind of / makes | ||
| // this file reachable by absolute path. | ||
| function forwarderMain(argv) { | ||
| const [portStr, sockPath, readyFile] = argv; | ||
| startTcpToUnixForwarder(Number(portStr), sockPath, () => { | ||
| try { require("node:fs").writeFileSync(readyFile, "ready\n"); } catch { /* best effort */ } | ||
| }); | ||
| } | ||
| // The install-phase runner: execute the package's declared install lifecycle | ||
| // scripts, in order, in the package dir with the scrubbed decoy env. This is | ||
@@ -363,2 +583,84 @@ // exactly the install-time execution surface the TeamPCP / node-ipc families | ||
| // The loader that runs INSIDE the sandbox to detonate the import phase. It | ||
| // resolves the package's own entry point (respecting package.json `main` / | ||
| // `exports` / index.js), then loads it so any top-level side effect executes | ||
| // and is observed by the capture proxy — the flatmap-stream / malicious-on-first- | ||
| // require shape that a lifecycle-only run never triggers. `require` handles CJS; | ||
| // on ERR_REQUIRE_ESM it falls back to dynamic import(). Best-effort: a package | ||
| // whose entry require()s an uninstalled dependency throws before its payload | ||
| // runs — the same ceiling any without-install detonation faces (noted in the | ||
| // result `limits`). Errors are swallowed to stderr; the goal is to trigger and | ||
| // observe side effects, not to grade whether the module loaded cleanly. | ||
| const IMPORT_PROBE_SOURCE = `'use strict'; | ||
| const { pathToFileURL } = require('node:url'); | ||
| const dir = process.argv[2]; | ||
| (async () => { | ||
| let entry; | ||
| try { | ||
| entry = require.resolve(dir); | ||
| } catch (e) { | ||
| process.stderr.write('import-phase: cannot resolve entry (' + (e && e.message || e) + ')'); | ||
| return; | ||
| } | ||
| try { | ||
| require(entry); | ||
| } catch (e) { | ||
| if (e && e.code === 'ERR_REQUIRE_ESM') { | ||
| try { await import(pathToFileURL(entry).href); } | ||
| catch (e2) { process.stderr.write('import-phase(esm): ' + (e2 && e2.message || e2)); } | ||
| } else { | ||
| process.stderr.write('import-phase: ' + (e && e.message || e)); | ||
| } | ||
| } | ||
| })(); | ||
| `; | ||
| // The import-phase runner: load the package's entry point inside the SAME | ||
| // sandbox (decoy HOME, capture proxy, OS wrapper, rlimits, process-group kill) | ||
| // so import-time behavior is observed too — not just install scripts. The probe | ||
| // script is written into the sandbox root (writable, and never the package dir, | ||
| // so the staged tree stays pristine) and run with the same node that runs | ||
| // pkgxray. Skips cleanly when the staged package has no resolvable entry. | ||
| async function runImportPhase({ pkgDir, env, timeoutMs, wrapper, rlimits, sandboxRoot }) { | ||
| try { | ||
| await fsp.access(path.join(pkgDir, "package.json")); | ||
| } catch { | ||
| return { attempted: false, note: "no package.json in staged package" }; | ||
| } | ||
| const probeDir = sandboxRoot || path.dirname(pkgDir); | ||
| const probePath = path.join(probeDir, `import-probe-${crypto.randomBytes(4).toString("hex")}.js`); | ||
| try { | ||
| await fsp.writeFile(probePath, IMPORT_PROBE_SOURCE, { mode: 0o600 }); | ||
| } catch (error) { | ||
| return { attempted: false, note: `could not write import probe: ${error.message}` }; | ||
| } | ||
| // Invoke node directly (not via a shell string) so a package path containing | ||
| // shell metacharacters can't break the command; execWithTimeout still wraps | ||
| // it in the OS sandbox and applies the process-group timeout kill. | ||
| const nodeBin = process.execPath; | ||
| const command = `${shellQuote(nodeBin)} ${shellQuote(probePath)} ${shellQuote(pkgDir)}`; | ||
| const outcome = await execWithTimeout(command, { cwd: pkgDir, env, timeoutMs, wrapper, rlimits }); | ||
| await fsp.rm(probePath, { force: true }).catch(() => {}); | ||
| return { attempted: true, entryDir: pkgDir, ...outcome }; | ||
| } | ||
| // Single-quote a token for safe interpolation into an `sh -c` command. Wraps in | ||
| // single quotes and escapes any embedded single quote the POSIX way ('\''). | ||
| function shellQuote(s) { | ||
| return `'${String(s).replace(/'/g, "'\\''")}'`; | ||
| } | ||
| // The default (real) runner: detonate BOTH phases in the same sandbox — | ||
| // install-time lifecycle scripts, then the import of the package entry point. | ||
| // Import runs even if a lifecycle script failed, so a package that is benign at | ||
| // install but malicious on first require is still observed. | ||
| async function runInstallAndImport(ctx) { | ||
| const install = await runLifecycleScripts(ctx); | ||
| let importPhase = { attempted: false, note: "import phase disabled" }; | ||
| if (ctx.importPhase !== false) { | ||
| importPhase = await runImportPhase(ctx); | ||
| } | ||
| return { ran: install.ran || [], note: install.note, importPhase }; | ||
| } | ||
| // Best-effort resource caps for the untrusted child, applied via `ulimit` in the | ||
@@ -488,3 +790,3 @@ // spawned POSIX shell. The timeout + process-group SIGKILL bound TIME; these | ||
| rationale: | ||
| `Install-time execution READ the decoy ${decoy} and transmitted its honeytoken to ${hit.host} (${hit.transport}). This is an observed credential-exfil, not a static inference.` | ||
| `Sandboxed execution (install + import) READ the decoy ${decoy} and transmitted its honeytoken to ${hit.host} (${hit.transport}). This is an observed credential-exfil, not a static inference.` | ||
| }); | ||
@@ -504,3 +806,3 @@ } | ||
| rationale: | ||
| `Install-time execution contacted ${hit.host} (${hit.transport}) — a known callback/exfil destination or a raw IP. Legitimate installs do not phone these.` | ||
| `Sandboxed execution (install + import) contacted ${hit.host} (${hit.transport}) — a known callback/exfil destination or a raw IP. Legitimate install/import does not phone these.` | ||
| }); | ||
@@ -523,3 +825,3 @@ } | ||
| rationale: | ||
| `Install-time execution made outbound network requests to: ${hostList.join(", ")}. Review whether this package should reach the network at install.` | ||
| `Sandboxed execution (install + import) made outbound network requests to: ${hostList.join(", ")}. Review whether this package should reach the network at install or import.` | ||
| }); | ||
@@ -548,3 +850,3 @@ } | ||
| rationale: | ||
| `Install-time execution accessed decoy credential file(s): ${readDecoys.join(", ")} (access-time tripwire; corroborating, since atime can be unreliable). Combined with any egress above this is a credential-harvest shape.` | ||
| `Sandboxed execution (install + import) accessed decoy credential file(s): ${readDecoys.join(", ")} (access-time tripwire; corroborating, since atime can be unreliable). Combined with any egress above this is a credential-harvest shape.` | ||
| }); | ||
@@ -583,3 +885,15 @@ } | ||
| // execute anything, rather than silently running with env-only isolation. | ||
| const wrapperInfo = detectSandboxWrapper(root); | ||
| let wrapperInfo = detectSandboxWrapper(root); | ||
| // Upgrade shared-net bwrap to netns confinement when this host can prove it | ||
| // (bwrap + ip + a passing self-test). The forwarder must outlive the payload | ||
| // long enough for the parent's egress-grace read, so it is built with the | ||
| // run's grace window. Opt out with importPhase-style options.netnsConfinement:false. | ||
| const graceMs = Number.isFinite(options.egressGraceMs) && options.egressGraceMs >= 0 | ||
| ? options.egressGraceMs | ||
| : DEFAULT_EGRESS_GRACE_MS; | ||
| let netns = null; | ||
| if (wrapperInfo.level === "bwrap" && options.netnsConfinement !== false && !options.runner) { | ||
| netns = await detectNetnsConfinement(root); | ||
| if (netns) wrapperInfo = netns.build(graceMs); | ||
| } | ||
| if (options.requireSandbox === true && wrapperInfo.level === "env-only") { | ||
@@ -597,5 +911,11 @@ await fsp.rm(root, { recursive: true, force: true }).catch(() => {}); | ||
| const canary = await seedCanaryFilesystem(home, runId); | ||
| const proxy = await startCaptureProxy(new Set(canary.tokens.keys())); | ||
| // Under netns, the sandbox cannot reach the host TCP loopback, so the capture | ||
| // proxy also opens a Unix socket the in-netns forwarder connects to, and the | ||
| // payload's proxy env points at the forwarder's inner loopback port instead. | ||
| const proxy = await startCaptureProxy( | ||
| new Set(canary.tokens.keys()), | ||
| netns ? { unixPath: netns.sockPath } : {} | ||
| ); | ||
| const proxyUrl = `http://127.0.0.1:${proxy.port}`; | ||
| const proxyUrl = netns ? `http://127.0.0.1:${netns.innerPort}` : `http://127.0.0.1:${proxy.port}`; | ||
| // Scrubbed env: keep only what a script needs to run, repoint HOME at the | ||
@@ -623,4 +943,14 @@ // decoy tree, and force every proxy variable at our capture server. | ||
| try { | ||
| const runner = options.runner || runLifecycleScripts; | ||
| execResult = await runner({ pkgDir, env, timeoutMs, wrapper: wrapperInfo.wrap, home, proxyPort: proxy.port, rlimits: options.rlimits }); | ||
| const runner = options.runner || runInstallAndImport; | ||
| execResult = await runner({ | ||
| pkgDir, | ||
| env, | ||
| timeoutMs, | ||
| wrapper: wrapperInfo.wrap, | ||
| home, | ||
| proxyPort: proxy.port, | ||
| rlimits: options.rlimits, | ||
| sandboxRoot: root, | ||
| importPhase: options.importPhase | ||
| }); | ||
| } finally { | ||
@@ -703,2 +1033,3 @@ // Keep the capture proxy alive for a short grace window after the runner | ||
| startCaptureProxy, | ||
| createCaptureServer, | ||
| evaluateTripwires, | ||
@@ -710,2 +1041,11 @@ matchTokens, | ||
| DECOY_SPECS, | ||
| // exported for tests: the two phase runners + their composition | ||
| runLifecycleScripts, | ||
| runImportPhase, | ||
| runInstallAndImport, | ||
| // exported for tests: netns confinement — forwarder, bootstrap, detection | ||
| startTcpToUnixForwarder, | ||
| buildNetnsBootstrap, | ||
| detectNetnsConfinement, | ||
| bwrapBaseArgs, | ||
| // exported for tests: process-group kill + timeout runner + resource caps | ||
@@ -716,1 +1056,13 @@ killProcessGroup, | ||
| }; | ||
| // CLI entrypoints invoked from inside the sandbox: `node <this file> __forwarder …`. | ||
| // Guarded so require() of the module never triggers them. | ||
| if (require.main === module) { | ||
| const [, , mode, ...rest] = process.argv; | ||
| if (mode === "__forwarder") { | ||
| forwarderMain(rest); | ||
| } else { | ||
| process.stderr.write("src/sandbox.js is an internal module; no directly runnable command.\n"); | ||
| process.exit(2); | ||
| } | ||
| } |
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.
No website
QualityPackage does not have a website.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
638872
2.87%25
4.17%14137
3.78%0
-100%164
-43.64%42
7.69%21
5%