🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

pkgxray

Package Overview
Dependencies
Maintainers
1
Versions
28
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

pkgxray - npm Package Compare versions

Comparing version
0.17.0
to
0.18.0
+7
-3
bin/audit.js

@@ -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 @@ // ---------------------------------------------------------------------------

{
"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
```

@@ -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`

@@ -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.

@@ -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