+36
-0
@@ -52,2 +52,4 @@ #!/usr/bin/env node | ||
| " [--no-scan-results] [--timing] # and tool RESULTS are scanned for injection (use in host config)", | ||
| " pkgxray mcp-server # run pkgxray ITSELF as a stdio MCP server (for MCP hosts /", | ||
| " # the MCP Registry entry: `npx -y pkgxray mcp-server`) # exposes audit/guard/lockfile tools. cf. `mcp` above, which audits.", | ||
| "", | ||
@@ -253,2 +255,18 @@ "Evidence JSON fields:", | ||
| async function main() { | ||
| // `pkgxray mcp-server` runs THIS package as a stdio MCP server — the same | ||
| // server the `pkgxray-mcp` bin exposes — so MCP hosts can launch it with | ||
| // `npx -y pkgxray mcp-server` (the registry entry) without a separate | ||
| // package. Intercepted before parseArgs so none of the audit machinery runs. | ||
| // NOTE: distinct from `pkgxray mcp <target>`, which AUDITS another server. | ||
| if (process.argv[2] === "mcp-server") { | ||
| if (process.argv.includes("--help") || process.argv.includes("-h")) { | ||
| process.stderr.write( | ||
| "Usage: pkgxray mcp-server # run pkgxray as a stdio MCP server (for MCP hosts)\n" | ||
| ); | ||
| return; | ||
| } | ||
| require("./mcp-server.js").startStdioServer(); | ||
| return; | ||
| } | ||
| const options = parseArgs(process.argv.slice(2)); | ||
@@ -290,2 +308,20 @@ if (options.help) { | ||
| process.stderr.write(`pkgxray: guard failed to complete (${error.message}); treating as ${verdict}\n`); | ||
| // Under --format json, a JSON consumer must still receive a parseable | ||
| // verdict on stdout — otherwise the degraded review is indistinguishable | ||
| // from a silent crash (empty stdout), which is exactly what batch scanners | ||
| // record as a "scan error". Emit a minimal, schema-tagged object. | ||
| if (options.format === "json") { | ||
| process.stdout.write( | ||
| `${JSON.stringify( | ||
| { | ||
| schemaVersion: 1, | ||
| decision: verdict, | ||
| reference: options.reference, | ||
| scanError: error.message | ||
| }, | ||
| null, | ||
| 2 | ||
| )}\n` | ||
| ); | ||
| } | ||
| process.exitCode = cfg.exitCodeForVerdict(verdict, config); | ||
@@ -292,0 +328,0 @@ return; |
@@ -830,4 +830,7 @@ #!/usr/bin/env node | ||
| // Exported for tests only. The wire behavior is unchanged whether or not these | ||
| // are consumed; they let the offline suite unit-test the config seam. | ||
| // `startStdioServer` is the same entry the `pkgxray-mcp` bin runs on direct | ||
| // invocation; it is exported so `pkgxray mcp-server` (bin/audit.js) can launch | ||
| // this server in-process without a separate package. The rest are exported for | ||
| // tests only — the wire behavior is unchanged whether or not they are consumed; | ||
| // they let the offline suite unit-test the config seam. | ||
| module.exports = { | ||
@@ -837,3 +840,4 @@ applyConfigToGuardResult, | ||
| listTools, | ||
| CONFIG_TOOL_KEY | ||
| CONFIG_TOOL_KEY, | ||
| startStdioServer: attachStdin | ||
| }; |
+2
-1
| { | ||
| "name": "pkgxray", | ||
| "version": "1.0.3", | ||
| "version": "1.0.4", | ||
| "mcpName": "io.github.adamsjack711-ux/pkgxray", | ||
| "description": "Zero-dep local CLI and MCP server that scans npm packages for supply-chain risk. OSV vuln pre-check, sandboxed quarantine, tarball-integrity verification, calibrated static heuristics, GitHub provenance cross-check.", | ||
@@ -5,0 +6,0 @@ "license": "MIT", |
+17
-4
@@ -73,4 +73,8 @@ <div align="center"> | ||
| (no LLM in the verdict path, so injected text can't steer them), only | ||
| citable evidence is reported, and the zero-false-block calibration is | ||
| [regression-gated in CI](docs/benchmark.md). | ||
| citable evidence is reported, and the **zero-heuristic-false-block calibration | ||
| on the top-1000 most-downloaded packages** is | ||
| [regression-gated in CI](docs/benchmark.md). That claim is scoped to the | ||
| most-installed set — it is *not* a claim of zero false blocks on every package; | ||
| the newer MCP/agent-tooling ecosystem is over-blocked and being reconciled | ||
| per-case ([details](docs/benchmark.md#scope-of-the-claim-read-this-first)). | ||
@@ -162,2 +166,6 @@ ## What it catches | ||
| pkgxray is published on the [MCP Registry](https://registry.modelcontextprotocol.io) | ||
| as `io.github.adamsjack711-ux/pkgxray`. Add it to any MCP client — locally | ||
| installed (`pkgxray-mcp`) or zero-install via `npx`: | ||
| ```json | ||
@@ -167,2 +175,6 @@ { "mcpServers": { "pkgxray": { "command": "pkgxray-mcp" } } } | ||
| ```json | ||
| { "mcpServers": { "pkgxray": { "command": "npx", "args": ["-y", "pkgxray", "mcp-server"] } } } | ||
| ``` | ||
| Gate installs with the [hookshot integration](examples/hookshot/) and wrap MCP | ||
@@ -244,4 +256,5 @@ servers with [`pkgxray mcp-proxy`](docs/mcp.md#per-call-runtime-gate-pkgxray-mcp-proxy). | ||
| - **Known-vulnerable packages block at the OSV pre-check**, before download | ||
| - **Calibration** (precision, recall, the 0-false-block gate) is measured by a | ||
| committed [benchmark corpus](benchmark/) that fails CI when it regresses | ||
| - **Calibration** (precision, recall, the 0-heuristic-false-block gate on the | ||
| top-1000 most-downloaded — [scope](docs/benchmark.md#scope-of-the-claim-read-this-first)) | ||
| is measured by a committed [benchmark corpus](benchmark/) that fails CI when it regresses | ||
@@ -248,0 +261,0 @@ Full numbers: [docs/reference.md#performance](docs/reference.md#performance) · |
+9
-0
@@ -302,2 +302,11 @@ "use strict"; | ||
| }; | ||
| // The write stream MUST have an 'error' listener. Without one, a stream error | ||
| // — including the ERR_STREAM_DESTROYED that fires when `cleanup()` destroys the | ||
| // file while `response.pipe(file)` is still flowing (size-cap / redirect / | ||
| // timeout races), or an open-time EACCES/ENOSPC — is emitted as an unhandled | ||
| // 'error' event and takes the whole process down with exit 1 (the verdict is | ||
| // already printed, so a scan crashes AFTER succeeding). Route it to cleanup, | ||
| // which is idempotent, so the download rejects instead of crashing. Mirrors | ||
| // downloadFromCacheServer and quarantine.downloadFile. | ||
| file.on("error", cleanup); | ||
| const get = (currentUrl, hops) => { | ||
@@ -304,0 +313,0 @@ if (hops > 5) return cleanup(new Error("Too many redirects")); |
+59
-4
@@ -54,7 +54,13 @@ "use strict"; | ||
| const DEFAULT_DOWNLOAD_MAX_REDIRECTS = 5; | ||
| // NOTE: `dist` / `build` are deliberately NOT skipped. In a *source repo* those | ||
| // are throwaway build output, but in a *published npm tarball* they are the | ||
| // shipped, executed code — `"main": "dist/index.js"` is ubiquitous, and many | ||
| // packages publish ONLY their compiled `dist/`. Skipping them meant the entire | ||
| // heuristic layer (de-obfuscation, exec/exfil correlation, artifact-diff) never | ||
| // ran on the code that actually runs at install/require time — a trivial evasion | ||
| // (drop the payload in `dist/`, point `main` at it). We keep skipping genuine | ||
| // non-code noise: VCS, vendored deps, framework caches, coverage, py caches. | ||
| const SKIP_DIRS = new Set([ | ||
| ".git", | ||
| "node_modules", | ||
| "dist", | ||
| "build", | ||
| "coverage", | ||
@@ -1222,2 +1228,6 @@ ".next", | ||
| }; | ||
| // Attach the error handler at creation, not inside the response callback: an | ||
| // open-time error (EACCES/ENOSPC) or a write-after-destroy race would | ||
| // otherwise be an unhandled 'error' event that crashes the process. | ||
| file.on("error", fail); | ||
| const succeed = () => { | ||
@@ -1275,3 +1285,2 @@ if (settled) return; | ||
| file.on("finish", succeed); | ||
| file.on("error", fail); | ||
| } | ||
@@ -1300,4 +1309,19 @@ ); | ||
| ]); | ||
| await normalizeTreePermissions(destination); | ||
| } | ||
| // Normalize owner perms across an extracted tree so the scanner can always read | ||
| // every file and traverse every directory. A package can ship a directory with | ||
| // no execute bit or a file with no read bit — by accident (pngjs ships lib/ as | ||
| // 0644) or on purpose, to hide code from the static walk so the scan aborts on | ||
| // EACCES and degrades to review instead of reading (and blocking) the payload. | ||
| // This is our throwaway, never-executed copy, so widening owner read/traverse is | ||
| // safe. `u+rX` adds read to everything and execute to DIRECTORIES only (never | ||
| // makes a regular file executable — `chmod -R` applies +X to a directory before | ||
| // it recurses, so a non-traversable dir gets fixed then descended into), so the | ||
| // file-mode signals we derive from the tar listing are unaffected. | ||
| async function normalizeTreePermissions(root) { | ||
| await run("chmod", ["-R", "u+rX", root]); | ||
| } | ||
| // Validate a `tar -tvzf` listing (array of non-empty lines). Throws | ||
@@ -1550,5 +1574,17 @@ // "Tarball rejected: ..." on any unsafe/unparseable entry so extraction fails | ||
| if (!entry.isFile() || !looksTextLike(relativePath)) { | ||
| if (!entry.isFile()) { | ||
| continue; | ||
| } | ||
| let collectThisFile = looksTextLike(relativePath); | ||
| if (!collectThisFile && !entry.name.includes(".")) { | ||
| // Extensionless regular file (e.g. bare `install` / `preinstall` / | ||
| // `configure`): collect it only if it's a shebang script. These carry | ||
| // no text extension so looksTextLike skips them, yet a lifecycle hook | ||
| // can run them at install time. The peek is a bounded 2-byte read and | ||
| // only happens for the handful of extensionless files in a package. | ||
| collectThisFile = await startsWithShebang(fullPath); | ||
| } | ||
| if (!collectThisFile) { | ||
| continue; | ||
| } | ||
@@ -1592,2 +1628,20 @@ // Use lstat (not stat) so a sneakily-replaced symlink between readdir | ||
| // True if the file's first two bytes are a shebang (`#!`). Used to rescue | ||
| // extensionless executable scripts that looksTextLike skips. Opens with a | ||
| // bounded 2-byte read and never follows a symlink (callers gate on a Dirent | ||
| // isFile() first, and the read targets a plain path). | ||
| async function startsWithShebang(fullPath) { | ||
| let handle; | ||
| try { | ||
| handle = await fsp.open(fullPath, "r"); | ||
| const buf = Buffer.alloc(2); | ||
| const { bytesRead } = await handle.read(buf, 0, 2, 0); | ||
| return bytesRead === 2 && buf[0] === 0x23 && buf[1] === 0x21; // "#!" | ||
| } catch { | ||
| return false; | ||
| } finally { | ||
| if (handle) await handle.close(); | ||
| } | ||
| } | ||
| function decisionForReport(report, policy) { | ||
@@ -1652,2 +1706,3 @@ if (report.verdict === "block") { | ||
| extractTarball, | ||
| normalizeTreePermissions, | ||
| parseTarListingLine, | ||
@@ -1654,0 +1709,0 @@ assertNoControlChars, |
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
621057
4.05%13622
2.64%291
4.68%