penthera 1.4.0 → 1.6.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 (114) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +148 -575
  3. package/bin/scan.js +19 -0
  4. package/docs/commands/README.md +23 -0
  5. package/docs/commands/action.md +21 -0
  6. package/docs/commands/agents.md +38 -0
  7. package/docs/commands/deps.md +115 -0
  8. package/docs/commands/fix.md +53 -0
  9. package/docs/commands/hook.md +69 -0
  10. package/docs/commands/install.md +53 -0
  11. package/docs/commands/scan.md +100 -0
  12. package/docs/commands/skill.md +58 -0
  13. package/docs/compare/README.md +25 -0
  14. package/docs/compare/github.md +60 -0
  15. package/docs/compare/semgrep.md +54 -0
  16. package/docs/compare/snyk.md +62 -0
  17. package/docs/compare/socket.md +78 -0
  18. package/docs/compare/zap.md +59 -0
  19. package/docs/compliance-cra.md +141 -0
  20. package/docs/compliance-nis2.md +3 -1
  21. package/docs/dashboard.md +244 -0
  22. package/docs/evidence.md +8 -4
  23. package/docs/examples/github-actions-deps.yml +4 -4
  24. package/docs/examples/github-actions-scan.yml +4 -4
  25. package/docs/index.md +11 -2
  26. package/docs/internal-audit.md +83 -0
  27. package/docs/marketing.md +151 -0
  28. package/docs/plan.md +101 -0
  29. package/docs/privacy.md +38 -0
  30. package/docs/references.bib +10 -0
  31. package/docs/references.md +1 -0
  32. package/docs/research/socket-positioning-notes.md +183 -0
  33. package/docs/use-cases/README.md +14 -0
  34. package/docs/use-cases/agencies.md +71 -0
  35. package/docs/use-cases/ai-coding-teams.md +81 -0
  36. package/docs/use-cases/eu-sme-compliance.md +68 -0
  37. package/docs/use-cases/open-source-maintainers.md +75 -0
  38. package/docs/use-cases/solo-founder.md +65 -0
  39. package/lib/blackbox/email-dns.js +5 -0
  40. package/lib/compliance/cra.js +525 -0
  41. package/lib/compliance/questionnaire.js +4 -2
  42. package/lib/deps/capabilities.js +49 -12
  43. package/lib/deps/index.js +314 -30
  44. package/lib/deps/license.js +329 -0
  45. package/lib/deps/lockfile.js +29 -3
  46. package/lib/deps/popular-pypi.json +1 -0
  47. package/lib/deps/python/capabilities.js +447 -0
  48. package/lib/deps/python/index.js +234 -0
  49. package/lib/deps/python/lockfiles.js +359 -0
  50. package/lib/deps/python/source.js +396 -0
  51. package/lib/deps/python/toml.js +219 -0
  52. package/lib/deps/report.js +54 -6
  53. package/lib/deps/source.js +149 -24
  54. package/lib/deps/spdx.json +1 -0
  55. package/lib/deps/typosquat.js +24 -13
  56. package/lib/fix/apply.js +83 -19
  57. package/lib/hook/index.js +345 -0
  58. package/lib/hook/policy.js +62 -0
  59. package/lib/install/index.js +59 -18
  60. package/lib/install/policy.js +27 -7
  61. package/lib/jurisdictions/nl.json +9 -0
  62. package/lib/memory/repo-suppress.js +11 -3
  63. package/lib/references.json +77 -0
  64. package/lib/remediation/index.js +9 -0
  65. package/lib/retirejs.js +69 -17
  66. package/lib/rules/regex-guard.js +368 -0
  67. package/lib/templates.js +13 -6
  68. package/lib/tls.js +7 -0
  69. package/lib/whitebox/aibom.js +384 -0
  70. package/lib/whitebox/frameworks.js +8 -5
  71. package/lib/whitebox/sbom.js +45 -10
  72. package/lib/whitebox/secrets.js +31 -8
  73. package/package.json +8 -3
  74. package/skills/penthera/SKILL.md +55 -8
  75. package/src/cli/deps.js +81 -6
  76. package/src/cli/evidence.js +14 -4
  77. package/src/cli/hook.js +131 -0
  78. package/src/cli/install.js +14 -5
  79. package/src/cli/report-outputs.js +25 -2
  80. package/src/cli/ui.js +89 -0
  81. package/src/cli.js +81 -11
  82. package/src/report/audience.js +10 -10
  83. package/src/report/evidence.js +33 -14
  84. package/src/report/footer.js +11 -0
  85. package/src/report/markdown.js +6 -3
  86. package/src/report/md-escape.js +16 -0
  87. package/src/reporter.js +1 -0
  88. package/src/scan-url.js +5 -1
  89. package/src/ui/api/data.js +309 -0
  90. package/src/ui/api/demo.js +464 -0
  91. package/src/ui/api/routes.js +134 -0
  92. package/src/ui/api/runs.js +243 -0
  93. package/src/ui/api/summarize.js +451 -0
  94. package/src/ui/security.js +153 -0
  95. package/src/ui/server.js +186 -0
  96. package/src/ui/static/app.css +723 -0
  97. package/src/ui/static/favicon.svg +1 -0
  98. package/src/ui/static/index.html +57 -0
  99. package/src/ui/static/js/api.js +29 -0
  100. package/src/ui/static/js/app.js +138 -0
  101. package/src/ui/static/js/charts.js +207 -0
  102. package/src/ui/static/js/copy.js +79 -0
  103. package/src/ui/static/js/dom.js +120 -0
  104. package/src/ui/static/js/format.js +42 -0
  105. package/src/ui/static/js/theme.js +10 -0
  106. package/src/ui/static/js/typer.js +99 -0
  107. package/src/ui/static/js/views/ai.js +68 -0
  108. package/src/ui/static/js/views/deps.js +96 -0
  109. package/src/ui/static/js/views/evidence.js +115 -0
  110. package/src/ui/static/js/views/findings.js +165 -0
  111. package/src/ui/static/js/views/history.js +92 -0
  112. package/src/ui/static/js/views/overview.js +184 -0
  113. package/src/ui/static/js/views/run.js +349 -0
  114. package/src/ui/static/js/views/settings.js +67 -0
