penthera 1.5.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 (104) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +128 -663
  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/dashboard.md +244 -0
  20. package/docs/evidence.md +8 -4
  21. package/docs/examples/github-actions-deps.yml +4 -4
  22. package/docs/examples/github-actions-scan.yml +4 -4
  23. package/docs/index.md +9 -0
  24. package/docs/internal-audit.md +83 -0
  25. package/docs/marketing.md +151 -0
  26. package/docs/plan.md +4 -3
  27. package/docs/privacy.md +38 -0
  28. package/docs/references.bib +10 -0
  29. package/docs/references.md +1 -0
  30. package/docs/research/socket-positioning-notes.md +183 -0
  31. package/docs/use-cases/README.md +14 -0
  32. package/docs/use-cases/agencies.md +71 -0
  33. package/docs/use-cases/ai-coding-teams.md +81 -0
  34. package/docs/use-cases/eu-sme-compliance.md +68 -0
  35. package/docs/use-cases/open-source-maintainers.md +75 -0
  36. package/docs/use-cases/solo-founder.md +65 -0
  37. package/lib/blackbox/email-dns.js +5 -0
  38. package/lib/compliance/questionnaire.js +4 -2
  39. package/lib/deps/capabilities.js +49 -12
  40. package/lib/deps/index.js +290 -30
  41. package/lib/deps/lockfile.js +25 -2
  42. package/lib/deps/popular-pypi.json +1 -0
  43. package/lib/deps/python/capabilities.js +447 -0
  44. package/lib/deps/python/index.js +234 -0
  45. package/lib/deps/python/lockfiles.js +359 -0
  46. package/lib/deps/python/source.js +396 -0
  47. package/lib/deps/python/toml.js +219 -0
  48. package/lib/deps/report.js +37 -6
  49. package/lib/deps/source.js +149 -24
  50. package/lib/deps/typosquat.js +24 -13
  51. package/lib/fix/apply.js +83 -19
  52. package/lib/hook/index.js +41 -8
  53. package/lib/install/index.js +59 -18
  54. package/lib/install/policy.js +17 -5
  55. package/lib/memory/repo-suppress.js +11 -3
  56. package/lib/references.json +77 -0
  57. package/lib/remediation/index.js +9 -0
  58. package/lib/retirejs.js +69 -17
  59. package/lib/rules/regex-guard.js +368 -0
  60. package/lib/templates.js +13 -6
  61. package/lib/tls.js +7 -0
  62. package/lib/whitebox/frameworks.js +8 -5
  63. package/lib/whitebox/secrets.js +20 -7
  64. package/package.json +7 -3
  65. package/skills/penthera/SKILL.md +23 -5
  66. package/src/cli/deps.js +60 -5
  67. package/src/cli/evidence.js +14 -4
  68. package/src/cli/install.js +14 -5
  69. package/src/cli/report-outputs.js +25 -2
  70. package/src/cli/ui.js +89 -0
  71. package/src/cli.js +19 -7
  72. package/src/report/audience.js +10 -10
  73. package/src/report/evidence.js +33 -14
  74. package/src/report/footer.js +11 -0
  75. package/src/report/markdown.js +6 -3
  76. package/src/report/md-escape.js +16 -0
  77. package/src/reporter.js +1 -0
  78. package/src/scan-url.js +5 -1
  79. package/src/ui/api/data.js +309 -0
  80. package/src/ui/api/demo.js +464 -0
  81. package/src/ui/api/routes.js +134 -0
  82. package/src/ui/api/runs.js +243 -0
  83. package/src/ui/api/summarize.js +451 -0
  84. package/src/ui/security.js +153 -0
  85. package/src/ui/server.js +186 -0
  86. package/src/ui/static/app.css +723 -0
  87. package/src/ui/static/favicon.svg +1 -0
  88. package/src/ui/static/index.html +57 -0
  89. package/src/ui/static/js/api.js +29 -0
  90. package/src/ui/static/js/app.js +138 -0
  91. package/src/ui/static/js/charts.js +207 -0
  92. package/src/ui/static/js/copy.js +79 -0
  93. package/src/ui/static/js/dom.js +120 -0
  94. package/src/ui/static/js/format.js +42 -0
  95. package/src/ui/static/js/theme.js +10 -0
  96. package/src/ui/static/js/typer.js +99 -0
  97. package/src/ui/static/js/views/ai.js +68 -0
  98. package/src/ui/static/js/views/deps.js +96 -0
  99. package/src/ui/static/js/views/evidence.js +115 -0
  100. package/src/ui/static/js/views/findings.js +165 -0
  101. package/src/ui/static/js/views/history.js +92 -0
  102. package/src/ui/static/js/views/overview.js +184 -0
  103. package/src/ui/static/js/views/run.js +349 -0
  104. package/src/ui/static/js/views/settings.js +67 -0
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,10 +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";
22
25
  import { checkLicenses } from "./license.js";
