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.
- package/package.json +1 -1
- package/src/auditor.js +104 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pkgxray",
|
|
3
|
-
"version": "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
|
-
|
|
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 =
|
|
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(
|