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.
- package/CHANGELOG.md +170 -0
- package/README.md +128 -663
- 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/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 +9 -0
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +4 -3
- 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/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +290 -30
- package/lib/deps/lockfile.js +25 -2
- 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 +37 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +41 -8
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +17 -5
- 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/frameworks.js +8 -5
- package/lib/whitebox/secrets.js +20 -7
- package/package.json +7 -3
- package/skills/penthera/SKILL.md +23 -5
- package/src/cli/deps.js +60 -5
- package/src/cli/evidence.js +14 -4
- 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 +19 -7
- 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/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
|
-
|
|
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: `${
|
|
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: `${
|
|
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:
|
|
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: `${
|
|
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(
|
|
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(
|
|
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(", ")}.
|
|
218
|
-
|
|
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
|
-
*
|
|
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 {
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|
package/lib/deps/lockfile.js
CHANGED
|
@@ -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
|
-
|
|
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}`);
|