26
+ import { hasPythonInputs, loadPythonTree, loadPythonBase } from "./python/index.js";
23
27
 
24
28
  const SOURCE = "deps-scan";
25
29
  const CATEGORY = "dependency-risk";
@@ -63,11 +67,26 @@ function scanTree(pkgs, treeRoot, ctx) {
63
67
  ctx.cache.set(key, src ? { ...scanPackage(src, { prefix: `${p.path}/` }), source: src.kind } : null);
64
68
  }
65
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);
66
83
  return {
67
84
  ...p,
68
85
  source: r?.source || null,
69
- capabilities: r ? r.capabilities : null,
70
- installScripts: r?.installScripts || {},
86
+ capabilities: r && !unscanned ? r.capabilities : null,
87
+ unscanned,
88
+ scriptMismatch,
89
+ installScripts,
71
90
  installRisk: r?.installRisk || [],
72
91
  exfil: r?.exfil || [],
73
92
  filesScanned: r?.filesScanned || 0,
@@ -119,8 +138,32 @@ function finding({ title, severity, confidence, method, description, pkg, ev, ca
119
138
  function riskFindings(pkg) {
120
139
  const out = [];
121
140
  const caps = pkg.capabilities;
122
- if (!caps) return out;
123
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);
124
167
 
125
168
  for (const id of KNOWN_BAD) {
126
169
  if (!caps[id]) continue;
@@ -131,7 +174,7 @@ function riskFindings(pkg) {
131
174
  severity: "critical",
132
175
  confidence: CAPABILITIES[id].confidence,
133
176
  method: "deps-capability-scan",
134
- description: `${CAPABILITIES[id].why} Evidence: ${evidenceText(caps, [id])}`,
177
+ description: `${why(id)} Evidence: ${evidenceText(caps, [id])}`,
135
178
  pkg, ev, capabilities: [id],
136
179
  }));
137
180
  }
@@ -142,7 +185,7 @@ function riskFindings(pkg) {
142
185
  severity: "high",
143
186
  confidence: "potential",
144
187
  method: "deps-capability-scan",
145
- 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.`,
146
189
  pkg, ev, capabilities: ["obfuscated"],
147
190
  }));
148
191
  }
@@ -153,7 +196,7 @@ function riskFindings(pkg) {
153
196
  severity: "high",
154
197
  confidence: "likely",
155
198
  method: "deps-capability-scan",
156
- 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"])}`,
157
200
  pkg, ev, capabilities: ["env-bulk", "network"],
158
201
  }));
159
202
  }
@@ -166,18 +209,70 @@ function riskFindings(pkg) {
166
209
  severity: risky ? "medium" : "low",
167
210
  confidence: "confirmed",
168
211
  method: "deps-capability-scan",
169
- 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."}`,
170
213
  pkg, ev, capabilities: ["install-scripts"],
171
214
  }));
172
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
+ }
173
251
  return out;
174
252
  }
175
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
+
176
270
  // Capabilities that riskFindings already reports on their own.
177
- const OWN_FINDING = new Set([...KNOWN_BAD, "obfuscated", "install-scripts"]);
271
+ const OWN_FINDING = new Set([...KNOWN_BAD, "obfuscated", "install-scripts", "startup-hook"]);
178
272
 
