penthera 1.2.0 → 1.4.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 (88) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +379 -39
  3. package/docs/METHODOLOGY.md +321 -0
  4. package/docs/evidence.md +158 -0
  5. package/docs/examples/github-actions-deps.yml +105 -0
  6. package/docs/examples/github-actions-scan.yml +1 -1
  7. package/docs/examples/pr-dependency-review.yml +56 -0
  8. package/docs/funding/due-diligence-checklist.md +79 -0
  9. package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
  10. package/docs/funding/partner-one-pager.md +84 -0
  11. package/docs/funding/sponsor-tiers.md +81 -0
  12. package/docs/index.md +12 -1
  13. package/docs/local-loop.md +276 -0
  14. package/docs/references.bib +539 -0
  15. package/docs/references.md +77 -0
  16. package/docs/release-plan.md +228 -0
  17. package/lib/adaptive-probes.js +8 -5
  18. package/lib/agents/discover.js +384 -0
  19. package/lib/agents/hidden-content.js +387 -0
  20. package/lib/agents/index.js +286 -0
  21. package/lib/agents/lock.js +115 -0
  22. package/lib/agents/owasp.js +83 -0
  23. package/lib/agents/rules.js +376 -0
  24. package/lib/crawler.js +5 -15
  25. package/lib/data/retire-jsrepository.json.gz +0 -0
  26. package/lib/data/retire-jsrepository.meta.json +7 -0
  27. package/lib/deps/capabilities.js +523 -0
  28. package/lib/deps/diff.js +84 -0
  29. package/lib/deps/index.js +416 -0
  30. package/lib/deps/lockfile.js +106 -0
  31. package/lib/deps/online.js +132 -0
  32. package/lib/deps/popular-packages.json +1 -0
  33. package/lib/deps/report.js +173 -0
  34. package/lib/deps/source.js +171 -0
  35. package/lib/deps/typosquat.js +113 -0
  36. package/lib/fix/apply.js +95 -0
  37. package/lib/fix/diff.js +199 -0
  38. package/lib/fix/index.js +129 -0
  39. package/lib/fix/model.js +108 -0
  40. package/lib/fix/recipes/cookies.js +130 -0
  41. package/lib/fix/recipes/cors.js +116 -0
  42. package/lib/fix/recipes/headers.js +250 -0
  43. package/lib/fix/recipes/rate-limit.js +211 -0
  44. package/lib/fix/recipes/secrets.js +110 -0
  45. package/lib/fix/util.js +231 -0
  46. package/lib/fuzzer.js +14 -8
  47. package/lib/injections.js +19 -15
  48. package/lib/install/index.js +271 -0
  49. package/lib/install/policy.js +117 -0
  50. package/lib/memory/history.js +52 -0
  51. package/lib/memory/index.js +111 -0
  52. package/lib/memory/repo-suppress.js +64 -0
  53. package/lib/memory/store.js +325 -0
  54. package/lib/memory/views.js +198 -0
  55. package/lib/params.js +4 -12
  56. package/lib/provenance.js +2 -0
  57. package/lib/recon.js +12 -20
  58. package/lib/references.js +31 -0
  59. package/lib/references.json +374 -0
  60. package/lib/remediation/index.js +28 -0
  61. package/lib/retirejs.js +50 -22
  62. package/lib/templates.js +7 -10
  63. package/lib/whitebox/frameworks.js +1 -1
  64. package/package.json +16 -4
  65. package/skills/penthera/SKILL.md +103 -22
  66. package/src/agent/git.js +87 -0
  67. package/src/agent/loop.js +519 -0
  68. package/src/agent/tests.js +48 -0
  69. package/src/cli/agent.js +183 -0
  70. package/src/cli/deps.js +84 -0
  71. package/src/cli/evidence.js +67 -0
  72. package/src/cli/fix.js +191 -0
  73. package/src/cli/install.js +225 -0
  74. package/src/cli/memory.js +146 -0
  75. package/src/cli/merge-results.js +2 -0
  76. package/src/cli/report-outputs.js +72 -0
  77. package/src/cli/run-scan.js +2 -0
  78. package/src/cli.js +152 -6
  79. package/src/report/audience.js +270 -0
  80. package/src/report/evidence.js +253 -0
  81. package/src/report/markdown.js +65 -2
  82. package/src/report/model.js +212 -0
  83. package/src/report/template.js +175 -0
  84. package/src/report/timeline.js +145 -0
  85. package/src/reporter.js +109 -5
  86. package/src/scan-url.js +31 -7
  87. package/src/utils/http.js +252 -11
  88. package/src/utils/url.js +23 -0
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Static capability detection for npm packages (`penthera deps`).
3
+ *
4
+ * For one package, answer "what can this code do?": run at install time, spawn
5
+ * a shell, open sockets, write outside its folder, read the environment, build
6
+ * code at runtime, load native binaries. Plus signals that a package is hiding
7
+ * something (obfuscation) and indicators from real attacks (writing AI agent
8
+ * config, driving AI CLIs to search the disk, harvesting GitHub tokens).
9
+ *
10
+ * Deliberately simple and deterministic: comments are blanked by a small
11
+ * tokenizer, then line-level patterns run over the code. No AST, no network, no
12
+ * model. Every capability carries file:line evidence so a human can check it.
13
+ * A regex pass misses things a determined attacker can hide; that is why the
14
+ * obfuscation signals exist, and why the output says "capability" rather than
15
+ * "safe".
16
+ *
17
+ * Literals that name attack indicators are assembled at runtime so Penthera's
18
+ * own source does not read like the payloads it looks for.
19
+ */
20
+
21
+ const j = (...parts) => parts.join("");
22
+
23
+ /** Capability catalogue. `severity` is used when a package newly gains it. */
24
+ export const CAPABILITIES = {
25
+ "install-scripts": { label: "install script", severity: "high", confidence: "confirmed",
26
+ why: "Runs code on `npm install`, before you have used or reviewed the package." },
27
+ shell: { label: "shell", severity: "medium", confidence: "likely",
28
+ why: "Can run arbitrary programs through child_process." },
29
+ network: { label: "network", severity: "medium", confidence: "likely",
30
+ why: "Can open network connections (http, https, net, dgram, tls, fetch, WebSocket)." },
31
+ "fs-write": { label: "writes outside its folder", severity: "medium", confidence: "potential",
32
+ why: "Writes files and refers to the home directory or system paths." },
33
+ env: { label: "env vars", severity: "low", confidence: "likely",
34
+ why: "Reads process.env." },
35
+ "env-bulk": { label: "whole environment", severity: "medium", confidence: "likely",
36
+ why: "Serialises or enumerates all of process.env, which holds tokens and keys." },
37
+ eval: { label: "dynamic code", severity: "medium", confidence: "likely",
38
+ why: "Builds code at runtime with eval, new Function or vm." },
39
+ "dynamic-require": { label: "computed require/import", severity: "low", confidence: "likely",
40
+ why: "Loads modules whose names are computed at runtime." },
41
+ native: { label: "native binary", severity: "low", confidence: "confirmed",
42
+ why: "Ships or builds native code (.node, binding.gyp) that this scanner cannot read." },
43
+ obfuscated: { label: "obfuscated", severity: "high", confidence: "potential",
44
+ why: "Code looks deliberately hidden (obfuscator identifiers, escape-heavy strings, encoded blobs fed to eval)." },
45
+ "ai-config-write": { label: "writes AI agent config", severity: "critical", confidence: "likely",
46
+ why: "Writes AI assistant config (~/.claude, ~/.cursor, .mcp.json, Claude Desktop config). A dependency has no business doing this." },
47
+ "ai-cli-invoke": { label: "drives AI CLIs", severity: "critical", confidence: "likely",
48
+ why: "Runs an AI coding CLI (claude, gemini, q), the pattern used by the s1ngularity attack to search the disk for secrets." },
49
+ "token-harvest": { label: "GitHub token harvesting", severity: "critical", confidence: "likely",
50
+ why: "Reads GitHub credentials: runs the gh CLI to print its token, reads gh's hosts file or git's credential store, or matches token-shaped strings next to network code." },
51
+ };
52
+
53
+ /** Stable display order. */
54
+ export const CAPABILITY_ORDER = Object.keys(CAPABILITIES);
55
+
56
+ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
57
+ export const maxSeverity = (sevs) => sevs.reduce((m, s) => (SEV_RANK[s] > SEV_RANK[m] ? s : m), "info");
58
+
59
+ const JS_EXT = /\.(c|m)?jsx?$/i;
60
+ const MAX_JS_BYTES = 8 * 1024 * 1024;
61
+ const MAX_EVIDENCE = 5;
62
+ const EXFIL_WINDOW = 40; // lines
63
+ const NEAR_WINDOW = 30; // lines
64
+ const PAYLOAD_WINDOW = 3000; // characters
65
+
66
+ // ── Tokenizer: blank comments, keep strings and line numbers ─────────────
67
+
68
+ const REGEX_PREV = new Set("(,=:[!&|?{};+-*%<>~^".split(""));
69
+ const REGEX_KEYWORDS = /(?:^|[^\w$])(return|typeof|case|do|else|in|of|void|yield|await|delete|instanceof|new|throw)$/;
70
+
71
+ /**
72
+ * Replace comment bodies with spaces (newlines preserved) so patterns don't
73
+ * fire on documentation. Strings, template literals and regex literals are
74
+ * skipped over intact. Heuristic, not a full parser.
75
+ */
76
+ export function blankComments(src) {
77
+ const n = src.length;
78
+ const parts = [];
79
+ let last = 0;
80
+ let i = 0;
81
+ let prev = ""; // previous significant character
82
+ const blank = (s) => s.replace(/[^\n]/g, " ");
83
+ while (i < n) {
84
+ const ch = src[i];
85
+ const nx = src[i + 1];
86
+ if (ch === "/" && nx === "/") {
87
+ let end = src.indexOf("\n", i);
88
+ if (end === -1) end = n;
89
+ parts.push(src.slice(last, i), " ".repeat(end - i));
90
+ i = last = end;
91
+ continue;
92
+ }
93
+ if (ch === "/" && nx === "*") {
94
+ let end = src.indexOf("*/", i + 2);
95
+ end = end === -1 ? n : end + 2;
96
+ parts.push(src.slice(last, i), blank(src.slice(i, end)));
97
+ i = last = end;
98
+ continue;
99
+ }
100
+ if (ch === '"' || ch === "'") {
101
+ i++;
102
+ while (i < n && src[i] !== ch && src[i] !== "\n") i += src[i] === "\\" ? 2 : 1;
103
+ i++;
104
+ prev = ch;
105
+ continue;
106
+ }
107
+ if (ch === "`") {
108
+ i++;
109
+ while (i < n && src[i] !== "`") i += src[i] === "\\" ? 2 : 1;
110
+ i++;
111
+ prev = ch;
112
+ continue;
113
+ }
114
+ if (ch === "/") {
115
+ const isRegex = prev === "" || REGEX_PREV.has(prev) ||
116
+ (/[\w$]/.test(prev) && REGEX_KEYWORDS.test(src.slice(Math.max(0, i - 12), i).trimEnd()));
117
+ if (isRegex) {
118
+ i++;
119
+ let inClass = false;
120
+ while (i < n && src[i] !== "\n") {
121
+ const c = src[i];
122
+ if (c === "\\") { i += 2; continue; }
123
+ if (c === "[") inClass = true;
124
+ else if (c === "]") inClass = false;
125
+ else if (c === "/" && !inClass) break;
126
+ i++;
127
+ }
128
+ i++;
129
+ prev = "a"; // a regex literal behaves like an operand
130
+ continue;
131
+ }
132
+ }
133
+ if (ch !== " " && ch !== "\t" && ch !== "\n" && ch !== "\r") prev = ch;
134
+ i++;
135
+ }
136
+ parts.push(src.slice(last));
137
+ return parts.join("");
138
+ }
139
+
140
+ // ── Patterns ──────────────────────────────────────────────────────────────
141
+
142
+ /** require("x") / import "x" / from "x" / import("x"), with optional node: prefix and subpath. */
143
+ const moduleRe = (mods) => new RegExp(
144
+ String.raw`(?:(?<![\w$.])require\s*\(\s*|\bfrom\s*|(?<![\w$.])import\s*\(\s*|(?<![\w$.])import\s+)(['"\`])(?:node:)?(?:${mods})(?:\/[\w/]*)?\1`,
145
+ );
146
+
147
+ const P = {
148
+ shell: [moduleRe("child_process"), /process\.binding\(\s*['"]spawn_sync['"]/],
149
+ network: [
150
+ moduleRe("http|https|http2|net|dgram|tls"),
151
+ /(?<![\w$.]|function\s{1,4}|\bfunction\s*\*?\s*)fetch\s*\(\s*[^)\s]/,
152
+ /\bnew\s+(?:WebSocket|XMLHttpRequest)\s*\(/,
153
+ /\bnavigator\.sendBeacon\s*\(/,
154
+ ],
155
+ env: [/\bprocess\.env\b/],
156
+ envSerialise: /\bJSON\.stringify\(\s*process\.env\s*[,)]|\bBuffer\.from\(\s*JSON\.stringify\(\s*process\.env/,
157
+ envBulk: [
158
+ /\bJSON\.stringify\(\s*process\.env\s*[,)]/,
159
+ /\bObject\.(?:keys|entries|values)\(\s*process\.env\s*\)/,
160
+ /\bfor\s*\(\s*(?:const|let|var)?\s*[\w$]+\s+in\s+process\.env\s*\)/,
161
+ ],
162
+ eval: [
163
+ /(?<![\w$.])eval\s*\(/,
164
+ /\bnew\s+Function\s*\((?!\s*['"`]return this['"`]\s*\))/,
165
+ /(?<![\w$.]|new\s{1,4})Function\s*\((?!\s*\))(?!\s*['"`]return this['"`]\s*\))/,
166
+ moduleRe("vm"),
167
+ ],
168
+ dynamicRequire: [
169
+ /(?<![\w$.]|function\s{1,4})require\s*\(\s*(?![\s'")]|`[^`$]*`)/,
170
+ /(?<![\w$.])import\s*\(\s*(?![\s'")]|`[^`$]*`)/,
171
+ ],
172
+ native: [/\bprocess\.dlopen\s*\(/],
173
+ fsReadCall: /\b(?:readFile|readFileSync|createReadStream)\s*\(/,
174
+ fsWriteCall: /\b(?:writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream|copyFile|copyFileSync|cpSync|rename|renameSync|symlink|symlinkSync|outputFile|outputFileSync|writeJson|writeJsonSync|chmod|chmodSync)\s*\(/,
175
+ outsidePath: /\b(?:os\.)?homedir\s*\(\s*\)|process\.env\.(?:HOME|USERPROFILE|APPDATA|LOCALAPPDATA|XDG_CONFIG_HOME)\b|['"`]~\/|['"`]\/(?:etc|usr|var|Library|System)\/|['"`][A-Z]:\\\\(?:Windows|Users)/,
176
+ dataUri: /data:(?:image|font|audio|video)\/[\w.+-]+;base64,$/,
177
+ };
178
+
179
+ // Attack indicators, assembled at runtime.
180
+ const escRe = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
181
+ const AI_CONFIG_TARGETS = [j(".cl", "aude"), j(".cur", "sor"), j(".m", "cp.json"), j("claude_", "desktop_", "config.json")];
182
+ const AI_CONFIG_RE = new RegExp(
183
+ `['"\`/\\\\](?:${AI_CONFIG_TARGETS.map(escRe).join("|")})(?:['"\`/\\\\]|$)`,
184
+ );
185
+ const DESKTOP_CONFIG_RE = new RegExp(escRe(AI_CONFIG_TARGETS[3]));
186
+ const AI_CLIS = [j("cla", "ude"), j("gem", "ini"), "q"];
187
+ const AI_CLI_CALL_RE = new RegExp(
188
+ String.raw`\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execa|execaSync)\s*\(\s*['"\`](?:${AI_CLIS.join("|")})(?:['"\`]|\s)`,
189
+ );
190
+ const AI_CLI_FLAGS = [j("--dangerously", "-skip-", "permissions"), j("--yo", "lo"), j("--trust-", "all-", "tools")];
191
+ const AI_CLI_FLAG_RE = new RegExp(`['"\`\\s](?:${AI_CLI_FLAGS.join("|")})(?:['"\`\\s]|$)`);
192
+ const GH_TOKEN_CMD_RE = new RegExp(j("\\bgh\\s+auth\\s+", "token\\b"));
193
+ const GH_CRED_FILE_RE = new RegExp(`(?:${j("\\.git-", "credentials")}|${j("gh[/\\\\\\\\]", "hosts\\.yml")})`);
194
+ const GH_TOKEN_REGEX_RE = new RegExp(`(?:${j("gh", "p_")}|${j("gh", "o_")}|${j("github", "_pat_")}|${j("gh", "\\[pousr")}|${j("gh", "\\[ps")})`);
195
+
196
+ const DECODE_RE = /\batob\s*\(|\bBuffer\.from\([^)]*['"`](?:base64|hex)['"`]|String\.fromCharCode\s*\(|\bdecodeURIComponent\s*\(\s*escape\s*\(/;
197
+
198
+ const SUSPICIOUS_INSTALL_CMD = /\b(?:curl|wget|powershell|Invoke-WebRequest|iwr)\b|\|\s*(?:ba)?sh\b|base64\s+(?:-d|--decode)/i;
199
+ const RISKY_FOR_INSTALL = ["network", "eval", "obfuscated", "env-bulk", "ai-config-write", "ai-cli-invoke", "token-harvest"];
200
+
201
+ // ── Helpers ───────────────────────────────────────────────────────────────
202
+
203
+ function snippet(line, idx) {
204
+ const start = Math.max(0, idx - 40);
205
+ const s = line.slice(start, start + 120).trim();
206
+ return (start > 0 ? "…" : "") + s + (start + 120 < line.length ? "…" : "");
207
+ }
208
+
209
+ function add(caps, id, ev) {
210
+ const list = caps[id] || (caps[id] = { count: 0, evidence: [] });
211
+ list.count++;
212
+ if (list.evidence.length < MAX_EVIDENCE) list.evidence.push(ev);
213
+ }
214
+
215
+ /** Shannon entropy in bits per character. */
216
+ export function entropy(s) {
217
+ const freq = new Map();
218
+ for (const ch of s) freq.set(ch, (freq.get(ch) || 0) + 1);
219
+ let h = 0;
220
+ for (const c of freq.values()) {
221
+ const p = c / s.length;
222
+ h -= p * Math.log2(p);
223
+ }
224
+ return h;
225
+ }
226
+
227
+ /**
228
+ * Obfuscation signals for one file (comment-blanked code).
229
+ * Returns { flagged, signals: string[], line }.
230
+ */
231
+ export function obfuscationSignals(code, { hasDynamicCode = false } = {}) {
232
+ const signals = [];
233
+ let line = 1;
234
+ let strong = 0;
235
+
236
+ const obfIds = code.match(/\b_0x[0-9a-f]{4,6}\b/g);
237
+ if (obfIds && obfIds.length >= 10) {
238
+ strong++;
239
+ signals.push(`${obfIds.length} obfuscator-style identifiers (_0x…)`);
240
+ line = lineOf(code, code.search(/\b_0x[0-9a-f]{4,6}\b/));
241
+ }
242
+
243
+ // Printable characters only: ANSI colour codes (\x1b) and control bytes are normal.
244
+ const hexEsc = code.match(/\\x[2-7][0-9a-fA-F]/g);
245
+ if (hexEsc && hexEsc.length >= 50 && (hexEsc.length * 4) / code.length >= 0.05) {
246
+ strong++;
247
+ signals.push(`\\x escape density ${Math.round(((hexEsc.length * 4) / code.length) * 100)}%`);
248
+ if (line === 1) line = lineOf(code, code.indexOf("\\x"));
249
+ }
250
+
251
+ // Encoded blobs: long base64/hex runs, or shorter runs with high entropy.
252
+ const blobs = [];
253
+ for (const m of code.matchAll(/[A-Za-z0-9+/]{200,}={0,2}|\b[0-9a-fA-F]{400,}\b/g)) {
254
+ const s = m[0];
255
+ const before = code.slice(Math.max(0, m.index - 40), m.index);
256
+ if (P.dataUri.test(before)) continue; // embedded image/font
257
+ if (/^[a-z]+$/i.test(s)) continue; // a long word, not an encoding
258
+ if (s.startsWith("AGFzbQ")) continue; // base64 WebAssembly module ("\0asm"), sandboxed
259
+ const e = entropy(s.slice(0, 4000));
260
+ const isHex = /^[0-9a-fA-F]+$/.test(s);
261
+ if (s.length >= 1000 || (!isHex && e >= 5.0)) {
262
+ blobs.push({ len: s.length, e, index: m.index, end: m.index + s.length, isHex });
263
+ if (blobs.length >= 50) break;
264
+ }
265
+ }
266
+
267
+ let longest = 0;
268
+ for (const l of code.split("\n")) if (l.length > longest) longest = l.length;
269
+
270
+ // The classic dropper: decode a blob and hand it to eval, all in one place.
271
+ // Requires the blob, a decode call and dynamic code within a few thousand
272
+ // characters of each other; any two of them far apart in a bundle is normal.
273
+ let payload = null;
274
+ if (blobs.length && hasDynamicCode) {
275
+ const offsets = (res) => res.flatMap((re) => [...code.matchAll(new RegExp(re.source, "g"))].map((m) => m.index));
276
+ const evals = offsets(P.eval);
277
+ const decodes = offsets([DECODE_RE]);
278
+ const near = (b, list) => list.some((o) => o >= b.index - PAYLOAD_WINDOW && o <= b.end + PAYLOAD_WINDOW);
279
+ payload = blobs.find((b) => near(b, evals) && near(b, decodes)) || null;
280
+ }
281
+ const blob = payload || blobs[0];
282
+ if (blob) signals.push(`${blob.isHex ? "hex" : "base64-like"} blob of ${blob.len} chars (entropy ${blob.e.toFixed(1)})`);
283
+ if (longest >= 5000) signals.push(`line of ${longest} chars`);
284
+ if (payload) signals.push("blob decoded and run with eval/new Function/vm");
285
+ const flagged = strong > 0 || !!payload;
286
+ if (flagged && line === 1 && payload) line = lineOf(code, payload.index);
287
+ return { flagged, signals, line };
288
+ }
289
+
290
+ function lineOf(code, idx) {
291
+ if (idx <= 0) return 1;
292
+ let n = 1;
293
+ for (let i = 0; i < idx; i++) if (code.charCodeAt(i) === 10) n++;
294
+ return n;
295
+ }
296
+
297
+ function findLine(text, needle) {
298
+ const lines = text.split("\n");
299
+ for (let i = 0; i < lines.length; i++) if (lines[i].includes(needle)) return i + 1;
300
+ return 1;
301
+ }
302
+
303
+ // ── Per-file scan ─────────────────────────────────────────────────────────
304
+
305
+ /**
306
+ * Scan one JS file's source. Returns { caps, fileFlags } where caps is
307
+ * { capId: { count, evidence: [{ file, line, snippet }] } }.
308
+ */
309
+ export function scanSource(src, file) {
310
+ const caps = {};
311
+ const code = blankComments(src);
312
+ const lines = code.split("\n");
313
+ const has = { shell: false, network: false, fsWrite: false, fsRead: false, outside: false, envBulk: false, eval: false };
314
+ const firstHit = {};
315
+ const netLines = [];
316
+ const writeHits = [];
317
+ const outsideLines = [];
318
+ const aiConfigHits = [];
319
+ const serialiseHits = [];
320
+
321
+ for (let i = 0; i < lines.length; i++) {
322
+ const l = lines[i];
323
+ if (!l.trim()) continue;
324
+ const ev = (re) => {
325
+ const m = l.match(re);
326
+ return m ? { file, line: i + 1, snippet: snippet(l, m.index) } : null;
327
+ };
328
+ const hit = (id, res) => {
329
+ for (const re of res) {
330
+ const e = ev(re);
331
+ if (e) { add(caps, id, e); return e; }
332
+ }
333
+ return null;
334
+ };
335
+ if (hit("shell", P.shell)) has.shell = true;
336
+ const net = hit("network", P.network);
337
+ if (net) { has.network = true; netLines.push(i + 1); }
338
+ hit("env", P.env);
339
+ const bulk = hit("env-bulk", P.envBulk);
340
+ if (bulk) { has.envBulk = true; if (P.envSerialise.test(l)) serialiseHits.push(bulk); }
341
+ if (hit("eval", P.eval)) has.eval = true;
342
+ hit("dynamic-require", P.dynamicRequire);
343
+ hit("native", P.native);
344
+
345
+ const w = ev(P.fsWriteCall);
346
+ if (w) { has.fsWrite = true; writeHits.push(w); }
347
+ if (P.outsidePath.test(l)) { has.outside = true; outsideLines.push(i + 1); }
348
+
349
+ const aiCfg = ev(AI_CONFIG_RE);
350
+ if (aiCfg) aiConfigHits.push(aiCfg);
351
+ const aiCall = ev(AI_CLI_CALL_RE) || ev(AI_CLI_FLAG_RE);
352
+ if (aiCall) firstHit.aiCli = firstHit.aiCli || aiCall;
353
+ const ghCmd = ev(GH_TOKEN_CMD_RE);
354
+ if (ghCmd) firstHit.ghCmd = firstHit.ghCmd || ghCmd;
355
+ const ghFile = ev(GH_CRED_FILE_RE);
356
+ if (ghFile) firstHit.ghFile = firstHit.ghFile || ghFile;
357
+ if (P.fsReadCall.test(l)) has.fsRead = true;
358
+ const ghShape = ev(GH_TOKEN_REGEX_RE);
359
+ if (ghShape) firstHit.ghShape = firstHit.ghShape || ghShape;
360
+ }
361
+
362
+ // Writes outside the package: a write call within a few lines of a home
363
+ // directory or system path reference.
364
+ const near = (line, lines) => lines.some((n) => Math.abs(n - line) <= NEAR_WINDOW);
365
+ const outsideWrite = writeHits.find((w) => near(w.line, outsideLines));
366
+ if (outsideWrite) add(caps, "fs-write", outsideWrite);
367
+
368
+ // AI agent config paths next to a write call. In the home directory (or
369
+ // Claude Desktop's config, which only lives there) it is the s1ngularity
370
+ // pattern. A project-level .mcp.json or .claude/ written by a CLI the user
371
+ // runs (e.g. an `init` command) is normal, so that only counts when an
372
+ // install hook runs the file (decided in scanPackage).
373
+ const writeLines = writeHits.map((w) => w.line);
374
+ const aiWrites = aiConfigHits.filter((h) => near(h.line, writeLines));
375
+ const homeWrite = aiWrites.find((h) => near(h.line, outsideLines) || DESKTOP_CONFIG_RE.test(h.snippet));
376
+ if (homeWrite) add(caps, "ai-config-write", homeWrite);
377
+ const aiConfigProject = homeWrite ? null : aiWrites[0] || null;
378
+ if (firstHit.aiCli && has.shell) add(caps, "ai-cli-invoke", firstHit.aiCli);
379
+ // The token command only matters if the file can run it; the credential
380
+ // files only matter if the file reads files or talks to the network.
381
+ if (firstHit.ghCmd && has.shell) add(caps, "token-harvest", firstHit.ghCmd);
382
+ else if (firstHit.ghFile && (has.fsRead || has.network)) add(caps, "token-harvest", firstHit.ghFile);
383
+ else if (firstHit.ghShape && has.network && (has.envBulk || has.outside)) add(caps, "token-harvest", firstHit.ghShape);
384
+
385
+ const obf = obfuscationSignals(code, { hasDynamicCode: has.eval });
386
+ if (obf.flagged) {
387
+ add(caps, "obfuscated", { file, line: obf.line, snippet: obf.signals.join("; ") });
388
+ }
389
+
390
+ // Composite: the whole environment serialised within a few lines of network
391
+ // code is the exfiltration shape. Enumerating keys to filter them is not.
392
+ const exfil = serialiseHits.find((e) => netLines.some((n) => Math.abs(n - e.line) <= EXFIL_WINDOW)) || null;
393
+ return { caps, exfil, aiConfigProject };
394
+ }
395
+
396
+ function mergeCaps(into, from) {
397
+ for (const [id, v] of Object.entries(from)) {
398
+ const t = into[id] || (into[id] = { count: 0, evidence: [] });
399
+ t.count += v.count;
400
+ for (const e of v.evidence) if (t.evidence.length < MAX_EVIDENCE) t.evidence.push(e);
401
+ }
402
+ }
403
+
404
+ // ── Per-package scan ──────────────────────────────────────────────────────
405
+
406
+ const INSTALL_HOOKS = ["preinstall", "install", "postinstall"];
407
+
408
+ /**
409
+ * Script files an install hook runs with `node [flags] <file>`. Tokenized
410
+ * rather than matched with one regex: hook commands come from the package
411
+ * under inspection, so the parser must stay linear on hostile input.
412
+ */
413
+ export function nodeScriptTargets(cmd) {
414
+ const out = [];
415
+ const tokens = String(cmd).slice(0, 4096).split(/\s+/);
416
+ for (let i = 0; i < tokens.length; i++) {
417
+ if (!/^(?:.*[\\/])?node(?:\.exe)?$/.test(tokens[i])) continue;
418
+ let j = i + 1;
419
+ while (j < tokens.length && tokens[j].startsWith("-")) j++;
420
+ if (j < tokens.length && /^[\w./-]+$/.test(tokens[j])) out.push(tokens[j]);
421
+ }
422
+ return out;
423
+ }
424
+
425
+ /**
426
+ * Scan a package through a source (see source.js).
427
+ * @param {{ files(): string[], read(rel): Buffer, size(rel): number }} source
428
+ * @param {{ prefix?: string }} opts prefix is prepended to evidence paths (e.g. node_modules/foo/)
429
+ * @returns {{ name, version, capabilities, installScripts, exfil, filesScanned, skipped }}
430
+ */
431
+ export function scanPackage(source, opts = {}) {
432
+ const prefix = opts.prefix || "";
433
+ const caps = {};
434
+ const files = source.files();
435
+ let pkg = {};
436
+ let pkgText = "";
437
+ try {
438
+ pkgText = source.read("package.json").toString("utf-8");
439
+ pkg = JSON.parse(pkgText);
440
+ } catch { /* no or broken package.json */ }
441
+
442
+ const installScripts = {};
443
+ for (const hook of INSTALL_HOOKS) {
444
+ const cmd = pkg.scripts?.[hook];
445
+ if (typeof cmd === "string" && cmd.trim()) {
446
+ installScripts[hook] = cmd;
447
+ add(caps, "install-scripts", { file: `${prefix}package.json`, line: findLine(pkgText, `"${hook}"`), snippet: `${hook}: ${cmd}`.slice(0, 160) });
448
+ }
449
+ }
450
+ // npm runs `node-gyp rebuild` implicitly when binding.gyp exists and no install hook does.
451
+ if (files.includes("binding.gyp") && !installScripts.install && !installScripts.preinstall) {
452
+ installScripts.install = "node-gyp rebuild (implicit, binding.gyp)";
453
+ add(caps, "install-scripts", { file: `${prefix}binding.gyp`, line: 1, snippet: "binding.gyp: npm runs node-gyp rebuild on install" });
454
+ }
455
+
456
+ for (const f of files) {
457
+ if (/\.node$/i.test(f)) add(caps, "native", { file: `${prefix}${f}`, line: 1, snippet: "prebuilt native addon" });
458
+ }
459
+ if (files.includes("binding.gyp")) add(caps, "native", { file: `${prefix}binding.gyp`, line: 1, snippet: "native addon built from source" });
460
+
461
+ const exfil = [];
462
+ const fileCaps = {};
463
+ const aiConfigProject = {};
464
+ let filesScanned = 0;
465
+ const skipped = [];
466
+ const binTargets = new Set(Object.values(typeof pkg.bin === "string" ? { x: pkg.bin } : pkg.bin || {}).map((p) => String(p).replace(/^\.\//, "")));
467
+ for (const f of files) {
468
+ const isJs = JS_EXT.test(f) || binTargets.has(f);
469
+ if (!isJs || /\.d\.[cm]?ts$/i.test(f)) continue;
470
+ if (source.size(f) > MAX_JS_BYTES) { skipped.push(f); continue; }
471
+ let src;
472
+ try { src = source.read(f).toString("utf-8"); } catch { continue; }
473
+ filesScanned++;
474
+ const r = scanSource(src, `${prefix}${f}`);
475
+ fileCaps[f] = Object.keys(r.caps);
476
+ if (r.aiConfigProject) aiConfigProject[f] = r.aiConfigProject;
477
+ mergeCaps(caps, r.caps);
478
+ if (r.exfil) exfil.push(r.exfil);
479
+ }
480
+
481
+ // Install scripts that are themselves suspicious: shell download pipes, or a
482
+ // `node file.js` hook whose file reaches the network, decodes payloads, etc.
483
+ const installRisk = [];
484
+ for (const [hook, cmd] of Object.entries(installScripts)) {
485
+ if (SUSPICIOUS_INSTALL_CMD.test(cmd)) installRisk.push(`${hook} runs \`${cmd.slice(0, 80)}\``);
486
+ // Inline code (`node -e "..."`) is scanned like a file.
487
+ const inline = Object.keys(scanSource(cmd, "package.json").caps).filter((c) => RISKY_FOR_INSTALL.includes(c));
488
+ if (inline.length) installRisk.push(`${hook} inline code (${inline.join(", ")})`);
489
+ // Files the hook runs: `node file.js` or `node -e "require('./file')"`.
490
+ const targets = [
491
+ ...nodeScriptTargets(cmd),
492
+ ...[...cmd.slice(0, 4096).matchAll(/\brequire\(\s*['"]([^'"]+)['"]\s*\)/g)].map((m) => m[1]),
493
+ ].map((t) => t.replace(/^\.\//, ""));
494
+ for (const target of new Set(targets)) {
495
+ const file = [target, `${target}.js`, `${target}/index.js`].find((f) => fileCaps[f]);
496
+ if (!file) continue;
497
+ const bad = fileCaps[file].filter((c) => RISKY_FOR_INSTALL.includes(c));
498
+ if (aiConfigProject[file]) {
499
+ add(caps, "ai-config-write", aiConfigProject[file]);
500
+ bad.push("ai-config-write");
501
+ }
502
+ if (bad.length) installRisk.push(`${hook} runs ${file} (${[...new Set(bad)].join(", ")})`);
503
+ }
504
+ }
505
+
506
+ // Stable evidence order.
507
+ for (const v of Object.values(caps)) {
508
+ v.evidence.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
509
+ }
510
+ const ordered = {};
511
+ for (const id of CAPABILITY_ORDER) if (caps[id]) ordered[id] = caps[id];
512
+
513
+ return {
514
+ name: pkg.name || null,
515
+ version: pkg.version || null,
516
+ capabilities: ordered,
517
+ installScripts,
518
+ installRisk,
519
+ exfil,
520
+ filesScanned,
521
+ skipped,
522
+ };
523
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Capability diff between two dependency trees (`penthera deps --diff`).
3
+ *
4
+ * The question a reviewer has on a lockfile PR is not "is anything vulnerable"
5
+ * but "what can my dependencies do now that they could not do before". This
6
+ * compares capability sets per package name: a package is new, or an upgrade
7
+ * gained (or lost) capabilities, or it stayed the same.
8
+ */
9
+ import { CAPABILITY_ORDER } from "./capabilities.js";
10
+
11
+ /** Compare two version strings numerically (prerelease sorts before release). */
12
+ export function compareVersions(a, b) {
13
+ const parse = (v) => {
14
+ const [core, pre] = String(v || "").replace(/^v/, "").split("-", 2);
15
+ return { nums: core.split(".").map((n) => parseInt(n, 10) || 0), pre: pre || null };
16
+ };
17
+ const x = parse(a);
18
+ const y = parse(b);
19
+ for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) {
20
+ const d = (x.nums[i] || 0) - (y.nums[i] || 0);
21
+ if (d) return d < 0 ? -1 : 1;
22
+ }
23
+ if (x.pre && !y.pre) return -1;
24
+ if (!x.pre && y.pre) return 1;
25
+ return String(x.pre || "").localeCompare(String(y.pre || ""));
26
+ }
27
+
28
+ const ordered = (ids) => CAPABILITY_ORDER.filter((id) => ids.has(id));
29
+
30
+ /**
31
+ * @param {{ name, version, capabilities: object|null }[]} oldTree capabilities null = not inspectable
32
+ * @param {{ name, version, capabilities: object|null }[]} newTree
33
+ */
34
+ export function diffTrees(oldTree, newTree) {
35
+ const group = (tree) => {
36
+ const m = new Map();
37
+ for (const p of tree) {
38
+ if (!m.has(p.name)) m.set(p.name, new Map());
39
+ m.get(p.name).set(p.version, p);
40
+ }
41
+ return m;
42
+ };
43
+ const oldBy = group(oldTree);
44
+ const newBy = group(newTree);
45
+ const added = [];
46
+ const upgraded = [];
47
+ const removed = [];
48
+
49
+ for (const [name, versions] of [...newBy].sort((a, b) => a[0].localeCompare(b[0]))) {
50
+ const before = oldBy.get(name);
51
+ for (const [version, p] of [...versions].sort((a, b) => compareVersions(a[0], b[0]))) {
52
+ if (before?.has(version)) continue; // unchanged
53
+ const capIds = p.capabilities ? Object.keys(p.capabilities) : null;
54
+ if (!before) {
55
+ added.push({ name, version, capabilities: capIds ? ordered(new Set(capIds)) : null, pkg: p });
56
+ continue;
57
+ }
58
+ // Compare against the closest older version present before (else the lowest one).
59
+ const olds = [...before.keys()].sort(compareVersions);
60
+ const from = [...olds].reverse().find((v) => compareVersions(v, version) < 0) || olds[0];
61
+ const prev = before.get(from);
62
+ if (!p.capabilities || !prev.capabilities) {
63
+ upgraded.push({ name, from, to: version, gained: null, lost: null, unavailable: !prev.capabilities ? "old" : "new", pkg: p, prev });
64
+ continue;
65
+ }
66
+ const o = new Set(Object.keys(prev.capabilities));
67
+ const n = new Set(Object.keys(p.capabilities));
68
+ upgraded.push({
69
+ name,
70
+ from,
71
+ to: version,
72
+ gained: ordered(new Set([...n].filter((c) => !o.has(c)))),
73
+ lost: ordered(new Set([...o].filter((c) => !n.has(c)))),
74
+ unavailable: null,
75
+ pkg: p,
76
+ prev,
77
+ });
78
+ }
79
+ }
80
+ for (const [name, versions] of [...oldBy].sort((a, b) => a[0].localeCompare(b[0]))) {
81
+ if (!newBy.has(name)) for (const v of [...versions.keys()].sort(compareVersions)) removed.push({ name, version: v });
82
+ }
83
+ return { added, upgraded, removed };
84
+ }