+201
| "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 }; |
+201
| "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 | ||
| }; |
@@ -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.", |
+1
-1
| { | ||
| "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. | ||
| [](https://www.npmjs.com/package/pkgxray) | ||
| [](https://www.npmjs.com/package/pkgxray) | ||
| [](https://github.com/adamsjack711-ux/pkgxray/actions/workflows/pkgxray-test.yml) | ||
@@ -14,9 +17,20 @@ [](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> |
+2
-2
@@ -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": { |
+250
-7
@@ -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 | ||
| }; |
+21
-9
| "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 @@ |
+109
-5
@@ -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
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
723058
13.18%27
8%15597
10.33%212
29.27%44
4.76%22
4.76%