179
- function diffFindings(diff) {
273
+ function diffFindings(diff, ecosystem = "npm") {
180
274
  const out = [];
275
+ const why = (id) => capabilityWhy(id, ecosystem);
181
276
  for (const a of diff.added) {
182
277
  const ids = a.capabilities || [];
183
278
  const notable = ids.filter((id) => id !== "env" && id !== "dynamic-require" && !OWN_FINDING.has(id));
@@ -187,7 +282,7 @@ function diffFindings(diff) {
187
282
  severity: maxSeverity(notable.map((id) => CAPABILITIES[id].severity)),
188
283
  confidence: "likely",
189
284
  method: "deps-capability-diff",
190
- 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)}`,
191
286
  pkg: a.pkg, ev: firstEvidence(a.pkg.capabilities, notable), capabilities: notable,
192
287
  extra: { change: "added" },
193
288
  }));
@@ -200,7 +295,7 @@ function diffFindings(diff) {
200
295
  severity: sev,
201
296
  confidence: u.gained.some((id) => CAPABILITIES[id].confidence === "potential") && u.gained.length === 1 ? "potential" : "likely",
202
297
  method: "deps-capability-diff",
203
- 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)}`,
204
299
  pkg: u.pkg, ev: firstEvidence(u.pkg.capabilities, u.gained), capabilities: u.gained,
205
300
  extra: { change: "upgraded", from: u.from },
206
301
  }));
@@ -214,8 +309,10 @@ function diffFindings(diff) {
214
309
  source: SOURCE,
215
310
  method: "deps-capability-diff",
216
311
  confidence: "confirmed",
217
- 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.`,
218
- 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",
219
316
  });
220
317
  }
221
318
  return out;
@@ -284,20 +381,15 @@ function loadBase(root, opts) {
284
381
  }
285
382
 
286
383
  /**
287
- * Scan a project's dependencies.
384
+ * npm: package-lock.json plus node_modules or npm's cache.
288
385
  * @param {string} root project folder containing package-lock.json
289
- * @param {{ diff?: string, base?: string, online?: boolean, prod?: boolean,
290
- * onPhase?: Function, cacheDir?: string|string[], now?: number, fetchImpl?: Function,
291
- * treeRoot?: string, lock?: object, baseLock?: object, baseLabel?: string,
292
- * changedOnly?: boolean, licensePolicy?: object }} opts
293
- * treeRoot: where installed packages live, when the lockfile describes a tree
294
- * that is not installed beside it (penthera install)
295
- * lock / baseLock: lockfile objects to use instead of reading them (the
296
- * pre-commit hook passes the staged and HEAD lockfiles)
297
- * changedOnly: in diff mode, scan only packages that changed (fast path)
298
- * licensePolicy: from normalizeLicensePolicy(); adds category "license" findings
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
299
391
  */
300
- export async function scanDeps(root, opts = {}) {
392
+ async function scanNpm(root, opts = {}) {
301
393
  const started = Date.now();
302
394
  const onPhase = opts.onPhase || (() => {});
303
395
  const lockPath = opts.lock ? join(root, "package-lock.json") : findLockfile(root);
@@ -350,8 +442,10 @@ export async function scanDeps(root, opts = {}) {
350
442
  // Verified against the base lockfile's integrity before anything is read.
351
443
  const src = await fetchTarballSource(oldPkg?.resolved, oldPkg?.integrity, { fetchImpl: opts.fetchImpl });
352
444
  if (!src) continue;
445
+ const old = scanPackage(src, { prefix: `${oldPkg.path}/` });
446
+ if (old.unscanned) continue; // still not comparable
353
447
  online.fetchedTarballs++;
354
- const o = new Set(Object.keys(scanPackage(src, { prefix: `${oldPkg.path}/` }).capabilities));
448
+ const o = new Set(Object.keys(old.capabilities));
355
449
  const n = new Set(Object.keys(u.pkg.capabilities || {}));
356
450
  u.gained = CAPABILITY_ORDER.filter((id) => n.has(id) && !o.has(id));
357
451
  u.lost = CAPABILITY_ORDER.filter((id) => o.has(id) && !n.has(id));
@@ -386,8 +480,8 @@ export async function scanDeps(root, opts = {}) {
386
480
  }
387
481
 
388
482
  // Optional packages for other platforms (esbuild-style binaries) are expected to be absent.
389
- const unavailable = tree.filter((p) => !p.capabilities && !p.optional);
390
- 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;
391
485
  const rel = (p) => {
392
486
  const r = relative(process.cwd(), p);
393
487
  return r === "" ? "." : r.startsWith("..") ? p : r;
@@ -400,8 +494,10 @@ export async function scanDeps(root, opts = {}) {
400
494
  timestamp: new Date(started).toISOString(),
401
495
  duration: Date.now() - started,
402
496
  deps: {
497
+ ecosystem: "npm",
403
498
  lockfile: rel(lockPath),
404
499
  lockfileVersion: lock.lockfileVersion || 1,
500
+ format: `package-lock.json v${lock.lockfileVersion || 1}`,
405
501
  prod: !!opts.prod,
406
502
  network: opts.online ? "online (registry metadata only)" : "offline",
407
503
  total: tree.length,
@@ -409,6 +505,8 @@ export async function scanDeps(root, opts = {}) {
409
505
  skippedOptional,
410
506
  direct: [...direct],
411
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}`),
412
510
  capabilityCounts: summarize(tree),