@@ -48,8 +48,30 @@ export const CAPABILITIES = {
48
48
  why: "Runs an AI coding CLI (claude, gemini, q), the pattern used by the s1ngularity attack to search the disk for secrets." },
49
49
  "token-harvest": { label: "GitHub token harvesting", severity: "critical", confidence: "likely",
50
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
+ // Python only (lib/deps/python): code that runs whenever the interpreter starts, and unpickling.
52
+ "startup-hook": { label: "runs at Python startup", severity: "high", confidence: "confirmed",
53
+ why: "Ships a .pth file with an import line, or writes one. Python runs those lines on every interpreter start in that environment, a known persistence trick." },
54
+ deserialize: { label: "unpickles data", severity: "low", confidence: "likely",
55
+ why: "Loads pickle, marshal or unsafe YAML data, which runs code if the data is attacker-controlled." },
51
56
  };
52
57
 
58
+ /** Python wording for capabilities whose npm description names npm or Node APIs. */
59
+ const PY_WHY = {
60
+ "install-scripts": "Runs code at install time: a setup.py with custom commands, or an in-tree build backend, executes when pip builds the package from source.",
61
+ shell: "Can run arbitrary programs (subprocess, os.system, os.popen, pty).",
62
+ network: "Can open network connections (socket, urllib, requests, httpx, aiohttp and similar).",
63
+ env: "Reads os.environ.",
64
+ "env-bulk": "Serialises or enumerates all of os.environ, which holds tokens and keys.",
65
+ eval: "Builds code at runtime with eval, exec or compile.",
66
+ "dynamic-require": "Imports modules whose names are computed at runtime (__import__, importlib).",
67
+ native: "Ships native extensions (.so, .pyd) or loads libraries with ctypes, which this scanner cannot read.",
68
+ obfuscated: "Code looks deliberately hidden (encoded blobs or compressed payloads handed to exec, escape-heavy strings, PyArmor).",
69
+ "ai-cli-invoke": "Runs an AI coding CLI (claude, gemini, q) through a subprocess, the pattern used by the s1ngularity attack to search the disk for secrets.",
70
+ };
71
+
72
+ /** Why a capability matters, worded for the package's ecosystem. */
73
+ export const capabilityWhy = (id, ecosystem) => (ecosystem === "pypi" && PY_WHY[id]) || CAPABILITIES[id]?.why || id;
74
+
53
75
  /** Stable display order. */
54
76
  export const CAPABILITY_ORDER = Object.keys(CAPABILITIES);
55
77
 
@@ -193,20 +215,25 @@ const GH_TOKEN_CMD_RE = new RegExp(j("\\bgh\\s+auth\\s+", "token\\b"));
193
215
  const GH_CRED_FILE_RE = new RegExp(`(?:${j("\\.git-", "credentials")}|${j("gh[/\\\\\\\\]", "hosts\\.yml")})`);
194
216
  const GH_TOKEN_REGEX_RE = new RegExp(`(?:${j("gh", "p_")}|${j("gh", "o_")}|${j("github", "_pat_")}|${j("gh", "\\[pousr")}|${j("gh", "\\[ps")})`);
195
217
 
