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.
- package/CHANGELOG.md +227 -0
- package/README.md +148 -575
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/compliance-cra.md +141 -0
- package/docs/compliance-nis2.md +3 -1
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +11 -2
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +101 -0
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/cra.js +525 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +314 -30
- package/lib/deps/license.js +329 -0
- package/lib/deps/lockfile.js +29 -3
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +54 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/spdx.json +1 -0
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +345 -0
- package/lib/hook/policy.js +62 -0
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +27 -7
- package/lib/jurisdictions/nl.json +9 -0
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/aibom.js +384 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/sbom.js +45 -10
- package/lib/whitebox/secrets.js +31 -8
- package/package.json +8 -3
- package/skills/penthera/SKILL.md +55 -8
- package/src/cli/deps.js +81 -6
- package/src/cli/evidence.js +14 -4
- package/src/cli/hook.js +131 -0
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +81 -11
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- package/src/ui/static/js/views/settings.js +67 -0
package/lib/deps/capabilities.js
CHANGED
|
@@ -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(
|
|
277
|
-
const decodes = offsets([
|
|
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
|
-
|
|
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
|
-
|
|
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: `${
|
|
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: `${
|
|
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:
|
|
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: `${
|
|
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(
|
|
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(
|
|
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(", ")}.
|
|
217
|
-
|
|
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
|
-
*
|
|
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 {
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|