413
511
  popularList: loadPopular().source,
414
512
  packages: tree.map((p) => ({
@@ -424,6 +522,98 @@ export async function scanDeps(root, opts = {}) {
424
522
  typosquats: typos,
425
523
  licenses,
426
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,
605
+ packages: tree.map((p) => ({
606
+ name: p.name,
607
+ version: p.version,
608
+ path: p.path,
609
+ dev: p.dev,
610
+ direct: direct.has(p.name),
611
+ source: p.source,
612
+ ecosystem: "pypi",
613
+ capabilities: p.capabilities,
614
+ })),
615
+ typosquats: typos,
616
+ online,
427
617
  diff: diff ? {
428
618
  base: base.label,
429
619
  added: diff.added.map(({ pkg, ...a }) => ({ ...a, installScripts: pkg.installScripts })),
@@ -438,3 +628,73 @@ export async function scanDeps(root, opts = {}) {
438
628
  findings,
439
629
  };
440
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
+ }
@@ -93,16 +93,39 @@ export function findLockfile(dir) {
93
93
  return null;
94
94
  }
95
95
 
96
+ /**
97
+ * Throws unless `ref` is a plain git revision: no leading "-" (it would be
98
+ * read as an option, e.g. --output=<file>) and only characters revisions use.
99
+ * `--base` reaches here from the command line and from CI inputs.
100
+ */
101
+ export function assertSafeRef(ref) {
102
+ if (typeof ref !== "string" || !/^[A-Za-z0-9_./@^~{}+-]{1,256}$/.test(ref) || ref.startsWith("-") || ref.includes("..")) {
103
+ throw new Error(`Invalid git ref ${JSON.stringify(String(ref).slice(0, 80))}: use a branch, tag or commit (letters, digits and . _ / @ ^ ~ { } + -, not starting with -)`);
104
+ }
105
+ return ref;
106
+ }
107
+
108
+ /**
109
+ * git for reading a scanned repo: no fsmonitor, no hooks, no transports.
110
+ * Read-only plumbing only (rev-parse, show, ls-tree).
111
+ */
112
+ export function readOnlyGit(dir) {
113
+ return (args) => execFileSync("git", ["-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", ...args], {
114
+ cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024, env: { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" },
115
+ });
116
+ }
117
+
96
118
  /**
97
119
  * Read the lockfile as it was at a git ref (for `--base <ref>`).
98
120
  * Only runs `git show`; no fetch, no checkout.
99
121
  */
100
122
  export function readLockfileAtRef(dir, ref) {
101
- const git = (args) => execFileSync("git", args, { cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 });
123
+ assertSafeRef(ref);
124
+ const git = readOnlyGit(dir);
102
125
  const prefix = git(["rev-parse", "--show-prefix"]).trim();
103
126
  for (const f of ["package-lock.json", "npm-shrinkwrap.json"]) {
104
127
  try {
105
- return JSON.parse(git(["show", `${ref}:${prefix}${f}`]));
128
+ return JSON.parse(git(["show", "--end-of-options", `${ref}:${prefix}${f}`]));
106
129
  } catch { /* try the next name */ }
107
130
  }
108
131
  throw new Error(`No package-lock.json at ${ref} in ${dir}`);