218
+ /** Attack-indicator patterns shared with the Python scanner (lib/deps/python). */
219
+ export const INDICATORS = {
220
+ AI_CONFIG_RE, DESKTOP_CONFIG_RE, AI_CLIS, AI_CLI_FLAG_RE, GH_TOKEN_CMD_RE, GH_CRED_FILE_RE, GH_TOKEN_REGEX_RE,
221
+ };
222
+
196
223
  const DECODE_RE = /\batob\s*\(|\bBuffer\.from\([^)]*['"`](?:base64|hex)['"`]|String\.fromCharCode\s*\(|\bdecodeURIComponent\s*\(\s*escape\s*\(/;
197
224
 
198
225
  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"];
226
+ export const RISKY_FOR_INSTALL = ["network", "eval", "obfuscated", "env-bulk", "ai-config-write", "ai-cli-invoke", "token-harvest"];
200
227
 
201
228
  // ── Helpers ───────────────────────────────────────────────────────────────
202
229
 
203
- function snippet(line, idx) {
230
+ export function snippet(line, idx) {
204
231
  const start = Math.max(0, idx - 40);
205
232
  const s = line.slice(start, start + 120).trim();
206
233
  return (start > 0 ? "…" : "") + s + (start + 120 < line.length ? "…" : "");
207
234
  }
208
235
 
209
- function add(caps, id, ev) {
236
+ export function add(caps, id, ev) {
210
237
  const list = caps[id] || (caps[id] = { count: 0, evidence: [] });
211
238
  list.count++;
212
239
  if (list.evidence.length < MAX_EVIDENCE) list.evidence.push(ev);
@@ -228,7 +255,7 @@ export function entropy(s) {
228
255
  * Obfuscation signals for one file (comment-blanked code).
229
256
  * Returns { flagged, signals: string[], line }.
230
257
  */
231
- export function obfuscationSignals(code, { hasDynamicCode = false } = {}) {
258
+ export function obfuscationSignals(code, { hasDynamicCode = false, evalRes = P.eval, decodeRe = DECODE_RE, strongEscapes = true } = {}) {
232
259
  const signals = [];
233
260
  let line = 1;
234
261
  let strong = 0;
@@ -243,7 +270,7 @@ export function obfuscationSignals(code, { hasDynamicCode = false } = {}) {
243
270
  // Printable characters only: ANSI colour codes (\x1b) and control bytes are normal.
244
271
  const hexEsc = code.match(/\\x[2-7][0-9a-fA-F]/g);
245
272
  if (hexEsc && hexEsc.length >= 50 && (hexEsc.length * 4) / code.length >= 0.05) {
246
- strong++;
273
+ if (strongEscapes) strong++;
247
274
  signals.push(`\\x escape density ${Math.round(((hexEsc.length * 4) / code.length) * 100)}%`);
248
275
  if (line === 1) line = lineOf(code, code.indexOf("\\x"));
249
276
  }
@@ -273,8 +300,8 @@ export function obfuscationSignals(code, { hasDynamicCode = false } = {}) {
273
300
  let payload = null;
274
301
  if (blobs.length && hasDynamicCode) {
275
302
  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]);
303
+ const evals = offsets(evalRes);
304
+ const decodes = offsets([decodeRe]);
278
305
  const near = (b, list) => list.some((o) => o >= b.index - PAYLOAD_WINDOW && o <= b.end + PAYLOAD_WINDOW);
279
306
  payload = blobs.find((b) => near(b, evals) && near(b, decodes)) || null;
280
307
  }
@@ -287,14 +314,14 @@ export function obfuscationSignals(code, { hasDynamicCode = false } = {}) {
287
314
  return { flagged, signals, line };
288
315
  }
289
316
 
290
- function lineOf(code, idx) {
317
+ export function lineOf(code, idx) {
291
318
  if (idx <= 0) return 1;
292
319
  let n = 1;
293
320
  for (let i = 0; i < idx; i++) if (code.charCodeAt(i) === 10) n++;
294
321
  return n;
295
322
  }
296
323
 
297
- function findLine(text, needle) {
324
+ export function findLine(text, needle) {
298
325
  const lines = text.split("\n");
299
326
  for (let i = 0; i < lines.length; i++) if (lines[i].includes(needle)) return i + 1;
300
327
  return 1;
@@ -393,7 +420,7 @@ export function scanSource(src, file) {
393
420
  return { caps, exfil, aiConfigProject };
394
421
  }
395
422
 
396
- function mergeCaps(into, from) {
423
+ export function mergeCaps(into, from) {
397
424
  for (const [id, v] of Object.entries(from)) {
398
425
  const t = into[id] || (into[id] = { count: 0, evidence: [] });
399
426
  t.count += v.count;
@@ -426,7 +453,9 @@ export function nodeScriptTargets(cmd) {
426
453
  * Scan a package through a source (see source.js).
427
454
  * @param {{ files(): string[], read(rel): Buffer, size(rel): number }} source
428
455
  * @param {{ prefix?: string }} opts prefix is prepended to evidence paths (e.g. node_modules/foo/)
429
- * @returns {{ name, version, capabilities, installScripts, exfil, filesScanned, skipped }}
456
+ * @returns {{ name, version, capabilities, installScripts, exfil, filesScanned, skipped, unscanned }}
457
+ * unscanned: why the package could not be fully read (archive anomaly, no
458
+ * package.json), else null. Callers must not treat such a package as clean.
430
459
  */
431
460
  export function scanPackage(source, opts = {}) {
432
461
  const prefix = opts.prefix || "";
@@ -434,10 +463,17 @@ export function scanPackage(source, opts = {}) {
434
463
  const files = source.files();
435
464
  let pkg = {};
436
465
  let pkgText = "";
466
+ // Install scripts come only from package.json, so a package whose archive or
467
+ // manifest could not be read is unscanned, never clean (fail closed).
468
+ let unscanned = source.anomaly || null;
437
469
  try {
438
470
  pkgText = source.read("package.json").toString("utf-8");
439
471
  pkg = JSON.parse(pkgText);
440
- } catch { /* no or broken package.json */ }
472
+ if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) throw new Error("not an object");
473
+ } catch {
474
+ pkg = {};
475
+ unscanned = unscanned || "package.json is missing or not valid JSON, so its install scripts are unknown";
476
+ }
441
477
 
442
478
  const installScripts = {};
443
479
  for (const hook of INSTALL_HOOKS) {
@@ -519,5 +555,6 @@ export function scanPackage(source, opts = {}) {
519
555
  exfil,
520
556
  filesScanned,
521
557
  skipped,
558
+ unscanned,
522
559
  };
523
560
  }
package/lib/deps/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  /**
2
- * `penthera deps`: offline, deterministic dependency risk scanning for npm.
2
+ * `penthera deps`: offline, deterministic dependency risk scanning for npm and
3
+ * Python (PyPI). The Python side (lib/deps/python) loads uv/poetry/pdm/Pipfile
4
+ * lockfiles, requirements files and virtualenvs; everything below the tree
5
+ * loaders (findings, diff, typosquats, reports) is shared.
3
6
  *
4
7
  * Reads package-lock.json, statically scans every installed package for what
5
8
  * it can do (lib/deps/capabilities.js), checks names for typosquats against a
@@ -16,9 +19,11 @@ import { existsSync, readFileSync, statSync } from "node:fs";
16
19
  import { join, dirname, relative, resolve, basename } from "node:path";
17
20
  import { findLockfile, readLockfile, parseLockfile, directDependencies, readLockfileAtRef } from "./lockfile.js";
18
21
  import { DirSource, sourceFromNpmCache, npmCacheDir } from "./source.js";
19
- import { scanPackage, CAPABILITIES, CAPABILITY_ORDER, maxSeverity } from "./capabilities.js";
22
+ import { scanPackage, CAPABILITIES, CAPABILITY_ORDER, maxSeverity, capabilityWhy } from "./capabilities.js";
20
23
  import { diffTrees } from "./diff.js";
21
24
  import { findTyposquats, loadPopular } from "./typosquat.js";
25
+ import { checkLicenses } from "./license.js";
26
+ import { hasPythonInputs, loadPythonTree, loadPythonBase } from "./python/index.js";
22
27
 
23
28
  const SOURCE = "deps-scan";
24
29
  const CATEGORY = "dependency-risk";
@@ -62,11 +67,26 @@ function scanTree(pkgs, treeRoot, ctx) {
62
67
  ctx.cache.set(key, src ? { ...scanPackage(src, { prefix: `${p.path}/` }), source: src.kind } : null);
63
68
  }
64
69
  const r = ctx.cache.get(key);
70
+ // Fail closed: a package that could not be fully read has unknown
71
+ // capabilities (null), not an empty set.
72
+ const unscanned = r?.unscanned || null;
73
+ const installScripts = r?.installScripts || {};
74
+ // npm records hasInstallScript from the registry manifest. If it says there
75
+ // is a script and the files we read have none, the scan saw different files
76
+ // than npm will install (or the lockfile was edited): never "clean". One
77
+ // known, benign cause is exempt: a native package that had binding.gyp when
78
+ // it was published (so the registry added the implicit node-gyp install)
79
+ // but ships only prebuilt .node binaries without it, like fsevents 2.x.
80
+ // With no binding.gyp and no script in the files, npm runs nothing there.
81
+ const prebuiltOnly = !!r?.capabilities?.native;
82
+ const scriptMismatch = !!(r && !unscanned && p.hasInstallScript && Object.keys(installScripts).length === 0 && !prebuiltOnly);
65
83
  return {
66
84
  ...p,
67
85
  source: r?.source || null,
68
- capabilities: r ? r.capabilities : null,
69
- installScripts: r?.installScripts || {},
86
+ capabilities: r && !unscanned ? r.capabilities : null,
87
+ unscanned,
88
+ scriptMismatch,
89
+ installScripts,
70
90
  installRisk: r?.installRisk || [],
71
91
  exfil: r?.exfil || [],
72
92
  filesScanned: r?.filesScanned || 0,
@@ -118,8 +138,32 @@ function finding({ title, severity, confidence, method, description, pkg, ev, ca
118
138
  function riskFindings(pkg) {
119
139
  const out = [];
120
140
  const caps = pkg.capabilities;
121
- if (!caps) return out;
122
141
  const at = `${pkg.name}@${pkg.version}`;
142
+ if (pkg.unscanned) {
143
+ out.push(finding({
144
+ title: `${at}: could not be scanned`,
145
+ severity: "high",
146
+ confidence: "confirmed",
147
+ method: "deps-capability-scan",
148
+ description: `${pkg.unscanned}. Penthera fails closed here: treat this package as able to do anything, including run install scripts, until you have read its tarball yourself.`,
149
+ pkg, ev: { file: `${pkg.path}/package.json` }, capabilities: [],
150
+ extra: { unscanned: pkg.unscanned },
151
+ }));
152
+ }
153
+ if (pkg.scriptMismatch) {
154
+ out.push(finding({
155
+ title: `${at}: lockfile lists an install script the scan did not find`,
156
+ severity: "high",
157
+ confidence: "confirmed",
158
+ method: "deps-capability-scan",
159
+ description: "package-lock.json marks this package `hasInstallScript`, but the package.json Penthera read declares no preinstall, install or postinstall script and there is no binding.gyp. Either the lockfile was edited, or the tarball shows a scanner different files than npm installs. Do not install it until the difference is explained.",
160
+ pkg, ev: { file: `${pkg.path}/package.json` }, capabilities: ["install-scripts"],
161
+ extra: { scriptMismatch: true },
162
+ }));
163
+ }
164
+ if (!caps) return out;
165
+ const py = pkg.ecosystem === "pypi";
166
+ const why = (id) => capabilityWhy(id, pkg.ecosystem);
123
167
 
124
168
  for (const id of KNOWN_BAD) {
125
169
  if (!caps[id]) continue;
@@ -130,7 +174,7 @@ function riskFindings(pkg) {
130
174
  severity: "critical",
131
175
  confidence: CAPABILITIES[id].confidence,
132
176
  method: "deps-capability-scan",
133
- description: `${CAPABILITIES[id].why} Evidence: ${evidenceText(caps, [id])}`,
177
+ description: `${why(id)} Evidence: ${evidenceText(caps, [id])}`,
134
178
  pkg, ev, capabilities: [id],
135
179
  }));
136
180
  }
@@ -141,7 +185,7 @@ function riskFindings(pkg) {
141
185
  severity: "high",
142
186
  confidence: "potential",
143
187
  method: "deps-capability-scan",
144
- description: `${CAPABILITIES.obfuscated.why} Signals at ${ev.file}:${ev.line}: ${ev.snippet}. Read the file before trusting it; minified code alone does not trigger this.`,
188
+ description: `${why("obfuscated")} Signals at ${ev.file}:${ev.line}: ${ev.snippet}. Read the file before trusting it; minified code alone does not trigger this.`,
145
189
  pkg, ev, capabilities: ["obfuscated"],
146
190
  }));
147
191
  }
@@ -152,7 +196,7 @@ function riskFindings(pkg) {
152
196
  severity: "high",
153
197
  confidence: "likely",
154
198
  method: "deps-capability-scan",
155
- description: `process.env (tokens, keys, CI secrets) is serialised within a few lines of a network call, the shape of credential exfiltration. Evidence: ${ev.file}:${ev.line} \`${ev.snippet}\`. ${evidenceText(caps, ["network"])}`,
199
+ description: `${py ? "os.environ" : "process.env"} (tokens, keys, CI secrets) is serialised within a few lines of a network call, the shape of credential exfiltration. Evidence: ${ev.file}:${ev.line} \`${ev.snippet}\`. ${evidenceText(caps, ["network"])}`,
156
200
  pkg, ev, capabilities: ["env-bulk", "network"],
157
201
  }));
158
202
  }
@@ -165,18 +209,70 @@ function riskFindings(pkg) {
165
209
  severity: risky ? "medium" : "low",
166
210
  confidence: "confirmed",
167
211
  method: "deps-capability-scan",
168
- description: `${CAPABILITIES["install-scripts"].why} ${hooks}.${risky ? ` Why it stands out: ${pkg.installRisk.join("; ")}.` : ""} Install with \`npm ci --ignore-scripts\` if the package works without it.`,
212
+ description: `${why("install-scripts")} ${hooks}.${risky ? ` Why it stands out: ${pkg.installRisk.join("; ")}.` : ""} ${py ? "Install from a wheel (`pip install --only-binary :all:`) so no build code runs, or read setup.py first." : "Install with `npm ci --ignore-scripts` if the package works without it."}`,
169
213
  pkg, ev, capabilities: ["install-scripts"],
170
214
  }));
171
215
  }
216
+ // Python: .pth import lines run on every interpreter start.
217
+ const hooks = pkg.startupHooks || [];
218
+ if (hooks.length) {
219
+ const risky = hooks.find((h) => h.risk.length);
220
+ const ev = risky || hooks[0];
221
+ out.push(finding({
222
+ title: `${at}: ${risky ? "runs risky code" : "runs code"} at every Python start (.pth)`,
223
+ severity: risky ? "high" : "low",
224
+ confidence: "confirmed",
225
+ method: "deps-capability-scan",
226
+ description: `${why("startup-hook")} ${ev.file}:${ev.line} \`${ev.snippet}\`.${risky ? ` Why it stands out: ${risky.risk.join("; ")}.` : " Editable installs, setuptools and virtualenv ship .pth files like this; check it is one of those."}`,
227
+ pkg, ev, capabilities: ["startup-hook"],
228
+ }));
229
+ } else if (caps["startup-hook"]) {
230
+ const ev = caps["startup-hook"].evidence[0];
231
+ out.push(finding({
232
+ title: `${at}: writes a .pth file (code that runs at every Python start)`,
233
+ severity: "medium",
234
+ confidence: "potential",
235
+ method: "deps-capability-scan",
236
+ description: `${why("startup-hook")} Evidence: ${ev.file}:${ev.line} \`${ev.snippet}\`. Installers and environment tools do this legitimately; a library has no reason to.`,
237
+ pkg, ev, capabilities: ["startup-hook"],
238
+ }));
239
+ }
240
+ if (pkg.modified?.length) {
241
+ out.push(finding({
242
+ title: `${at}: ${pkg.modified.length} installed file(s) changed since install`,
243
+ severity: "medium",
244
+ confidence: "confirmed",
245
+ method: "deps-capability-scan",
246
+ description: `These files no longer match the hashes pip recorded in RECORD when the package was installed: ${pkg.modified.slice(0, 5).join(", ")}${pkg.modified.length > 5 ? ", …" : ""}. Something edited them afterwards: a local patch, or code planting itself in the environment. Reinstall the package and see if it comes back.`,
247
+ pkg, ev: { file: pkg.modified[0], line: 1 }, capabilities: [],
248
+ extra: { modified: pkg.modified },
249
+ }));
250
+ }
172
251
  return out;
173
252
  }
174
253
 
254
+ /** .pth files in site-packages that no installed distribution claims. */
255
+ function unownedHookFindings(hooks) {
256
+ return hooks.map((h) => ({
257
+ title: `Unowned startup hook ${h.file.split("/").pop()} in site-packages`,
258
+ severity: h.risk.length ? "high" : "medium",
259
+ category: CATEGORY,
260
+ source: SOURCE,
261
+ method: "deps-capability-scan",
262
+ confidence: "confirmed",
263
+ description: `${CAPABILITIES["startup-hook"].why} No installed distribution lists this file in its RECORD, so something wrote it after install. ${h.file}:${h.line} \`${h.snippet}\`.${h.risk.length ? ` The line itself uses ${h.risk.join(", ")}.` : ""} Delete it if you cannot trace where it came from.`,
264
+ url: h.file,
265
+ line: h.line,
266
+ capabilities: ["startup-hook"],
267
+ }));
268
+ }
269
+
175
270
  // Capabilities that riskFindings already reports on their own.
176
- const OWN_FINDING = new Set([...KNOWN_BAD, "obfuscated", "install-scripts"]);
271
+ const OWN_FINDING = new Set([...KNOWN_BAD, "obfuscated", "install-scripts", "startup-hook"]);
177
272
 
178
- function diffFindings(diff) {
273
+ function diffFindings(diff, ecosystem = "npm") {
179
274
  const out = [];
275
+ const why = (id) => capabilityWhy(id, ecosystem);
180
276
  for (const a of diff.added) {
181
277
  const ids = a.capabilities || [];
182
278
  const notable = ids.filter((id) => id !== "env" && id !== "dynamic-require" && !OWN_FINDING.has(id));
@@ -186,7 +282,7 @@ function diffFindings(diff) {
186
282
  severity: maxSeverity(notable.map((id) => CAPABILITIES[id].severity)),
187
283
  confidence: "likely",
188
284
  method: "deps-capability-diff",
189
- description: `A package that was not in the base tree. It can: ${notable.map((id) => CAPABILITIES[id].why).join(" ")} Evidence: ${evidenceText(a.pkg.capabilities, notable)}`,
285
+ description: `A package that was not in the base tree. It can: ${notable.map(why).join(" ")} Evidence: ${evidenceText(a.pkg.capabilities, notable)}`,
190
286
  pkg: a.pkg, ev: firstEvidence(a.pkg.capabilities, notable), capabilities: notable,
191
287
  extra: { change: "added" },
192
288
  }));
@@ -199,7 +295,7 @@ function diffFindings(diff) {
199
295
  severity: sev,
200
296
  confidence: u.gained.some((id) => CAPABILITIES[id].confidence === "potential") && u.gained.length === 1 ? "potential" : "likely",
201
297
  method: "deps-capability-diff",
202
- description: `This upgrade can do things the previous version could not. ${u.gained.map((id) => CAPABILITIES[id].why).join(" ")} Evidence: ${evidenceText(u.pkg.capabilities, u.gained)}`,
298
+ description: `This upgrade can do things the previous version could not. ${u.gained.map(why).join(" ")} Evidence: ${evidenceText(u.pkg.capabilities, u.gained)}`,
203
299
  pkg: u.pkg, ev: firstEvidence(u.pkg.capabilities, u.gained), capabilities: u.gained,
204
300
  extra: { change: "upgraded", from: u.from },
205
301
  }));
@@ -213,8 +309,10 @@ function diffFindings(diff) {
213
309
  source: SOURCE,
214
310
  method: "deps-capability-diff",
215
311
  confidence: "confirmed",
216
- description: `No local copy to compare against for: ${missing.map((u) => `${u.name} ${u.from} → ${u.to}`).join(", ")}. Install the base tree (\`npm ci --ignore-scripts\` in a checkout of the base) and pass that folder to --diff, or rerun with --online to fetch the old tarballs from the registry.`,
217
- url: "package-lock.json",
312
+ description: `No local copy to compare against for: ${missing.map((u) => `${u.name} ${u.from} → ${u.to}`).join(", ")}. ${ecosystem === "pypi"
313
+ ? "Create the base environment (`uv sync` or `pip install -r requirements.txt` in a checkout of the base, in a .venv) and pass that folder to --diff; old wheels already in pip's or uv's cache are used automatically when their hash matches the base lockfile."
314
+ : "Install the base tree (`npm ci --ignore-scripts` in a checkout of the base) and pass that folder to --diff, or rerun with --online to fetch the old tarballs from the registry."}`,
315
+ url: ecosystem === "pypi" ? diff.lockfile || "requirements.txt" : "package-lock.json",
218
316
  });
219
317
  }
220
318
  return out;
@@ -267,6 +365,7 @@ function summarize(tree) {
267
365
 
268
366
  /** Load the old tree for --diff (lockfile path or project folder) or --base (git ref). */
269
367
  function loadBase(root, opts) {
368
+ if (opts.baseLock) return { lock: opts.baseLock, treeRoot: null, label: opts.baseLabel || "base" };
270
369
  if (opts.base) {
271
370
  return { lock: readLockfileAtRef(root, opts.base), treeRoot: null, label: opts.base };
272
371
  }
@@ -282,31 +381,36 @@ function loadBase(root, opts) {
282
381
  }
283
382
 
284
383
  /**
285
- * Scan a project's dependencies.
384
+ * npm: package-lock.json plus node_modules or npm's cache.
286
385
  * @param {string} root project folder containing package-lock.json
287
- * @param {{ diff?: string, base?: string, online?: boolean, prod?: boolean,
288
- * onPhase?: Function, cacheDir?: string|string[], now?: number, fetchImpl?: Function,
289
- * treeRoot?: string }} opts treeRoot: where installed packages live, when
290
- * the lockfile describes a tree that is not installed beside it (penthera install)
386
+ * @param {object} opts see scanDeps(). lock / baseLock: lockfile objects to use
387
+ * instead of reading them (the pre-commit hook passes the staged and HEAD
388
+ * lockfiles); changedOnly: in diff mode, scan only packages that changed (fast
389
+ * path); licensePolicy: from normalizeLicensePolicy(), adds category "license"
390
+ * findings
291
391
  */
292
- export async function scanDeps(root, opts = {}) {
392
+ async function scanNpm(root, opts = {}) {
293
393
  const started = Date.now();
294
394
  const onPhase = opts.onPhase || (() => {});
295
- const lockPath = findLockfile(root);
395
+ const lockPath = opts.lock ? join(root, "package-lock.json") : findLockfile(root);
296
396
  if (!lockPath) throw new Error(`No package-lock.json in ${root}. penthera deps reads the lockfile; run \`npm install --package-lock-only\` first.`);
297
- const lock = readLockfile(lockPath);
397
+ const lock = opts.lock || readLockfile(lockPath);
298
398
  const ctx = { cache: new Map(), cacheDirs: [].concat(opts.cacheDir || npmCacheDir()) };
299
399
  const keep = (p) => !p.link && p.version && (!opts.prod || !p.dev);
300
400
 
301
- const pkgs = parseLockfile(lock).filter(keep);
401
+ const diffing = !!(opts.diff || opts.base || opts.baseLock);
402
+ const base = diffing ? loadBase(root, opts) : null;
403
+ let pkgs = parseLockfile(lock).filter(keep);
404
+ if (diffing && opts.changedOnly) {
405
+ const before = new Set(parseLockfile(base.lock).map((p) => `${p.name}@${p.version}`));
406
+ pkgs = pkgs.filter((p) => !before.has(`${p.name}@${p.version}`));
407
+ }
302
408
  onPhase(`Scanning ${pkgs.length} packages from ${basename(lockPath)} (offline)`);
303
409
  const tree = scanTree(pkgs, opts.treeRoot || root, ctx);
304
410
  const direct = new Set(directDependencies(lock));
305
411
 
306
412
  let diff = null;
307
- let base = null;
308
- if (opts.diff || opts.base) {
309
- base = loadBase(root, opts);
413
+ if (diffing) {
310
414
  const oldPkgs = parseLockfile(base.lock).filter(keep);
311
415
  const byVersion = new Map(tree.map((p) => [`${p.name}@${p.version}`, p]));
312
416
  const newNames = new Set(tree.map((p) => p.name));
@@ -338,8 +442,10 @@ export async function scanDeps(root, opts = {}) {
338
442
  // Verified against the base lockfile's integrity before anything is read.
339
443
  const src = await fetchTarballSource(oldPkg?.resolved, oldPkg?.integrity, { fetchImpl: opts.fetchImpl });
340
444
  if (!src) continue;
445
+ const old = scanPackage(src, { prefix: `${oldPkg.path}/` });
446
+ if (old.unscanned) continue; // still not comparable
341
447
  online.fetchedTarballs++;
342
- const o = new Set(Object.keys(scanPackage(src, { prefix: `${oldPkg.path}/` }).capabilities));
448
+ const o = new Set(Object.keys(old.capabilities));
343
449
  const n = new Set(Object.keys(u.pkg.capabilities || {}));
344
450
  u.gained = CAPABILITY_ORDER.filter((id) => n.has(id) && !o.has(id));
345
451
  u.lost = CAPABILITY_ORDER.filter((id) => o.has(id) && !n.has(id));
@@ -363,9 +469,19 @@ export async function scanDeps(root, opts = {}) {
363
469
  const typos = findTyposquats(nameScope);
364
470
  findings.push(...typosquatFindings(typos, new Map(tree.map((p) => [p.name, p])), direct));
365
471
 
472
+ // License policy: the whole tree, or in diff mode only what changed.
473
+ let licenses = null;
474
+ if (opts.licensePolicy) {
475
+ const scope = tree.filter((p) => inScope.has(p));
476
+ onPhase(`Checking ${scope.length} package license(s) against the policy`);
477
+ const r = checkLicenses(scope, opts.licensePolicy, { treeRoot: opts.treeRoot || root, lockfileHasLicenses: (lock.lockfileVersion || 1) >= 2 });
478
+ findings.push(...r.findings);
479
+ licenses = r.summary;
480
+ }
481
+
366
482
  // Optional packages for other platforms (esbuild-style binaries) are expected to be absent.
367
- const unavailable = tree.filter((p) => !p.capabilities && !p.optional);
368
- const skippedOptional = tree.filter((p) => !p.capabilities && p.optional).length;
483
+ const unavailable = tree.filter((p) => !p.capabilities && !p.unscanned && !p.optional);
484
+ const skippedOptional = tree.filter((p) => !p.capabilities && !p.unscanned && p.optional).length;
369
485
  const rel = (p) => {
370
486
  const r = relative(process.cwd(), p);
371
487
  return r === "" ? "." : r.startsWith("..") ? p : r;
@@ -378,8 +494,10 @@ export async function scanDeps(root, opts = {}) {
378
494
  timestamp: new Date(started).toISOString(),
379
495
  duration: Date.now() - started,
380
496
  deps: {
497
+ ecosystem: "npm",
381
498
  lockfile: rel(lockPath),
382
499
  lockfileVersion: lock.lockfileVersion || 1,
500
+ format: `package-lock.json v${lock.lockfileVersion || 1}`,
383
501
  prod: !!opts.prod,
384
502
  network: opts.online ? "online (registry metadata only)" : "offline",
385
503
  total: tree.length,
@@ -387,8 +505,103 @@ export async function scanDeps(root, opts = {}) {
387
505
  skippedOptional,
388
506
  direct: [...direct],
389
507
  notInstalled: unavailable.map((p) => `${p.name}@${p.version}`),
508
+ unscanned: tree.filter((p) => p.unscanned).map((p) => ({ package: `${p.name}@${p.version}`, reason: p.unscanned })),
509
+ scriptMismatch: tree.filter((p) => p.scriptMismatch).map((p) => `${p.name}@${p.version}`),
390
510
  capabilityCounts: summarize(tree),
391
511
  popularList: loadPopular().source,
512
+ packages: tree.map((p) => ({
513
+ name: p.name,
514
+ version: p.version,
515
+ path: p.path,
516
+ dev: p.dev,
517
+ license: p.license,
518
+ direct: direct.has(p.name),
519
+ source: p.source,
520
+ capabilities: p.capabilities,
521
+ })),
522
+ typosquats: typos,
523
+ licenses,
524
+ online,
525
+ diff: diff ? {
526
+ base: base.label,
527
+ added: diff.added.map(({ pkg, ...a }) => ({ ...a, installScripts: pkg.installScripts, unscanned: pkg.unscanned || null, scriptMismatch: !!pkg.scriptMismatch })),
528
+ upgraded: diff.upgraded.map(({ pkg, prev, ...u }) => ({ ...u, installScripts: pkg.installScripts, previousInstallScripts: prev?.installScripts || null, unscanned: pkg.unscanned || null, scriptMismatch: !!pkg.scriptMismatch })),
529
+ removed: diff.removed,
530
+ evidence: Object.fromEntries([...diff.added, ...diff.upgraded].map((x) => {
531
+ const ids = x.gained || x.capabilities || [];
532
+ return [`${x.name}@${x.version || x.to}`, Object.fromEntries(ids.map((id) => [id, x.pkg.capabilities?.[id]?.evidence || []]))];
533
+ })),
534
+ } : null,
535
+ },
536
+ findings,
537
+ };
538
+ }
539
+
540
+ const relCwd = (p) => {
541
+ const r = relative(process.cwd(), p);
542
+ return r === "" ? "." : r.startsWith("..") ? p : r;
543
+ };
544
+
545
+ /** Python: uv/poetry/pdm/Pipfile lockfiles, requirements files, a virtualenv. */
546
+ async function scanPython(root, opts = {}) {
547
+ const started = Date.now();
548
+ const onPhase = opts.onPhase || (() => {});
549
+ const cur = loadPythonTree(root, opts);
550
+ const keep = (p) => !opts.prod || !p.dev;
551
+ onPhase(`Scanning ${cur.tree.length} Python packages from ${cur.format} (offline)`);
552
+ const tree = cur.tree.filter(keep);
553
+ const direct = new Set(cur.direct);
554
+
555
+ let diff = null;
556
+ let base = null;
557
+ if (opts.diff || opts.base) {
558
+ base = loadPythonBase(root, opts, { ...cur, tree });
559
+ onPhase(`Comparing against ${base.label}: ${base.needOld} replaced version(s) to inspect`);
560
+ diff = diffTrees(base.tree.filter(keep), tree);
561
+ diff.lockfile = cur.lockfile;
562
+ }
563
+
564
+ const findings = [];
565
+ const inScope = diff
566
+ ? new Set([...diff.added.map((a) => a.pkg), ...diff.upgraded.map((u) => u.pkg)])
567
+ : new Set(tree);
568
+ for (const p of tree) if (inScope.has(p)) findings.push(...riskFindings(p));
569
+ if (diff) findings.push(...diffFindings(diff, "pypi"));
570
+ if (!diff) findings.push(...unownedHookFindings(cur.unownedHooks));
571
+
572
+ const nameScope = diff ? diff.added.map((a) => a.name) : tree.filter((p) => direct.has(p.name)).map((p) => p.name);
573
+ const typos = findTyposquats(nameScope, "pypi");
574
+ findings.push(...typosquatFindings(typos, new Map(tree.map((p) => [p.name, p])), direct));
575
+
576
+ let online = null;
577
+ if (opts.online) {
578
+ // Registry checks are npm-only for now; say so instead of silently skipping.
579
+ online = { checked: 0, unreachable: 0, fetchedTarballs: 0, note: "--online registry checks cover npm only; PyPI packages were scanned offline." };
580
+ }
581
+
582
+ const unavailable = tree.filter((p) => !p.capabilities);
583
+ return {
584
+ target: relCwd(root),
585
+ mode: "deps",
586
+ local: true,
587
+ reachable: true,
588
+ timestamp: new Date(started).toISOString(),
589
+ duration: Date.now() - started,
590
+ deps: {
591
+ ecosystem: "pypi",
592
+ lockfile: cur.lockfile,
593
+ lockfileVersion: cur.lockfileVersion,
594
+ format: cur.format,
595
+ venv: cur.venv,
596
+ prod: !!opts.prod,
597
+ network: "offline",
598
+ total: tree.length,
599
+ scanned: tree.filter((p) => p.capabilities).length,
600
+ skippedOptional: 0,
601
+ direct: [...direct],
602
+ notInstalled: unavailable.map((p) => (p.version ? `${p.name}@${p.version}` : `${p.name} (unpinned, not installed)`)),
603
+ capabilityCounts: summarize(tree),
604
+ popularList: loadPopular("pypi").source,
392
605
  packages: tree.map((p) => ({
393
606
  name: p.name,
394
607
  version: p.version,
@@ -396,6 +609,7 @@ export async function scanDeps(root, opts = {}) {
396
609
  dev: p.dev,
397
610
  direct: direct.has(p.name),
398
611
  source: p.source,
612
+ ecosystem: "pypi",
399
613
  capabilities: p.capabilities,
400
614
  })),
401
615
  typosquats: typos,
@@ -414,3 +628,73 @@ export async function scanDeps(root, opts = {}) {
414
628
  findings,
415
629
  };
416
630
  }
631
+
632
+ /** One result for a project with both npm and Python dependencies. */
633
+ function combineResults(results) {
634
+ const [first] = results;
635
+ const ds = results.map((r) => r.deps);
636
+ const sum = (k) => ds.reduce((n, d) => n + (d[k] || 0), 0);
637
+ const counts = {};
638
+ for (const id of CAPABILITY_ORDER) counts[id] = ds.reduce((n, d) => n + (d.capabilityCounts[id] || 0), 0);
639
+ const diffs = ds.map((d) => d.diff).filter(Boolean);
640
+ return {
641
+ ...first,
642
+ duration: Math.max(...results.map((r) => r.duration)),
643
+ deps: {
644
+ ...ds[0],
645
+ ecosystem: ds.map((d) => d.ecosystem).join("+"),
646
+ ecosystems: ds.map((d) => d.ecosystem),
647
+ lockfile: ds.map((d) => d.lockfile).join(" + "),
648
+ format: ds.map((d) => d.format).join(" + "),
649
+ venv: ds.find((d) => d.venv)?.venv || null,
650
+ network: ds.map((d) => d.network).find((n) => n !== "offline") || "offline",
651
+ total: sum("total"),
652
+ scanned: sum("scanned"),
653
+ skippedOptional: sum("skippedOptional"),
654
+ direct: ds.flatMap((d) => d.direct),
655
+ notInstalled: ds.flatMap((d) => d.notInstalled),
656
+ capabilityCounts: counts,
657
+ popularList: ds.map((d) => d.popularList).join(", "),
658
+ packages: ds.flatMap((d) => d.packages.map((p) => ({ ecosystem: d.ecosystem, ...p }))),
659
+ typosquats: ds.flatMap((d) => d.typosquats),
660
+ online: ds.find((d) => d.online)?.online || null,
661
+ diff: diffs.length ? {
662
+ base: diffs[0].base,
663
+ added: diffs.flatMap((d) => d.added),
664
+ upgraded: diffs.flatMap((d) => d.upgraded),
665
+ removed: diffs.flatMap((d) => d.removed),
666
+ evidence: Object.assign({}, ...diffs.map((d) => d.evidence)),
667
+ } : null,
668
+ parts: ds,
669
+ },
670
+ findings: results.flatMap((r) => r.findings),
671
+ };
672
+ }
673
+
674
+ /**
675
+ * Scan a project's dependencies: npm (package-lock.json), Python (uv.lock,
676
+ * poetry.lock, pdm.lock, Pipfile.lock, requirements*.txt, a virtualenv), or both.
677
+ * @param {string} root project folder
678
+ * @param {{ diff?: string, base?: string, online?: boolean, prod?: boolean,
679
+ * onPhase?: Function, cacheDir?: string|string[], now?: number, fetchImpl?: Function,
680
+ * treeRoot?: string, ecosystem?: "npm"|"python"|"auto", venv?: string,
681
+ * pipCacheDir?: string, uvCacheDir?: string, lock?: object, baseLock?: object,
682
+ * baseLabel?: string, changedOnly?: boolean, licensePolicy?: object }} opts
683
+ * lock, baseLock, changedOnly, licensePolicy: npm only, see scanNpm(). treeRoot: where installed
684
+ * packages live, when the lockfile describes a tree that is not installed beside
685
+ * it (penthera install). venv: the Python virtualenv to read (default .venv or venv).
686
+ */
687
+ export async function scanDeps(root, opts = {}) {
688
+ // Lockfile objects passed in (the pre-commit hook) are npm lockfiles.
689
+ if (opts.lock || opts.baseLock) return scanNpm(root, opts);
690
+ const eco = opts.ecosystem || "auto";
691
+ const npm = eco !== "python" && !!findLockfile(root);
692
+ const python = eco === "python" || (eco !== "npm" && hasPythonInputs(root, opts.venv));
693
+ if (!npm && !python) {
694
+ throw new Error(`No package-lock.json in ${root}. penthera deps reads the lockfile; run \`npm install --package-lock-only\` first. For Python it reads uv.lock, poetry.lock, pdm.lock, Pipfile.lock, requirements*.txt or a .venv.`);
695
+ }
696
+ const results = [];
697
+ if (npm) results.push(await scanNpm(root, opts));
698
+ if (python) results.push(await scanPython(root, opts));
699
+ return results.length === 1 ? results[0] : combineResults(results);
700
+ }