pkgxray 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/auditor.js +104 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pkgxray",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Zero-dep local CLI and MCP server that scans npm packages and AI-agent extensions for supply-chain risk. OSV vuln pre-check, sandboxed quarantine, tarball-integrity verification, calibrated static heuristics.",
5
5
  "license": "MIT",
6
6
  "author": "Jack Adams-Lovell",
package/src/auditor.js CHANGED
@@ -57,19 +57,54 @@ const PERSISTENCE_REGEXES = [
57
57
  const EXEC_REGEX = /\b(?:child_process\.(?:exec|execSync|spawn|spawnSync|fork)|require\(['"]child_process['"]\)|os\.system\(|subprocess\.(?:Popen|run|call|check_output)|Runtime\.getRuntime\(\)\.exec)/;
58
58
  const DYNAMIC_EVAL_REGEX = /\b(?:eval\s*\(|new\s+Function\s*\(|vm\.runIn[A-Za-z]+Context\b)/;
59
59
 
60
- const NETWORK_REGEX = /\b(?:fetch\s*\(|axios\.[a-z]+\s*\(|got\s*\(|node-fetch|undici|https?\.request\s*\(|XMLHttpRequest|new\s+WebSocket|requests\.[a-z]+\s*\(|urllib(?:\.request)?|net\/http)/i;
60
+ const NETWORK_REGEX = /\b(?:fetch\s*\(|axios\.[a-z]+\s*\(|got\s*\(|node-fetch|undici|https?\.(?:request|get|post|put|delete)\s*\(|XMLHttpRequest|new\s+WebSocket|requests\.[a-z]+\s*\(|urllib(?:\.request)?|net\/http|httpx\.[a-z]+\s*\()/i;
61
61
  const SHELL_NETWORK_REGEX = /(?:^|[\s;&|`$(])(?:curl|wget|Invoke-WebRequest)\s/m;
62
62
 
63
- const URL_SHORTENER_PATTERNS = [
63
+ // Domains that are almost never legitimate destinations from production code.
64
+ // Three buckets: URL shorteners (data hiding), paste/webhook services
65
+ // (drop sites), and OAST/tunneling services (Burp Collaborator-style
66
+ // out-of-band callbacks used in dependency-confusion PoCs and credential
67
+ // staging). A real library would not call any of these.
68
+ const EXFIL_AND_CALLBACK_DOMAINS = [
69
+ // URL shorteners
64
70
  "bit.ly",
65
71
  "tinyurl.com",
66
72
  "t.co/",
67
73
  "goo.gl",
74
+ "is.gd",
75
+ "ow.ly",
76
+ // Paste / drop sites
68
77
  "pastebin.com",
69
78
  "hastebin",
79
+ "transfer.sh",
80
+ // Webhooks
70
81
  "webhook.site",
71
82
  "discord.com/api/webhooks",
72
- "hooks.slack.com"
83
+ "hooks.slack.com",
84
+ "discordapp.com/api/webhooks",
85
+ // OAST / collaborator services (Burp, Caido, ProjectDiscovery)
86
+ "oast.live",
87
+ "oast.fun",
88
+ "oast.online",
89
+ "oast.pro",
90
+ "oast.me",
91
+ "oast.site",
92
+ "oastify.com",
93
+ "interact.sh",
94
+ "burpcollaborator.net",
95
+ // Pipe / request inspector services
96
+ "requestbin.com",
97
+ "requestbin.net",
98
+ "pipedream.net",
99
+ "pipedream.com",
100
+ "rce.ee",
101
+ // Tunneling / reverse proxies
102
+ "ngrok-free.app",
103
+ "ngrok.io",
104
+ "serveo.net",
105
+ "lhr.life",
106
+ "loca.lt",
107
+ "trycloudflare.com"
73
108
  ];
74
109
 
75
110
  // Directive phrases targeting an LLM / auditor. Kept narrow on purpose — generic
@@ -175,6 +210,7 @@ function auditEvidence(input) {
175
210
 
176
211
  const verdict = decideVerdict(findings, evidence);
177
212
  const grading = gradeEvidence(findings, evidence);
213
+ const riskBands = computeRiskBands(findings);
178
214
  return {
179
215
  verdict,
180
216
  grade: grading.grade,
@@ -182,10 +218,58 @@ function auditEvidence(input) {
182
218
  parameters: grading.parameters,
183
219
  summary: summarizeVerdict(verdict, findings),
184
220
  packageName: evidence.packageName || null,
221
+ riskBands,
185
222
  findings: findings.sort(compareFindings)
186
223
  };
187
224
  }
188
225
 
226
+ // Maps the granular finding categories the auditor produces into a smaller
227
+ // set of human-readable "bands" so the verdict explainer can say things like
228
+ // "review because: lifecycle-script + dynamic-eval" instead of dumping the
229
+ // raw category list.
230
+ const BAND_DEFINITIONS = [
231
+ { band: "prompt-injection", label: "prompt-injection", categories: ["injection-attempt"], rationale: "README/docs contain text aimed at instructing an LLM auditor." },
232
+ { band: "credential-access", label: "credential-access", categories: ["credential-access"], rationale: "Reads a path to a credential / wallet / key store near a filesystem read." },
233
+ { band: "persistence", label: "persistence", categories: ["persistence"], rationale: "Writes to a shell rc, crontab, launchagent, systemd unit, or Windows Run key." },
234
+ { band: "exfiltration", label: "network-exfiltration", categories: ["network-exfil-or-loader"], rationale: "Code reaches a hardcoded public IP / shortener / webhook from a file that also has exec or net capability." },
235
+ { band: "obfuscation", label: "obfuscation", categories: ["obfuscation"], rationale: "Large encoded blob co-located with an execution primitive — classic malware shape." },
236
+ { band: "known-vulnerability", label: "known-vulnerability", categories: ["known-vulnerability"], rationale: "OSV reports this package/version as affected by a published vulnerability." },
237
+ { band: "lifecycle-script", label: "lifecycle-script", categories: ["install-hook"], rationale: "Runs a script at install time with the installing user's privileges." },
238
+ { band: "dynamic-eval", label: "dynamic-eval", categories: ["code-execution"], severityMin: "medium", rationale: "Uses eval / new Function / vm — can execute strings as code at runtime." },
239
+ { band: "bulk-env", label: "bulk-env-access", categories: ["environment-access"], rationale: "Reads the entire process environment in bulk; risky paired with network." },
240
+ { band: "clipboard", label: "clipboard-access", categories: ["data-access"], rationale: "Reads or writes the system clipboard — can expose copied secrets." },
241
+ { band: "incomplete-evidence", label: "incomplete-evidence", categories: ["missing-evidence", "missing-package-json", "package-metadata"], rationale: "Source or package.json was missing or unparseable — cannot rule the package safe." },
242
+ { band: "missing-metadata", label: "missing-metadata", categories: ["missing-metadata", "supply-chain-signal"], rationale: "Provenance metadata (npm registry / GitHub) absent or weak; cross-checks skipped." }
243
+ ];
244
+
245
+ const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3 };
246
+
247
+ function computeRiskBands(findings) {
248
+ const result = [];
249
+ for (const def of BAND_DEFINITIONS) {
250
+ const matched = findings.filter((finding) => {
251
+ if (!def.categories.includes(finding.category)) return false;
252
+ if (def.severityMin && SEVERITY_RANK[finding.severity] < SEVERITY_RANK[def.severityMin]) return false;
253
+ return true;
254
+ });
255
+ if (matched.length === 0) continue;
256
+ const severity = matched.reduce(
257
+ (max, f) => (SEVERITY_RANK[f.severity] > SEVERITY_RANK[max] ? f.severity : max),
258
+ "info"
259
+ );
260
+ const examples = matched.slice(0, 3).map((f) => f.file);
261
+ result.push({
262
+ band: def.band,
263
+ label: def.label,
264
+ severity,
265
+ count: matched.length,
266
+ examples,
267
+ rationale: def.rationale
268
+ });
269
+ }
270
+ return result.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
271
+ }
272
+
189
273
  function auditMetadata(evidence, findings) {
190
274
  const packageJson = findPackageJson(evidence.sourceFiles);
191
275
  if (packageJson) {
@@ -489,7 +573,7 @@ function inspectExecNetworkCombinations(file, content, lower, findings) {
489
573
  const hasDynamicEval = DYNAMIC_EVAL_REGEX.test(content);
490
574
  const hasNetwork = NETWORK_REGEX.test(content) || SHELL_NETWORK_REGEX.test(content);
491
575
  const hardcodedIp = findPublicIpInCode(content);
492
- const shortener = URL_SHORTENER_PATTERNS.find((pattern) => lower.includes(pattern));
576
+ const shortener = EXFIL_AND_CALLBACK_DOMAINS.find((pattern) => lower.includes(pattern));
493
577
  const hasBulkEnv = BULK_ENV_REGEXES.some((re) => re.test(content));
494
578
 
495
579
  // HIGH: real exfil/loader signal — execution OR network plus a hardcoded IP /
@@ -773,6 +857,22 @@ function renderMarkdown(report) {
773
857
  lines.push(`Package: \`${report.packageName}\``, "");
774
858
  }
775
859
 
860
+ if (report.riskBands && report.riskBands.length > 0) {
861
+ const verb = report.verdict === "block"
862
+ ? "Block because"
863
+ : report.verdict === "review"
864
+ ? "Review because"
865
+ : "Notes";
866
+ lines.push(`${verb}:`);
867
+ for (const band of report.riskBands) {
868
+ const examples = band.examples && band.examples.length > 0
869
+ ? ` (${band.examples.slice(0, 2).map((e) => `\`${e}\``).join(", ")}${band.count > band.examples.length ? `, +${band.count - band.examples.length} more` : ""})`
870
+ : "";
871
+ lines.push(`- **${band.severity.toUpperCase()} ${band.label}** — ${band.rationale}${examples}`);
872
+ }
873
+ lines.push("");
874
+ }
875
+
776
876
  lines.push("Parameter grades:");
777
877
  for (const [name, parameter] of Object.entries(report.parameters)) {
778
878
  lines.push(