🎩 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
1.0.5
to
1.1.0
+201
src/pep440.js
"use strict";
// ---------------------------------------------------------------------------
// Minimal zero-dep PEP 440 — the PyPI counterpart to src/semver.js. Just enough
// for version-drift pre-vetting: parse, compare, and "is this a stable release
// newer than the pinned one".
//
// PyPI versions are NOT semver. PEP 440 has epochs (`1!2.0`), a fixed
// pre-release vocabulary (a/b/rc, with alpha/beta/c/pre/preview spellings),
// post-releases (`.post1`, or the `-1` shorthand), dev-releases (`.dev0`), and
// local versions (`+local`). Ordering follows the PEP 440 summary:
//
// 1.0.dev0 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1
//
// The comparison key mirrors the reference `packaging._cmpkey`:
// (epoch, release, preKey, postKey, devKey) — where a dev-only release sorts
// BEFORE any pre-release, a final release sorts AFTER all pre-releases, and a
// post-release sorts after its final release. Local versions are ignored for
// ordering (drift treats `1.0` and `1.0+local` as equal), matching how
// semver.js ignores +build metadata.
//
// Deliberately NOT a full PEP 440 range/specifier engine. Lockfiles pin exact
// versions, so recheck only needs ordering + a same-"major" approximation.
// ---------------------------------------------------------------------------
// PEP 440 grammar (case-insensitive, leading `v` and surrounding space tolerated):
// [N!] N(.N)* [{a|b|c|rc|alpha|beta|pre|preview}[N]] [ .postN | -N ] [.devN] [+local]
const PEP440_RE = new RegExp(
"^\\s*v?" +
"(?:(\\d+)!)?" + // 1: epoch
"(\\d+(?:\\.\\d+)*)" + // 2: release
"(?:[-_.]?(a|b|c|rc|alpha|beta|pre|preview)[-_.]?(\\d+)?)?" + // 3: pre label, 4: pre num
"(?:(?:-(\\d+))|(?:[-_.]?(post|rev|r)[-_.]?(\\d+)?))?" + // 5: post shorthand, 6: post label, 7: post num
"(?:[-_.]?(dev)[-_.]?(\\d+)?)?" + // 8: dev label, 9: dev num
"(?:\\+([a-z0-9]+(?:[-_.][a-z0-9]+)*))?" + // 10: local
"\\s*$",
"i"
);
// Normalize a pre-release spelling to its canonical short form: alpha→a, beta→b,
// c/pre/preview/rc→rc. Returns "a" | "b" | "rc".
function normalizePreLabel(label) {
const l = String(label).toLowerCase();
if (l === "alpha") return "a";
if (l === "beta") return "b";
if (l === "c" || l === "pre" || l === "preview" || l === "rc") return "rc";
return l; // already "a" or "b"
}
// Rank the pre-release letters: a < b < rc.
function preLetterRank(letter) {
return letter === "a" ? 0 : letter === "b" ? 1 : 2; // rc
}
// Parse a PEP 440 version string. Returns null for anything that isn't a clean
// PEP 440 version, so an unparseable string never masquerades as a real release.
// { epoch, release[], pre: [letter, num]|null, post: num|null,
// dev: num|null, local: string|null }
function parsePep440(version) {
if (typeof version !== "string") return null;
const m = version.match(PEP440_RE);
if (!m) return null;
const epoch = m[1] ? Number(m[1]) : 0;
const release = m[2].split(".").map(Number);
let pre = null;
if (m[3]) pre = [normalizePreLabel(m[3]), m[4] ? Number(m[4]) : 0];
let post = null;
if (m[5] !== undefined) post = Number(m[5]); // the `-N` post shorthand
else if (m[6]) post = m[7] ? Number(m[7]) : 0; // explicit post/rev/r
const dev = m[8] ? (m[9] ? Number(m[9]) : 0) : null;
const local = m[10] || null;
return { epoch, release, pre, post, dev, local };
}
// A pre-release (aN/bN/rcN) OR a dev-release is "not a stable release". A
// post-release of a final version (1.0.post1) is stable. Mirrors the intent of
// semver.js isPrerelease (don't blind-suggest a non-final version as an update).
function isPrerelease(version) {
const p = parsePep440(version);
return Boolean(p && (p.pre !== null || p.dev !== null));
}
// Compare the release-number tuples, padding the shorter with zeros (so 1.0 and
// 1.0.0 compare equal).
function compareRelease(a, b) {
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i += 1) {
const ai = i < a.length ? a[i] : 0;
const bi = i < b.length ? b[i] : 0;
if (ai !== bi) return ai < bi ? -1 : 1;
}
return 0;
}
// Pre-release sort category, per PEP 440 _cmpkey:
// - no pre, no post, but has dev => sorts BEFORE everything (dev of a final)
// - no pre (final or post-only) => sorts AFTER all pre-releases
// - an actual pre-release => ordered by [letterRank, num]
function comparePre(pa, pb) {
const cat = (p) => {
if (p.pre === null && p.post === null && p.dev !== null) return 0; // -inf
if (p.pre === null) return 2; // +inf
return 1; // real pre-release
};
const ca = cat(pa);
const cb = cat(pb);
if (ca !== cb) return ca < cb ? -1 : 1;
if (ca !== 1) return 0; // both -inf or both +inf
const la = preLetterRank(pa.pre[0]);
const lb = preLetterRank(pb.pre[0]);
if (la !== lb) return la < lb ? -1 : 1;
if (pa.pre[1] !== pb.pre[1]) return pa.pre[1] < pb.pre[1] ? -1 : 1;
return 0;
}
// A missing post sorts before any real post (-inf); a missing dev sorts AFTER
// any real dev (+inf, i.e. a non-dev release is "later" than its dev builds).
function compareNullable(a, b, missing) {
if (a === null && b === null) return 0;
if (a === null) return missing === "low" ? -1 : 1;
if (b === null) return missing === "low" ? 1 : -1;
return a === b ? 0 : a < b ? -1 : 1;
}
// Compare two PEP 440 strings. Returns -1 / 0 / 1. Unparseable versions sort
// last (treated as greater-than a valid one) but equal to each other, so a
// malformed version never masquerades as a newer stable release — same policy
// as semver.js compare.
function comparePep440(a, b) {
const pa = parsePep440(a);
const pb = parsePep440(b);
if (!pa && !pb) return 0;
if (!pa) return 1;
if (!pb) return -1;
if (pa.epoch !== pb.epoch) return pa.epoch < pb.epoch ? -1 : 1;
const rel = compareRelease(pa.release, pb.release);
if (rel !== 0) return rel;
const pre = comparePre(pa, pb);
if (pre !== 0) return pre;
const post = compareNullable(pa.post, pb.post, "low"); // missing post = -inf
if (post !== 0) return post;
const dev = compareNullable(pa.dev, pb.dev, "high"); // missing dev = +inf
if (dev !== 0) return dev;
// Local versions are ignored for drift ordering.
return 0;
}
function gt(a, b) {
return comparePep440(a, b) > 0;
}
// From a list of available versions, pick the pre-vetting candidates newer than
// `pinned`. Returns { latest, latestInMajor } (either may be null) with the SAME
// shape semver.newerCandidates returns, so recheck's version-drift pass is
// comparator-agnostic:
// - latest : highest stable version overall (skips pre/dev releases)
// - latestInMajor : highest stable version sharing pinned's epoch + first
// release component (the PEP 440 analog of "same major"),
// only when it differs from `latest`.
// Pre/dev releases are excluded unless the pinned version is itself one.
function newerPypiCandidates(pinned, versions) {
const pinnedParsed = parsePep440(pinned);
const allowPre = Boolean(pinnedParsed && (pinnedParsed.pre !== null || pinnedParsed.dev !== null));
const newer = versions
.filter((v) => parsePep440(v))
.filter((v) => allowPre || !isPrerelease(v))
.filter((v) => gt(v, pinned));
if (newer.length === 0) return { latest: null, latestInMajor: null };
let latest = newer[0];
for (const v of newer) if (gt(v, latest)) latest = v;
let latestInMajor = null;
if (pinnedParsed) {
const pinnedMajor = pinnedParsed.release[0] || 0;
for (const v of newer) {
const p = parsePep440(v);
if (p && p.epoch === pinnedParsed.epoch && (p.release[0] || 0) === pinnedMajor) {
if (!latestInMajor || gt(v, latestInMajor)) latestInMajor = v;
}
}
}
if (latestInMajor && latestInMajor === latest) latestInMajor = null;
return { latest, latestInMajor };
}
module.exports = { parsePep440, comparePep440, gt, isPrerelease, newerPypiCandidates };
"use strict";
const https = require("node:https");
const { normalizePypiName } = require("./lockfile");
// ---------------------------------------------------------------------------
// PyPI registry client — the acquisition layer for the Python ecosystem, the
// counterpart to src/registry.js (npm packument) and the npm-registry bits of
// quarantine.js. Dependency-free: raw https against the PyPI JSON API.
//
// - existence: GET /pypi/<name>/json -> 404 means the package is NOT
// published. That 404 is the hallucinated / slopsquat signal
// — a lockfile pinning a name PyPI never served is the whole
// reason a supply-chain auditor exists.
// - versions: the `releases` map keys; latest = info.version.
// - metadata: info + ownership + release files, normalized by
// `pypiMetadataForEvidence` into the SAME shape
// `npmMetadataForEvidence` produces, so the heuristic engine
// (maintainer surface, deprecation, github cross-check) reuses
// unchanged.
//
// The actual download / extraction / host-allowlisting lives in quarantine.js
// (resolvePyPIPackage) — kept there so this module stays free of a require
// cycle with quarantine. This module only speaks to the registry.
// ---------------------------------------------------------------------------
const PYPI_BASE = "https://pypi.org";
const REGISTRY_AGENT = new https.Agent({ keepAlive: true, maxSockets: 8 });
// Raw GET returning { statusCode, body } without throwing on non-2xx, so
// existence checks can branch on 404 rather than catch an error.
function httpGet(url, headers) {
return new Promise((resolve, reject) => {
https
.get(url, { headers: { "user-agent": "pkgxray", accept: "application/json", ...headers }, agent: REGISTRY_AGENT }, (res) => {
const statusCode = res.statusCode;
let body = "";
res.setEncoding("utf8");
res.on("data", (c) => (body += c));
res.on("end", () => resolve({ statusCode, body }));
})
.on("error", reject);
});
}
async function fetchJson(url, headers) {
const { statusCode, body } = await httpGet(url, headers);
if (statusCode < 200 || statusCode >= 300) {
const err = new Error(`HTTP ${statusCode} from ${url}`);
err.statusCode = statusCode;
throw err;
}
return JSON.parse(body);
}
// Parse a `pypi:` guard specifier: `requests`, `requests@2.31.0`, or the pip
// `requests==2.31.0` form. Version is null when unpinned (resolve to latest).
function parsePypiSpecifier(specifier) {
const s = String(specifier).trim();
const eq = s.indexOf("==");
if (eq > 0) return { name: normalizePypiName(s.slice(0, eq)), version: s.slice(eq + 2).trim() || null };
const at = s.lastIndexOf("@");
if (at > 0) return { name: normalizePypiName(s.slice(0, at)), version: s.slice(at + 1).trim() || null };
return { name: normalizePypiName(s), version: null };
}
// True if published on PyPI, false on a definitive 404. Network / non-404
// errors propagate so a caller reports "unknown" rather than "does not exist".
async function pypiPackageExists(name) {
const { statusCode } = await httpGet(`${PYPI_BASE}/pypi/${encodeURIComponent(normalizePypiName(name))}/json`);
if (statusCode === 404) return false;
if (statusCode >= 200 && statusCode < 300) return true;
const err = new Error(`HTTP ${statusCode} checking existence of ${name}`);
err.statusCode = statusCode;
throw err;
}
// Full package metadata (all versions). Throws with .statusCode on 404.
async function fetchPypiMetadata(name, version) {
const encoded = encodeURIComponent(normalizePypiName(name));
const url = version
? `${PYPI_BASE}/pypi/${encoded}/${encodeURIComponent(version)}/json`
: `${PYPI_BASE}/pypi/${encoded}/json`;
return fetchJson(url);
}
// { versions: string[], latest: string|null } — parallels registry.js
// listNpmVersions. `latest` comes from info.version (authoritative), NOT from
// releases-key order (PyPI does not guarantee the map is version-sorted).
async function listPypiVersions(name) {
const json = await fetchPypiMetadata(name);
const versions = json && json.releases ? Object.keys(json.releases) : [];
const latest = json && json.info && typeof json.info.version === "string" ? json.info.version : null;
return { versions, latest };
}
// ---------------------------------------------------------------------------
// Evidence shaping — map PyPI's metadata onto the exact shape the auditor's
// heuristics consume (same as npmMetadataForEvidence):
// { name, version, repository, maintainers, dist, deprecated }
// ---------------------------------------------------------------------------
const REPO_HOST_RE = /github\.com|gitlab\.com|bitbucket\.org|codeberg\.org|sr\.ht/i;
// Find a source-repository URL from project_urls / home_page so the GitHub
// reputation + npm-vs-source cross-checks have something to work with. Prefers
// a link explicitly labelled source/repository/code over a bare homepage.
function extractRepository(info) {
const urls = (info && info.project_urls) || {};
for (const [label, url] of Object.entries(urls)) {
if (typeof url === "string" && /source|repo|code|git|tracker/i.test(label) && REPO_HOST_RE.test(url)) {
return { url, type: "git" };
}
}
for (const url of Object.values(urls)) {
if (typeof url === "string" && REPO_HOST_RE.test(url)) return { url, type: "git" };
}
if (info && typeof info.home_page === "string" && REPO_HOST_RE.test(info.home_page)) {
return { url: info.home_page, type: "git" };
}
return null;
}
// Maintainer accounts. PyPI's `ownership.roles` (a newer field) names the
// actual owner/maintainer usernames — the closest analog to npm's maintainers
// array. Falls back to the free-text maintainer/author fields (often null).
function extractMaintainers(json) {
const roles = json && json.ownership && Array.isArray(json.ownership.roles) ? json.ownership.roles : [];
if (roles.length) {
return roles
.filter((r) => r && r.user)
.map((r) => ({ name: r.user, role: r.role || null }));
}
const info = (json && json.info) || {};
if (info.maintainer) return [{ name: info.maintainer, email: info.maintainer_email || null }];
if (info.author) return [{ name: info.author, email: info.author_email || null }];
return [];
}
function pypiMetadataForEvidence(json) {
const info = (json && json.info) || {};
return {
name: info.name || null,
version: info.version || null,
repository: extractRepository(info),
maintainers: extractMaintainers(json),
dist: null, // PyPI has no npm-style dist object; provenance is deferred to v2
// A yanked release is PyPI's equivalent of npm's `deprecated` — surface the
// reason so inspectMetadataObject flags it.
deprecated: info.yanked ? (info.yanked_reason || "yanked") : null
};
}
// ---------------------------------------------------------------------------
// Release-file selection — choose which artifact to download for source
// inspection. Prefer the sdist (a .tar.gz of the actual source, including
// setup.py — the exec surface) over a wheel (a zip of built files). Returns
// null when the version has no usable, un-yanked file with a sha256 digest.
// ---------------------------------------------------------------------------
function releaseFileForVersion(json, version) {
const releases = (json && json.releases) || {};
// `version` may be omitted when the caller fetched the single-version
// endpoint, which puts the files in top-level `urls`.
let files = version && releases[version] ? releases[version] : json && json.urls;
if (!Array.isArray(files)) files = [];
const usable = files.filter((f) => f && !f.yanked && f.url && f.digests && f.digests.sha256);
if (usable.length === 0) return null;
const chosen =
usable.find((f) => f.packagetype === "sdist") ||
usable.find((f) => f.packagetype === "bdist_wheel") ||
usable[0];
return {
url: chosen.url,
filename: chosen.filename,
packagetype: chosen.packagetype,
sha256: chosen.digests.sha256,
size: typeof chosen.size === "number" ? chosen.size : null,
uploadTime: chosen.upload_time_iso_8601 || chosen.upload_time || null
};
}
// Convert PyPI's hex sha256 into an npm SRI integrity string ("sha256-<b64>")
// so quarantine's existing verifyNpmTarballIntegrity path can verify a PyPI
// download with no new hashing code.
function sha256ToSri(hex) {
return "sha256-" + Buffer.from(String(hex), "hex").toString("base64");
}
module.exports = {
PYPI_BASE,
parsePypiSpecifier,
pypiPackageExists,
fetchPypiMetadata,
listPypiVersions,
pypiMetadataForEvidence,
extractRepository,
extractMaintainers,
releaseFileForVersion,
sha256ToSri
};
+17
-1

@@ -426,2 +426,13 @@ #!/usr/bin/env node

out = out.replace(/(?:^|\s)\/(?:[^\s/]+\/)+([^\s/'"`)]+)/g, " <path>/$1");
// Windows absolute paths: drive-letter roots (C:\… or C:/…) and UNC shares
// (\\host\share\…). Neither pattern above matches a backslash path or a drive
// letter, so on win32 the whole path survived into the reply — the temp dir
// and the user profile with it, which is exactly the box-mapping this function
// exists to prevent. The negative lookbehind stops a URL scheme ("http://…")
// from reading as a drive letter; the basename is kept for context, matching
// the POSIX branch above.
out = out.replace(
/(?<![A-Za-z])(?:[A-Za-z]:[\\/]|\\\\[^\\/\s]+[\\/])(?:[^\s'"`)]*[\\/])?([^\s\\/'"`)]+)/g,
"<path>/$1"
);
return out;

@@ -963,3 +974,8 @@ }

attachStdin,
resolveOperatorPath
resolveOperatorPath,
// Exported for direct unit testing. The end-to-end leak test can only
// exercise whichever path shape the host platform produces, so a Windows
// path leak was invisible to a Linux-only suite; testing the pure function
// lets every platform assert every shape.
sanitizeErrorMessage
};
+9
-3

@@ -44,3 +44,7 @@ #!/usr/bin/env node

upstreamCodeload: "https://codeload.github.com",
host: "0.0.0.0",
// Bind loopback-only by default. This server is NOT an auth boundary (see
// printUsage), so a careless deploy that exposes it should fail SAFE: the
// operator must opt into a routable interface with --host 0.0.0.0 (or
// PKGXRAY_CACHE_HOST) and put their own auth/network controls in front.
host: process.env.PKGXRAY_CACHE_HOST || "127.0.0.1",
maxCacheBytes: process.env.PKGXRAY_CACHE_MAX_BYTES

@@ -82,3 +86,3 @@ ? Number(process.env.PKGXRAY_CACHE_MAX_BYTES)

"Usage:",
" pkgxray-cache [--port 8819] [--host 0.0.0.0] [--cache-dir DIR]",
" pkgxray-cache [--port 8819] [--host 127.0.0.1] [--cache-dir DIR]",
" [--upstream-github-api URL] [--upstream-codeload URL]",

@@ -92,3 +96,5 @@ " [--max-cache-bytes N]",

"",
"Not an auth boundary — run on a private network or behind your own proxy.",
"Binds 127.0.0.1 by default. To serve a fleet, set --host 0.0.0.0 (or",
"PKGXRAY_CACHE_HOST) explicitly — and note this is NOT an auth boundary:",
"run it on a private network or behind your own authenticating proxy.",
"The server never uses its own GitHub token for a client request; a client",

@@ -95,0 +101,0 @@ "must present x-pkgxray-github-token to reach private repos.",

{
"name": "pkgxray",
"version": "1.0.5",
"version": "1.1.0",
"mcpName": "io.github.adamsjack711-ux/pkgxray",

@@ -5,0 +5,0 @@ "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.",

+83
-35
<div align="center">
<img src="docs/banner.png" alt="pkgxray — analyze packages before you install them" width="820">
# pkgxray — pre-install security for npm packages, MCP servers, and AI agents
**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.**
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.
[![npm version](https://img.shields.io/npm/v/pkgxray)](https://www.npmjs.com/package/pkgxray)
[![npm downloads](https://img.shields.io/npm/dm/pkgxray)](https://www.npmjs.com/package/pkgxray)
[![tests](https://github.com/adamsjack711-ux/pkgxray/actions/workflows/pkgxray-test.yml/badge.svg)](https://github.com/adamsjack711-ux/pkgxray/actions/workflows/pkgxray-test.yml)

@@ -14,9 +17,20 @@ [![calibration benchmark](https://github.com/adamsjack711-ux/pkgxray/actions/workflows/pkgxray-benchmark.yml/badge.svg)](https://github.com/adamsjack711-ux/pkgxray/actions/workflows/pkgxray-benchmark.yml)

[**Website**](https://pkgxray.ca) · [**Documentation**](docs/README.md) · [**Calibration**](https://pkgxray.ca/stats) · [**Report a bug**](https://github.com/adamsjack711-ux/pkgxray/issues)
<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">
<sub>Real runs: `guard` clears `express@4.21.0`, then blocks a sample modeled on
the 2024 `@solana/web3.js` compromise.</sub>
<sub>Real runs: <code>guard</code> clears <code>express@4.21.0</code>, then blocks a sample modeled on the 2024 <code>@solana/web3.js</code> compromise.</sub>
</div>
## Highlights
- **Zero runtime dependencies** — pure Node, runs entirely on your machine (~25 ms static pass).
- **Normal scans never execute package code** — the tarball is read as bytes in quarantine.
- **Deterministic, cited verdicts** — every finding names the file and evidence; no LLM in the verdict path, so injected text can't steer it.
- **Built for the agent era** — vet MCP servers before connect, gate the installs an agent runs, and re-audit live MCP traffic.
- **Calibrated and regression-gated** — zero heuristic false blocks on the top-1000 most-downloaded packages, enforced in CI.
> **[1. Quick start](#quick-start)** · [2. What it scans & detects](#what-it-scans--detects) · [3. Verdicts](#verdicts) · [4. Usage](#usage) · [5. Integrations](#integrations) · [6. How it compares](#how-it-compares) · [7. Documentation](#documentation)
## Why

@@ -43,7 +57,16 @@

<details>
<summary>Sample output</summary>
```text
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)
```
</details>
**2. Read the verdict:**

@@ -80,12 +103,36 @@

## What it catches
## What it scans & detects
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).
**Scans** — `pkgxray guard npm:name@version` or `pypi:name@version`,
`github:owner/repo`, a local directory, whole lockfiles across two ecosystems
(npm: `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `package.json`; PyPI:
`requirements.txt`, `poetry.lock`, `Pipfile.lock`, `pyproject.toml`), MCP
servers, and AI-agent extensions.
**Detects** — credential theft (incl. split-fragment paths), cloud
instance-metadata and secret-store harvesting, prompt injection, Unicode
smuggling, base64 payloads and stage-2 loaders, exfiltration, persistence
(shell profile, OS scheduler, and injected CI/CD workflows), self-deleting
droppers, registry worm replication (install-time `npm publish`), npm
install-hook and PyPI `setup.py` install-time execution, obfuscated computed-arg
execution, hallucinated / slopsquat names (a lockfile pin the registry never
published), known CVEs (via OSV, before download), npm↔GitHub artifact
divergence, trojaned updates (`recheck`), and MCP
capability-surface abuse.
The full coverage matrix — and the known download-later blind spot — is in the
[threat model](docs/threat-model.md); a side-by-side comparison table is on the
[website](https://pkgxray.ca/#catches).
## Verdicts
| Verdict | You should |
|---|---|
| `SAFE` | Install. Only `safe` promotes out of quarantine by default. |
| `REVIEW` | Inspect the quarantined copy before promoting. |
| `BLOCK` | Do not install. Every finding names the file and evidence. |
Exit codes are stable and CI-friendly: **`0`** safe/allow · **`2`** block ·
**`3`** review.
## Usage

@@ -95,9 +142,13 @@

pkgxray guard npm:some-package@1.2.3 [--format json] # vet a package before install
pkgxray guard pypi:some-package@1.2.3 # same, for a PyPI package (sdist staged + scanned)
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 audit requirements.txt [--deep] # PyPI: also poetry.lock, Pipfile.lock, pyproject.toml
pkgxray recheck package-lock.json # scheduled: non-zero only on a regression
```
Exit codes are stable and CI-friendly: **`0`** safe/allow · **`2`** block ·
**`3`** review.
One optional `.pkgxray.json` (read by every surface) tunes policy; 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:
[configuration.md](docs/configuration.md) · [`.pkgxray.example.json`](.pkgxray.example.json).

@@ -118,9 +169,9 @@ ## Integrations

## Configuration
## How it compares
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).
Run pkgxray *alongside* `npm audit` / OSV-Scanner, not instead of them — they
answer *"known CVE?"*. Against tools in the same lane (behavioral supply-chain
vetting — Socket.dev, OpenSSF Package Analysis, Cisco MCP Scanner), the full
capability comparison is in [docs/comparison.md](docs/comparison.md) and on the
[website](https://pkgxray.ca/#comparison).

@@ -131,12 +182,6 @@ ## Evidence

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.
and the published runs live at [pkgxray.ca/stats](https://pkgxray.ca/stats). That
claim is scoped to the most-installed set — not a claim of zero false blocks on
every package.
## How it compares
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).
## Documentation

@@ -156,3 +201,3 @@

## Development
## Contributing

@@ -165,7 +210,10 @@ ```bash

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.
Pull requests are welcome — read [CONTRIBUTING.md](CONTRIBUTING.md) and the
[Code of Conduct](CODE_OF_CONDUCT.md). Report vulnerabilities privately per
[SECURITY.md](SECURITY.md). Releases publish to npm with provenance (SLSA
attestation), gated on tests, the calibration benchmark, and pkgxray's own
supply-chain guard.
[MIT](LICENSE)
<div align="center">
<sub>Built by <a href="https://github.com/adamsjack711-ux">Jack Adams-Lovell</a> · <a href="LICENSE">MIT</a> · <a href="https://pkgxray.ca">pkgxray.ca</a></sub>
</div>

@@ -12,3 +12,3 @@ {

},
"version": "1.0.5",
"version": "1.1.0",
"packages": [

@@ -19,3 +19,3 @@ {

"identifier": "pkgxray",
"version": "1.0.5",
"version": "1.1.0",
"runtimeHint": "npx",

@@ -22,0 +22,0 @@ "transport": {

@@ -19,5 +19,27 @@ "use strict";

if (base === "package.json") return "package-json";
// Python / PyPI manifests. requirements files come in many names
// (requirements.txt, requirements-dev.txt, dev-requirements.txt) — match any
// *.txt whose name mentions "requirements".
if (/requirements[\w.-]*\.txt$/i.test(base) || /[\w.-]*requirements\.txt$/i.test(base)) return "requirements";
if (base === "poetry.lock") return "poetry";
if (base === "Pipfile.lock") return "pipfile";
if (base === "pyproject.toml") return "pyproject";
return null;
}
// Which registry ecosystem a detected format belongs to. Drives the OSV query
// ecosystem string and the guard reference scheme. Everything defaults to npm
// (the original single-ecosystem behaviour); Python formats map to PyPI.
function formatEcosystem(format) {
switch (format) {
case "requirements":
case "poetry":
case "pipfile":
case "pyproject":
return { osv: "PyPI", scheme: "pypi" };
default:
return { osv: "npm", scheme: "npm" };
}
}
async function parseLockfile(filePath) {

@@ -34,2 +56,6 @@ const format = detectFormat(filePath);

case "package-json": return { format, deps: parsePackageJson(text) };
case "requirements": return { format, deps: parseRequirementsTxt(text) };
case "poetry": return { format, deps: parsePoetryLock(text) };
case "pipfile": return { format, deps: parsePipfileLock(text) };
case "pyproject": return { format, deps: parsePyproject(text) };
default: throw new Error(`unreachable`);

@@ -293,2 +319,208 @@ }

// ---------------------------------------------------------------------------
// Python (PyPI) manifest parsers — same normalized shape as the npm parsers:
// Map<name@version, { name, version, paths }>. Names are PEP 503-normalized so
// OSV (which keys PyPI advisories on the canonical name) matches regardless of
// how the manifest spelled them (Flask == flask == FLASK). Lockfiles
// (poetry.lock, Pipfile.lock) pin exact versions; requirements.txt is pinned
// when produced by `pip freeze`; pyproject.toml usually carries ranges and so
// mostly lands as `unresolved` (same philosophy as parsePackageJson).
// ---------------------------------------------------------------------------
// PEP 503 name normalization: lowercase, collapse runs of -, _, . into one -.
function normalizePypiName(name) {
return String(name).trim().replace(/[-_.]+/g, "-").toLowerCase();
}
function parseRequirementsTxt(text) {
const deps = new Map();
// Join backslash line-continuations so a wrapped requirement parses as one.
const merged = [];
let buffer = "";
for (const raw of text.split(/\r?\n/)) {
if (/\\\s*$/.test(raw)) {
buffer += raw.replace(/\\\s*$/, "");
continue;
}
merged.push(buffer + raw);
buffer = "";
}
if (buffer) merged.push(buffer);
for (const rawLine of merged) {
const parsed = parseRequirementLine(rawLine);
if (!parsed) continue;
if (parsed.unresolved) {
addUnresolved(deps, parsed.name, parsed.spec, ["requirements.txt"], parsed.kind);
} else {
add(deps, parsed.name, parsed.version, ["requirements.txt"]);
}
}
return deps;
}
// Parse ONE requirements/PEP 508 line into { name, version } (exact pin only),
// an unresolved marker (range/url/vcs/editable/unpinnable), or null (blank,
// comment, or an option line carrying no package). Mirrors
// resolvePackageJsonSpec: never emit a bogus pinned version that reads "safe".
function parseRequirementLine(rawLine) {
// Strip a leading or whitespace-preceded `#` comment (pip's comment rule).
let line = rawLine.replace(/(^|\s)#.*$/, "$1").trim();
if (!line) return null;
if (line.startsWith("-")) {
// -e / --editable installs a VCS or local checkout we can't OSV-query.
// Surface those; skip other option lines (-r/-c/--index-url carry nothing).
if (!/^(-e|--editable)\b/.test(line)) return null;
const target = line.replace(/^(-e|--editable)\s+/, "").trim();
// A `#egg=NAME` fragment names the package; otherwise keep the raw target
// (a URL/path) as the label — it's unresolved either way.
const egg = target.match(/#egg=([A-Za-z0-9._-]+)/);
return { unresolved: true, name: egg ? normalizePypiName(egg[1]) : target, spec: rawLine.trim(), kind: "editable" };
}
// Drop inline options (--hash=..., --global-option ...) and env markers.
line = line.replace(/\s--[\w-]+[=\s]\S+/g, "").trim();
const semi = line.indexOf(";");
if (semi !== -1) line = line.slice(0, semi).trim();
if (!line) return null;
// PEP 508 direct reference `name @ url`, or a bare VCS/URL/local-path install.
if (/\s@\s/.test(line)) {
const name = normalizePypiName(line.split(/\s@\s/)[0].trim().replace(/\[[^\]]*\]$/, ""));
return { unresolved: true, name, spec: rawLine.trim(), kind: "url" };
}
if (/^(git\+|hg\+|svn\+|bzr\+|https?:\/\/|file:|\.|\/)/.test(line)) {
return { unresolved: true, name: line, spec: rawLine.trim(), kind: "url" };
}
const m = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(===|==|~=|!=|<=|>=|<|>)?\s*(.*)$/);
if (!m) return null;
const name = normalizePypiName(m[1]);
const op = m[2];
let version = (m[3] || "").trim();
if ((op === "==" || op === "===") && version) {
// A pin can still hide a wildcard (==2.1.*) or comma range (==2,<3).
version = version.split(",")[0].trim();
if (!version || version.includes("*")) {
return { unresolved: true, name, spec: rawLine.trim(), kind: "unpinnable" };
}
return { name, version };
}
return { unresolved: true, name, spec: rawLine.trim(), kind: op ? "range" : "unpinnable" };
}
function parsePoetryLock(text) {
const deps = new Map();
// poetry.lock is TOML: a series of [[package]] tables. Scrape name/version
// rather than pull in a TOML parser (matches the pnpm YAML-scrape approach).
const blocks = text.split(/^\[\[package\]\]\s*$/m).slice(1);
for (const block of blocks) {
const nameM = block.match(/^\s*name\s*=\s*"([^"]+)"/m);
const verM = block.match(/^\s*version\s*=\s*"([^"]+)"/m);
if (!nameM || !verM) continue;
const name = normalizePypiName(nameM[1]);
const version = verM[1].trim();
// A [package.source] with a non-registry type is a git/url/local install,
// not a PyPI release we can vet. "legacy" is a private index — still real.
const typeM = block.match(/^\s*type\s*=\s*"([^"]+)"/m);
if (typeM && ["git", "url", "directory", "file"].includes(typeM[1])) {
addUnresolved(deps, name, version, ["poetry.lock"], "source");
continue;
}
add(deps, name, version, ["poetry.lock"]);
}
return deps;
}
function parsePipfileLock(text) {
const json = JSON.parse(text);
const deps = new Map();
for (const section of ["default", "develop"]) {
const entries = json[section] || {};
for (const [rawName, spec] of Object.entries(entries)) {
const name = normalizePypiName(rawName);
if (!spec || typeof spec !== "object") continue;
if (spec.git || spec.path || spec.file || spec.url) {
addUnresolved(deps, name, spec.version || spec.git || spec.path || spec.url || "*", [section], "source");
continue;
}
let version = typeof spec.version === "string" ? spec.version.trim() : "";
if (version.startsWith("==")) version = version.slice(2).trim();
if (!version || /[<>=~!*]/.test(version)) {
addUnresolved(deps, name, spec.version || "*", [section], "unpinnable");
continue;
}
add(deps, name, version, [section]);
}
}
return deps;
}
function parsePyproject(text) {
const deps = new Map();
// pyproject.toml expresses deps two common ways; scrape both without a TOML
// parser. Versions are usually ranges, so most land as unresolved.
// (1) PEP 621: [project] dependencies = [ "requests>=2", ... ].
const projDeps = matchTomlArray(text, /(^|\n)\s*dependencies\s*=\s*\[/);
for (const item of projDeps) {
const parsed = parseRequirementLine(item);
if (!parsed) continue;
if (parsed.unresolved) addUnresolved(deps, parsed.name, parsed.spec, ["pyproject.toml"], parsed.kind);
else add(deps, parsed.name, parsed.version, ["pyproject.toml"]);
}
// (2) Poetry: [tool.poetry.dependencies] name = "^1.2.3" table entries.
parsePoetryTomlTable(text, deps);
return deps;
}
// Return the quoted string items of the first TOML array whose assignment
// matches `re` (e.g. `dependencies = [ ... ]`). Bracket-matched so nested
// arrays don't end it early. Empty if not found.
function matchTomlArray(text, re) {
const m = re.exec(text);
if (!m) return [];
const open = text.indexOf("[", m.index + m[0].length - 1);
if (open === -1) return [];
let depth = 0;
let end = open;
for (; end < text.length; end++) {
const c = text[end];
if (c === "[") depth++;
else if (c === "]" && --depth === 0) { end++; break; }
}
const inner = text.slice(open + 1, end - 1);
const items = [];
const strRe = /"([^"]*)"|'([^']*)'/g;
let s;
while ((s = strRe.exec(inner))) items.push(s[1] !== undefined ? s[1] : s[2]);
return items;
}
// Scrape `[tool.poetry.dependencies]` (and group sub-tables) `name = "spec"`
// entries. Only exact pins ("1.2.3", no caret/tilde/range) resolve.
function parsePoetryTomlTable(text, deps) {
const lines = text.split(/\r?\n/);
let inTable = false;
for (const raw of lines) {
const line = raw.trim();
if (line.startsWith("[")) {
inTable = /^\[tool\.poetry(\.group\.[\w.-]+)?\.dependencies\]$/.test(line);
continue;
}
if (!inTable || !line || line.startsWith("#")) continue;
const m = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s*=\s*(.+)$/);
if (!m) continue;
const name = normalizePypiName(m[1]);
if (name === "python") continue; // the interpreter constraint, not a package
const rhs = m[2].trim();
// Table/inline forms ({ version = ..., git = ... }) → unresolved.
const strM = rhs.match(/^["']([^"']+)["']$/);
if (!strM) { addUnresolved(deps, name, rhs, ["pyproject.toml"], "complex"); continue; }
const spec = strM[1].trim();
if (/^\d[\w.+-]*$/.test(spec)) add(deps, name, spec, ["pyproject.toml"]);
else addUnresolved(deps, name, spec, ["pyproject.toml"], "range");
}
}
// ---------------------------------------------------------------------------
// Batch OSV

@@ -303,10 +535,12 @@ // ---------------------------------------------------------------------------

function batchOsvQuery(deps) {
function batchOsvQuery(deps, ecosystem = "npm") {
// Unresolved specs (workspace/catalog/url/git/alias-without-pin) carry a raw
// spec in `version` that OSV cannot resolve — querying them would come back
// empty and read "safe". Skip them here; the audit surfaces them separately.
// `ecosystem` is OSV's registry token ("npm", "PyPI", ...); it comes from the
// lockfile format so every dep in one file shares it.
const queries = Array.from(deps.values())
.filter((d) => !d.unresolved)
.map((d) => ({
package: { name: d.name, ecosystem: "npm" },
package: { name: d.name, ecosystem },
version: d.version

@@ -369,2 +603,3 @@ }));

const { format, deps } = await parseLockfile(filePath);
const ecosystem = formatEcosystem(format);
const start = Date.now();

@@ -380,3 +615,3 @@ const queries = Array.from(deps.values());

} else if (options.vulnerabilityCheck !== false) {
osvResults = await batchOsvQuery(deps);
osvResults = await batchOsvQuery(deps, ecosystem.osv);
}

@@ -467,3 +702,3 @@ const osvMs = Date.now() - start;

: results.filter((r) => r.decision === "block");
await runDeep(targets, options);
await runDeep(targets, options, ecosystem.scheme);
deepMs = Date.now() - deepStart;

@@ -496,3 +731,3 @@ }

async function runDeep(results, options) {
async function runDeep(results, options, scheme = "npm") {
if (results.length === 0) return;

@@ -504,3 +739,3 @@ // Lazy-require to avoid a cycle (lockfile -> quarantine -> lockfile).

try {
const result = await guardExtension(`npm:${r.name}@${r.version}`, {
const result = await guardExtension(`${scheme}:${r.name}@${r.version}`, {
vulnerabilityCheck: false, // already done by the lockfile pass

@@ -593,5 +828,13 @@ githubMetadata: options.githubMetadata !== false,

detectFormat,
formatEcosystem,
batchOsvQuery,
renderLockfileMarkdown,
sanitizeForTerminal
sanitizeForTerminal,
// Exposed for unit tests of the Python manifest parsers.
parseRequirementsTxt,
parseRequirementLine,
parsePoetryLock,
parsePipfileLock,
parsePyproject,
normalizePypiName
};
"use strict";
const { parseLockfile, sanitizeForTerminal } = require("./lockfile");
const { parseLockfile, sanitizeForTerminal, formatEcosystem } = require("./lockfile");
const {

@@ -12,2 +12,3 @@ loadDecisions,

const { newerCandidates } = require("./semver");
const { newerPypiCandidates } = require("./pep440");

@@ -70,6 +71,6 @@ // ---------------------------------------------------------------------------

// NOT pass anything that would disable it.
function makeDefaultEvaluator(options) {
function makeDefaultEvaluator(options, scheme = "npm") {
const { guardExtension } = require("./quarantine");
return async (dep) => {
const result = await guardExtension(`npm:${dep.name}@${dep.version}`, {
const result = await guardExtension(`${scheme}:${dep.name}@${dep.version}`, {
vulnerabilityCheck: true, // OSV is the primary drift signal

@@ -97,4 +98,7 @@ githubMetadata: options.githubMetadata !== false,

// ---------------------------------------------------------------------------
async function versionDriftPass(depList, evaluate, options) {
const listVersions = options.listVersions || defaultListVersions(options);
async function versionDriftPass(depList, evaluate, options, eco = { scheme: "npm" }) {
const listVersions = options.listVersions || defaultListVersions(options, eco);
// PyPI versions are PEP 440, not semver — pick the matching newer-candidate
// comparator so a `1!2.0.post1`-style version isn't mis-ordered as garbage.
const pickNewer = eco.scheme === "pypi" ? newerPypiCandidates : newerCandidates;
const concurrency = options.concurrency || defaultConcurrency(depList.length);

@@ -106,3 +110,3 @@

const { versions } = await listVersions(dep.name);
const picked = newerCandidates(dep.version, versions || []);
const picked = pickNewer(dep.version, versions || []);
candidates = [picked.latest, picked.latestInMajor].filter(Boolean);

@@ -161,3 +165,7 @@ } catch (err) {

function defaultListVersions(options) {
function defaultListVersions(options, eco = { scheme: "npm" }) {
if (eco.scheme === "pypi") {
const { listPypiVersions } = require("./pypi");
return (name) => listPypiVersions(name);
}
const { listNpmVersions } = require("./registry");

@@ -169,6 +177,10 @@ return (name) => listNpmVersions(name, { registry: options.registry });

const { format, deps } = await parseLockfile(lockfilePath);
// One lockfile = one ecosystem. This decides the guard scheme (npm:/pypi:),
// the registry version-lister, and the version-drift comparator (semver vs
// PEP 440), so a Python lockfile is never mis-scanned as npm.
const eco = formatEcosystem(format);
const lockPath = options.lockPath || lockPathForLockfile(lockfilePath);
const decisions = options.decisions || (await loadDecisions(lockPath));
const evaluate = options.evaluate || makeDefaultEvaluator(options);
const evaluate = options.evaluate || makeDefaultEvaluator(options, eco.scheme);
const depList = Array.from(deps.values());

@@ -256,3 +268,3 @@ const concurrency = options.concurrency || defaultConcurrency(depList.length);

if (options.versionDrift !== false) {
versionDrift = await versionDriftPass(depList, evaluate, options);
versionDrift = await versionDriftPass(depList, evaluate, options, eco);
}

@@ -259,0 +271,0 @@

@@ -589,3 +589,47 @@ "use strict";

const dir = process.argv[2];
// The parent passes a result-file path and an unforgeable nonce. Read them, then
// TRUNCATE argv so the package we are about to load — it runs in THIS process via
// require() — cannot read the path to overwrite our result, nor learn the nonce
// to forge one. Capture the original fs.writeFileSync NOW, before the package can
// monkeypatch it, and report through that captured reference. Writing to a file
// (not stdout) keeps the signal out of the truncatable, package-writable output
// stream. Residual: same-process instrumentation can still be defeated by malware
// that tampers below this layer — the canary "confirms but cannot clear".
const HOOK_OUT = process.argv[3];
const NONCE = process.argv[4] || '';
try { process.argv.length = 3; } catch (e) {}
const _writeFileSync = require('node:fs').writeFileSync;
// Snapshot references to global network primitives BEFORE the package loads.
// A response-rewriting clipper (the chalk/qix class) exfiltrates nothing for the
// capture proxy to see — its entire behavior is REASSIGNING global fetch /
// XMLHttpRequest / Response at import so it can tamper with requests/responses in
// place. Comparing identity after import surfaces exactly that mutation. We only
// flag a primitive that EXISTED before and had its identity replaced; a fresh
// polyfill of a missing global (old-Node node-fetch) is not tampering.
function snapshotHooks() {
const g = globalThis;
const R = typeof g.Response === 'function' ? g.Response.prototype : null;
const X = typeof g.XMLHttpRequest === 'function' ? g.XMLHttpRequest.prototype : null;
return {
'globalThis.fetch': g.fetch,
'Response.prototype.text': R ? R.text : undefined,
'Response.prototype.json': R ? R.json : undefined,
'XMLHttpRequest.prototype.open': X ? X.open : undefined,
'XMLHttpRequest.prototype.send': X ? X.send : undefined
};
}
function diffHooks(before) {
const after = snapshotHooks();
const hooked = [];
for (const k of Object.keys(before)) {
if (before[k] !== undefined && after[k] !== before[k]) hooked.push(k);
}
return hooked;
}
function report(hooked) {
if (!HOOK_OUT) return;
try { _writeFileSync(HOOK_OUT, JSON.stringify({ nonce: NONCE, hooked: hooked })); } catch (e) {}
}
(async () => {
const before = snapshotHooks();
let entry;

@@ -596,2 +640,3 @@ try {

process.stderr.write('import-phase: cannot resolve entry (' + (e && e.message || e) + ')');
report([]);
return;

@@ -608,2 +653,11 @@ }

}
} finally {
try {
// Let deferred patches land before diffing: a clipper can schedule the
// global reassignment in a microtask / setTimeout(...,0) just after import
// to dodge a purely synchronous check. A short settle drains the microtask
// queue and one 0ms macrotask before we snapshot.
await new Promise(function (r) { setTimeout(r, 50); });
report(diffHooks(before));
} catch (e) { /* instrumentation must never break the run */ }
}

@@ -613,2 +667,13 @@ })();

// The import probe writes its observed global-network-primitive reassignments to
// a nonce-stamped result FILE (not shared stdout, which the untrusted package can
// flood past the output cap or forge a suppressing marker on). runImportPhase
// verifies the nonce and surfaces the parsed array as importPhase.runtimeHooks;
// this just reads that already-verified field.
function extractRuntimeHooks(execResult) {
const ip = execResult && execResult.importPhase;
if (!ip || !Array.isArray(ip.runtimeHooks)) return [];
return ip.runtimeHooks.filter((x) => typeof x === "string");
}
// The import-phase runner: load the package's entry point inside the SAME

@@ -627,3 +692,12 @@ // sandbox (decoy HOME, capture proxy, OS wrapper, rlimits, process-group kill)

const probeDir = sandboxRoot || path.dirname(pkgDir);
const probePath = path.join(probeDir, `import-probe-${crypto.randomBytes(4).toString("hex")}.js`);
const tag = crypto.randomBytes(4).toString("hex");
const probePath = path.join(probeDir, `import-probe-${tag}.js`);
// Nonce-stamped result file: the probe writes its observed global-primitive
// reassignments here instead of to stdout, so a chatty or hostile package can
// neither push the signal past execWithTimeout's output cap nor forge a
// suppressing marker on the shared stream. The nonce is passed on argv (which
// the probe strips before loading the package) and authenticates the file the
// parent reads back, so a blind write to a guessed path is rejected.
const hookOutPath = path.join(probeDir, `import-hooks-${tag}.json`);
const nonce = crypto.randomBytes(16).toString("hex");
try {

@@ -638,6 +712,16 @@ await fsp.writeFile(probePath, IMPORT_PROBE_SOURCE, { mode: 0o600 });

const nodeBin = process.execPath;
const command = `${shellQuote(nodeBin)} ${shellQuote(probePath)} ${shellQuote(pkgDir)}`;
const command =
`${shellQuote(nodeBin)} ${shellQuote(probePath)} ${shellQuote(pkgDir)} ` +
`${shellQuote(hookOutPath)} ${shellQuote(nonce)}`;
const outcome = await execWithTimeout(command, { cwd: pkgDir, env, timeoutMs, wrapper, rlimits });
let runtimeHooks = [];
try {
const parsed = JSON.parse(await fsp.readFile(hookOutPath, "utf8"));
if (parsed && parsed.nonce === nonce && Array.isArray(parsed.hooked)) {
runtimeHooks = parsed.hooked.filter((x) => typeof x === "string");
}
} catch { /* no result file / unreadable / nonce mismatch → nothing observed */ }
await fsp.rm(probePath, { force: true }).catch(() => {});
return { attempted: true, entryDir: pkgDir, ...outcome };
await fsp.rm(hookOutPath, { force: true }).catch(() => {});
return { attempted: true, entryDir: pkgDir, runtimeHooks, ...outcome };
}

@@ -774,5 +858,24 @@

// Turn captured proxy hits + decoy atimes into behavioral findings.
async function evaluateTripwires(canary, hits) {
async function evaluateTripwires(canary, hits, runtimeHooks = []) {
const findings = [];
// 0) Import-phase tampering of a global network primitive — the response-
// rewriting clipper shape (chalk/qix). Nothing leaves the box, so the egress
// tripwires below stay silent; the observed tell is that IMPORTING the package
// reassigned global.fetch / XMLHttpRequest / Response in place, so it can
// rewrite request or response bodies (swap a wallet address, inject a payload)
// on every call the host later makes. Static analysis can't distinguish this
// from legitimate middleware; behavioral execution can — it watched it happen.
if (Array.isArray(runtimeHooks) && runtimeHooks.length > 0) {
findings.push({
severity: "high",
category: "behavioral-runtime-hook",
file: "CANARY_SANDBOX",
snippet: runtimeHooks.join(", "),
rationale:
`Importing the package reassigned global network primitive(s) in place: ${runtimeHooks.join(", ")}. ` +
"Monkeypatching fetch / XMLHttpRequest / Response at import is the runtime-tampering (crypto-clipper / response-rewriter) shape — it alters requests or responses without exfiltrating a token, so it leaves no egress for the capture proxy to catch. Observed during sandboxed import, not statically inferred."
});
}
// 1) DEFINITIVE: a canary token appeared in captured egress → the install

@@ -963,3 +1066,3 @@ // read that specific decoy AND tried to transmit it. Proof, not inference.

const findings = await evaluateTripwires(canary, proxy.hits);
const findings = await evaluateTripwires(canary, proxy.hits, extractRuntimeHooks(execResult));

@@ -1028,2 +1131,3 @@ if (!options.keepSandbox) {

evaluateTripwires,
extractRuntimeHooks,
matchTokens,

@@ -1030,0 +1134,0 @@ tokenVariants,

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display