penthera 1.2.0 → 1.5.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 +269 -0
- package/README.md +493 -45
- package/docs/METHODOLOGY.md +321 -0
- package/docs/compliance-cra.md +141 -0
- package/docs/compliance-nis2.md +3 -1
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +1 -1
- package/docs/examples/pr-dependency-review.yml +56 -0
- package/docs/funding/due-diligence-checklist.md +79 -0
- package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
- package/docs/funding/partner-one-pager.md +84 -0
- package/docs/funding/sponsor-tiers.md +81 -0
- package/docs/index.md +12 -1
- package/docs/local-loop.md +276 -0
- package/docs/plan.md +100 -0
- package/docs/references.bib +539 -0
- package/docs/references.md +77 -0
- package/docs/release-plan.md +228 -0
- package/lib/adaptive-probes.js +8 -5
- package/lib/agents/discover.js +384 -0
- package/lib/agents/hidden-content.js +387 -0
- package/lib/agents/index.js +286 -0
- package/lib/agents/lock.js +115 -0
- package/lib/agents/owasp.js +83 -0
- package/lib/agents/rules.js +376 -0
- package/lib/compliance/cra.js +525 -0
- package/lib/crawler.js +5 -15
- package/lib/data/retire-jsrepository.json.gz +0 -0
- package/lib/data/retire-jsrepository.meta.json +7 -0
- package/lib/deps/capabilities.js +523 -0
- package/lib/deps/diff.js +84 -0
- package/lib/deps/index.js +440 -0
- package/lib/deps/license.js +329 -0
- package/lib/deps/lockfile.js +109 -0
- package/lib/deps/online.js +132 -0
- package/lib/deps/popular-packages.json +1 -0
- package/lib/deps/report.js +190 -0
- package/lib/deps/source.js +171 -0
- package/lib/deps/spdx.json +1 -0
- package/lib/deps/typosquat.js +113 -0
- package/lib/fix/apply.js +95 -0
- package/lib/fix/diff.js +199 -0
- package/lib/fix/index.js +129 -0
- package/lib/fix/model.js +108 -0
- package/lib/fix/recipes/cookies.js +130 -0
- package/lib/fix/recipes/cors.js +116 -0
- package/lib/fix/recipes/headers.js +250 -0
- package/lib/fix/recipes/rate-limit.js +211 -0
- package/lib/fix/recipes/secrets.js +110 -0
- package/lib/fix/util.js +231 -0
- package/lib/fuzzer.js +14 -8
- package/lib/hook/index.js +312 -0
- package/lib/hook/policy.js +62 -0
- package/lib/injections.js +19 -15
- package/lib/install/index.js +271 -0
- package/lib/install/policy.js +125 -0
- package/lib/jurisdictions/nl.json +9 -0
- package/lib/memory/history.js +52 -0
- package/lib/memory/index.js +111 -0
- package/lib/memory/repo-suppress.js +64 -0
- package/lib/memory/store.js +325 -0
- package/lib/memory/views.js +198 -0
- package/lib/params.js +4 -12
- package/lib/provenance.js +2 -0
- package/lib/recon.js +12 -20
- package/lib/references.js +31 -0
- package/lib/references.json +374 -0
- package/lib/remediation/index.js +28 -0
- package/lib/retirejs.js +50 -22
- package/lib/templates.js +7 -10
- package/lib/whitebox/aibom.js +384 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/sbom.js +45 -10
- package/lib/whitebox/secrets.js +11 -1
- package/package.json +17 -4
- package/skills/penthera/SKILL.md +132 -22
- package/src/agent/git.js +87 -0
- package/src/agent/loop.js +519 -0
- package/src/agent/tests.js +48 -0
- package/src/cli/agent.js +183 -0
- package/src/cli/deps.js +104 -0
- package/src/cli/evidence.js +67 -0
- package/src/cli/fix.js +191 -0
- package/src/cli/hook.js +131 -0
- package/src/cli/install.js +225 -0
- package/src/cli/memory.js +146 -0
- package/src/cli/merge-results.js +2 -0
- package/src/cli/report-outputs.js +72 -0
- package/src/cli/run-scan.js +2 -0
- package/src/cli.js +212 -8
- package/src/report/audience.js +270 -0
- package/src/report/evidence.js +253 -0
- package/src/report/markdown.js +65 -2
- package/src/report/model.js +212 -0
- package/src/report/template.js +175 -0
- package/src/report/timeline.js +145 -0
- package/src/reporter.js +109 -5
- package/src/scan-url.js +31 -7
- package/src/utils/http.js +252 -11
- package/src/utils/url.js +23 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera deps`: offline, deterministic dependency risk scanning for npm.
|
|
3
|
+
*
|
|
4
|
+
* Reads package-lock.json, statically scans every installed package for what
|
|
5
|
+
* it can do (lib/deps/capabilities.js), checks names for typosquats against a
|
|
6
|
+
* vendored list, and, with --diff / --base, reports packages that are new or
|
|
7
|
+
* that gained capabilities in an upgrade. Results are ordinary Penthera
|
|
8
|
+
* findings (category "dependency-risk", source "deps-scan"), so the terminal,
|
|
9
|
+
* Markdown, JSON and SARIF outputs all work unchanged.
|
|
10
|
+
*
|
|
11
|
+
* Offline by default. Old versions for the diff come from the base tree's
|
|
12
|
+
* node_modules or npm's local content cache, verified against the lockfile
|
|
13
|
+
* integrity. `--online` is the only network use (lib/deps/online.js).
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
16
|
+
import { join, dirname, relative, resolve, basename } from "node:path";
|
|
17
|
+
import { findLockfile, readLockfile, parseLockfile, directDependencies, readLockfileAtRef } from "./lockfile.js";
|
|
18
|
+
import { DirSource, sourceFromNpmCache, npmCacheDir } from "./source.js";
|
|
19
|
+
import { scanPackage, CAPABILITIES, CAPABILITY_ORDER, maxSeverity } from "./capabilities.js";
|
|
20
|
+
import { diffTrees } from "./diff.js";
|
|
21
|
+
import { findTyposquats, loadPopular } from "./typosquat.js";
|
|
22
|
+
import { checkLicenses } from "./license.js";
|
|
23
|
+
|
|
24
|
+
const SOURCE = "deps-scan";
|
|
25
|
+
const CATEGORY = "dependency-risk";
|
|
26
|
+
const KNOWN_BAD = ["ai-config-write", "ai-cli-invoke", "token-harvest"];
|
|
27
|
+
// Owners of these scopes legitimately manage their own AI tool config.
|
|
28
|
+
const KNOWN_BAD_EXEMPT = { "ai-config-write": [/^@anthropic-ai\//], "ai-cli-invoke": [/^@anthropic-ai\//] };
|
|
29
|
+
|
|
30
|
+
// ── Resolving package contents ────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function installedVersion(dir) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")).version || null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Find a package's files offline: the tree's node_modules (when the installed
|
|
42
|
+
* version matches the lockfile), else npm's cache by integrity.
|
|
43
|
+
*/
|
|
44
|
+
function resolveSource(pkg, treeRoot, cacheDirs) {
|
|
45
|
+
if (treeRoot) {
|
|
46
|
+
const dir = join(treeRoot, pkg.path);
|
|
47
|
+
if (existsSync(dir) && installedVersion(dir) === pkg.version) return new DirSource(dir);
|
|
48
|
+
}
|
|
49
|
+
if (!pkg.integrity) return null;
|
|
50
|
+
for (const d of cacheDirs) {
|
|
51
|
+
const src = sourceFromNpmCache(pkg.integrity, d);
|
|
52
|
+
if (src) return src;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Scan a list of lockfile packages. Results cached by name@version+integrity. */
|
|
58
|
+
function scanTree(pkgs, treeRoot, ctx) {
|
|
59
|
+
return pkgs.map((p) => {
|
|
60
|
+
const key = `${p.name}@${p.version}#${p.integrity || ""}`;
|
|
61
|
+
if (!ctx.cache.has(key)) {
|
|
62
|
+
const src = resolveSource(p, treeRoot, ctx.cacheDirs);
|
|
63
|
+
ctx.cache.set(key, src ? { ...scanPackage(src, { prefix: `${p.path}/` }), source: src.kind } : null);
|
|
64
|
+
}
|
|
65
|
+
const r = ctx.cache.get(key);
|
|
66
|
+
return {
|
|
67
|
+
...p,
|
|
68
|
+
source: r?.source || null,
|
|
69
|
+
capabilities: r ? r.capabilities : null,
|
|
70
|
+
installScripts: r?.installScripts || {},
|
|
71
|
+
installRisk: r?.installRisk || [],
|
|
72
|
+
exfil: r?.exfil || [],
|
|
73
|
+
filesScanned: r?.filesScanned || 0,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Findings ──────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const capList = (ids) => ids.map((id) => CAPABILITIES[id]?.label || id).join(", ");
|
|
81
|
+
|
|
82
|
+
function evidenceText(caps, ids) {
|
|
83
|
+
const lines = [];
|
|
84
|
+
for (const id of ids) {
|
|
85
|
+
for (const e of (caps?.[id]?.evidence || []).slice(0, 2)) {
|
|
86
|
+
lines.push(`${CAPABILITIES[id].label}: ${e.file}:${e.line} \`${e.snippet}\``);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return lines.join("; ");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function firstEvidence(caps, ids) {
|
|
93
|
+
for (const id of ids) {
|
|
94
|
+
const e = caps?.[id]?.evidence?.[0];
|
|
95
|
+
if (e) return e;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function finding({ title, severity, confidence, method, description, pkg, ev, capabilities, extra = {} }) {
|
|
101
|
+
return {
|
|
102
|
+
title,
|
|
103
|
+
severity,
|
|
104
|
+
category: CATEGORY,
|
|
105
|
+
source: SOURCE,
|
|
106
|
+
method,
|
|
107
|
+
confidence,
|
|
108
|
+
description,
|
|
109
|
+
url: ev?.file || pkg.path,
|
|
110
|
+
line: ev?.line || undefined,
|
|
111
|
+
package: pkg.name,
|
|
112
|
+
version: pkg.version,
|
|
113
|
+
capabilities,
|
|
114
|
+
...extra,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Risk findings that stand on their own, without a diff. */
|
|
119
|
+
function riskFindings(pkg) {
|
|
120
|
+
const out = [];
|
|
121
|
+
const caps = pkg.capabilities;
|
|
122
|
+
if (!caps) return out;
|
|
123
|
+
const at = `${pkg.name}@${pkg.version}`;
|
|
124
|
+
|
|
125
|
+
for (const id of KNOWN_BAD) {
|
|
126
|
+
if (!caps[id]) continue;
|
|
127
|
+
if ((KNOWN_BAD_EXEMPT[id] || []).some((re) => re.test(pkg.name))) continue;
|
|
128
|
+
const ev = caps[id].evidence[0];
|
|
129
|
+
out.push(finding({
|
|
130
|
+
title: `${at}: ${CAPABILITIES[id].label}`,
|
|
131
|
+
severity: "critical",
|
|
132
|
+
confidence: CAPABILITIES[id].confidence,
|
|
133
|
+
method: "deps-capability-scan",
|
|
134
|
+
description: `${CAPABILITIES[id].why} Evidence: ${evidenceText(caps, [id])}`,
|
|
135
|
+
pkg, ev, capabilities: [id],
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
if (caps.obfuscated) {
|
|
139
|
+
const ev = caps.obfuscated.evidence[0];
|
|
140
|
+
out.push(finding({
|
|
141
|
+
title: `${at}: obfuscated code`,
|
|
142
|
+
severity: "high",
|
|
143
|
+
confidence: "potential",
|
|
144
|
+
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.`,
|
|
146
|
+
pkg, ev, capabilities: ["obfuscated"],
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
if (pkg.exfil?.length) {
|
|
150
|
+
const ev = pkg.exfil[0];
|
|
151
|
+
out.push(finding({
|
|
152
|
+
title: `${at}: serialises the whole environment next to network code`,
|
|
153
|
+
severity: "high",
|
|
154
|
+
confidence: "likely",
|
|
155
|
+
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"])}`,
|
|
157
|
+
pkg, ev, capabilities: ["env-bulk", "network"],
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
if (caps["install-scripts"]) {
|
|
161
|
+
const ev = caps["install-scripts"].evidence[0];
|
|
162
|
+
const hooks = Object.entries(pkg.installScripts).map(([h, cmd]) => `${h}: \`${cmd.slice(0, 100)}\``).join("; ");
|
|
163
|
+
const risky = pkg.installRisk.length > 0;
|
|
164
|
+
out.push(finding({
|
|
165
|
+
title: `${at}: runs ${risky ? "a risky " : ""}install script`,
|
|
166
|
+
severity: risky ? "medium" : "low",
|
|
167
|
+
confidence: "confirmed",
|
|
168
|
+
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.`,
|
|
170
|
+
pkg, ev, capabilities: ["install-scripts"],
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Capabilities that riskFindings already reports on their own.
|
|
177
|
+
const OWN_FINDING = new Set([...KNOWN_BAD, "obfuscated", "install-scripts"]);
|
|
178
|
+
|
|
179
|
+
function diffFindings(diff) {
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const a of diff.added) {
|
|
182
|
+
const ids = a.capabilities || [];
|
|
183
|
+
const notable = ids.filter((id) => id !== "env" && id !== "dynamic-require" && !OWN_FINDING.has(id));
|
|
184
|
+
if (notable.length === 0) continue;
|
|
185
|
+
out.push(finding({
|
|
186
|
+
title: `New dependency ${a.name}@${a.version}: ${capList(notable)}`,
|
|
187
|
+
severity: maxSeverity(notable.map((id) => CAPABILITIES[id].severity)),
|
|
188
|
+
confidence: "likely",
|
|
189
|
+
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)}`,
|
|
191
|
+
pkg: a.pkg, ev: firstEvidence(a.pkg.capabilities, notable), capabilities: notable,
|
|
192
|
+
extra: { change: "added" },
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
for (const u of diff.upgraded) {
|
|
196
|
+
if (!u.gained || u.gained.length === 0) continue;
|
|
197
|
+
const sev = maxSeverity(u.gained.map((id) => CAPABILITIES[id].severity));
|
|
198
|
+
out.push(finding({
|
|
199
|
+
title: `${u.name} ${u.from} → ${u.to} gained ${capList(u.gained)}`,
|
|
200
|
+
severity: sev,
|
|
201
|
+
confidence: u.gained.some((id) => CAPABILITIES[id].confidence === "potential") && u.gained.length === 1 ? "potential" : "likely",
|
|
202
|
+
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)}`,
|
|
204
|
+
pkg: u.pkg, ev: firstEvidence(u.pkg.capabilities, u.gained), capabilities: u.gained,
|
|
205
|
+
extra: { change: "upgraded", from: u.from },
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
const missing = diff.upgraded.filter((u) => u.unavailable);
|
|
209
|
+
if (missing.length) {
|
|
210
|
+
out.push({
|
|
211
|
+
title: `${missing.length} upgraded package(s) not compared: old version not available offline`,
|
|
212
|
+
severity: "info",
|
|
213
|
+
category: CATEGORY,
|
|
214
|
+
source: SOURCE,
|
|
215
|
+
method: "deps-capability-diff",
|
|
216
|
+
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",
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function typosquatFindings(hits, pkgByName, direct) {
|
|
225
|
+
return hits.map((h) => {
|
|
226
|
+
const pkg = pkgByName.get(h.name) || { name: h.name, version: null, path: "package-lock.json" };
|
|
227
|
+
const isDirect = direct.has(h.name);
|
|
228
|
+
return finding({
|
|
229
|
+
title: `${h.name} looks like ${h.target} (${h.kind})`,
|
|
230
|
+
severity: isDirect ? "medium" : "low",
|
|
231
|
+
confidence: "potential",
|
|
232
|
+
method: "deps-typosquat",
|
|
233
|
+
description: `\`${h.name}\` is ${h.distance} edit(s) from \`${h.target}\` (#${h.rank} in the vendored popular-package list) and is not itself on that list. Typosquats and AI-hallucinated names (slopsquatting) look exactly like this. Check it is the package you meant to install${isDirect ? "" : ` and why a dependency pulls it in`}.`,
|
|
234
|
+
pkg, ev: { file: pkg.path, line: undefined }, capabilities: [],
|
|
235
|
+
extra: { typosquatOf: h.target, direct: isDirect },
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function registryFindings(results, pkgByKey) {
|
|
241
|
+
const out = [];
|
|
242
|
+
for (const [key, r] of [...results].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
243
|
+
const pkg = pkgByKey.get(key);
|
|
244
|
+
if (!pkg) continue;
|
|
245
|
+
for (const s of r.signals) {
|
|
246
|
+
out.push(finding({
|
|
247
|
+
title: `${key}: ${s.text}`,
|
|
248
|
+
severity: s.severity,
|
|
249
|
+
confidence: "confirmed",
|
|
250
|
+
method: "deps-registry",
|
|
251
|
+
description: `From npm registry metadata (--online). ${s.id === "publisher-change" ? "A new publisher on an established package is how account takeovers ship malware; check the release notes and diff." : s.id === "new-package" ? "Brand-new packages are where typosquats and slopsquats (names AI assistants hallucinate) live." : s.id === "fresh-version" ? "Most malicious versions are caught and pulled within days; waiting out a short cooldown on new releases avoids them." : ""}`,
|
|
252
|
+
pkg, ev: { file: pkg.path }, capabilities: [],
|
|
253
|
+
extra: { registrySignal: s.id },
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Entry point ───────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
function summarize(tree) {
|
|
263
|
+
const counts = {};
|
|
264
|
+
for (const id of CAPABILITY_ORDER) counts[id] = 0;
|
|
265
|
+
for (const p of tree) for (const id of Object.keys(p.capabilities || {})) counts[id]++;
|
|
266
|
+
return counts;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Load the old tree for --diff (lockfile path or project folder) or --base (git ref). */
|
|
270
|
+
function loadBase(root, opts) {
|
|
271
|
+
if (opts.baseLock) return { lock: opts.baseLock, treeRoot: null, label: opts.baseLabel || "base" };
|
|
272
|
+
if (opts.base) {
|
|
273
|
+
return { lock: readLockfileAtRef(root, opts.base), treeRoot: null, label: opts.base };
|
|
274
|
+
}
|
|
275
|
+
const p = resolve(opts.diff);
|
|
276
|
+
if (!existsSync(p)) throw new Error(`--diff path not found: ${opts.diff}`);
|
|
277
|
+
const isDir = statSync(p).isDirectory();
|
|
278
|
+
const lockPath = isDir ? findLockfile(p) : p;
|
|
279
|
+
if (!lockPath) throw new Error(`No package-lock.json in ${opts.diff}`);
|
|
280
|
+
const treeRoot = dirname(lockPath);
|
|
281
|
+
// A lockfile beside the project's own node_modules describes another tree;
|
|
282
|
+
// only trust node_modules that sit next to the old lockfile.
|
|
283
|
+
return { lock: readLockfile(lockPath), treeRoot: treeRoot === resolve(root) ? null : treeRoot, label: opts.diff };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Scan a project's dependencies.
|
|
288
|
+
* @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
|
|
299
|
+
*/
|
|
300
|
+
export async function scanDeps(root, opts = {}) {
|
|
301
|
+
const started = Date.now();
|
|
302
|
+
const onPhase = opts.onPhase || (() => {});
|
|
303
|
+
const lockPath = opts.lock ? join(root, "package-lock.json") : findLockfile(root);
|
|
304
|
+
if (!lockPath) throw new Error(`No package-lock.json in ${root}. penthera deps reads the lockfile; run \`npm install --package-lock-only\` first.`);
|
|
305
|
+
const lock = opts.lock || readLockfile(lockPath);
|
|
306
|
+
const ctx = { cache: new Map(), cacheDirs: [].concat(opts.cacheDir || npmCacheDir()) };
|
|
307
|
+
const keep = (p) => !p.link && p.version && (!opts.prod || !p.dev);
|
|
308
|
+
|
|
309
|
+
const diffing = !!(opts.diff || opts.base || opts.baseLock);
|
|
310
|
+
const base = diffing ? loadBase(root, opts) : null;
|
|
311
|
+
let pkgs = parseLockfile(lock).filter(keep);
|
|
312
|
+
if (diffing && opts.changedOnly) {
|
|
313
|
+
const before = new Set(parseLockfile(base.lock).map((p) => `${p.name}@${p.version}`));
|
|
314
|
+
pkgs = pkgs.filter((p) => !before.has(`${p.name}@${p.version}`));
|
|
315
|
+
}
|
|
316
|
+
onPhase(`Scanning ${pkgs.length} packages from ${basename(lockPath)} (offline)`);
|
|
317
|
+
const tree = scanTree(pkgs, opts.treeRoot || root, ctx);
|
|
318
|
+
const direct = new Set(directDependencies(lock));
|
|
319
|
+
|
|
320
|
+
let diff = null;
|
|
321
|
+
if (diffing) {
|
|
322
|
+
const oldPkgs = parseLockfile(base.lock).filter(keep);
|
|
323
|
+
const byVersion = new Map(tree.map((p) => [`${p.name}@${p.version}`, p]));
|
|
324
|
+
const newNames = new Set(tree.map((p) => p.name));
|
|
325
|
+
// Only old versions that are gone but whose name remains need inspecting:
|
|
326
|
+
// a version present in both trees has the same contents (same lockfile
|
|
327
|
+
// integrity), and a removed name is not compared.
|
|
328
|
+
const needOld = oldPkgs.filter((p) => !byVersion.has(`${p.name}@${p.version}`) && newNames.has(p.name));
|
|
329
|
+
onPhase(`Comparing against ${base.label}: ${needOld.length} replaced version(s) to inspect`);
|
|
330
|
+
const oldScanned = new Map(scanTree(needOld, base.treeRoot, ctx).map((p) => [p.path, p]));
|
|
331
|
+
const oldTree = oldPkgs.map((p) => oldScanned.get(p.path)
|
|
332
|
+
|| { ...p, capabilities: byVersion.get(`${p.name}@${p.version}`)?.capabilities || {}, installScripts: byVersion.get(`${p.name}@${p.version}`)?.installScripts || {} });
|
|
333
|
+
diff = diffTrees(oldTree, tree);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --online: registry metadata, and old tarballs the cache did not have.
|
|
337
|
+
let online = null;
|
|
338
|
+
const findings = [];
|
|
339
|
+
if (opts.online) {
|
|
340
|
+
const { checkRegistry, fetchTarballSource } = await import("./online.js");
|
|
341
|
+
const changed = diff ? [...diff.added.map((a) => a.pkg), ...diff.upgraded.map((u) => ({ ...u.pkg, previous: u.from }))] : [];
|
|
342
|
+
const items = diff ? changed : tree.filter((p) => direct.has(p.name));
|
|
343
|
+
onPhase(`--online: fetching registry metadata for ${items.length} package(s) (the only network use)`);
|
|
344
|
+
const results = await checkRegistry(items.map((p) => ({ name: p.name, version: p.version, previous: p.previous })), { fetchImpl: opts.fetchImpl, now: opts.now });
|
|
345
|
+
online = { checked: items.length, unreachable: [...results.values()].filter((r) => !r.reachable).length, fetchedTarballs: 0 };
|
|
346
|
+
if (diff) {
|
|
347
|
+
const oldPkgs = parseLockfile(base.lock);
|
|
348
|
+
for (const u of diff.upgraded.filter((x) => x.unavailable === "old")) {
|
|
349
|
+
const oldPkg = oldPkgs.find((p) => p.name === u.name && p.version === u.from);
|
|
350
|
+
// Verified against the base lockfile's integrity before anything is read.
|
|
351
|
+
const src = await fetchTarballSource(oldPkg?.resolved, oldPkg?.integrity, { fetchImpl: opts.fetchImpl });
|
|
352
|
+
if (!src) continue;
|
|
353
|
+
online.fetchedTarballs++;
|
|
354
|
+
const o = new Set(Object.keys(scanPackage(src, { prefix: `${oldPkg.path}/` }).capabilities));
|
|
355
|
+
const n = new Set(Object.keys(u.pkg.capabilities || {}));
|
|
356
|
+
u.gained = CAPABILITY_ORDER.filter((id) => n.has(id) && !o.has(id));
|
|
357
|
+
u.lost = CAPABILITY_ORDER.filter((id) => o.has(id) && !n.has(id));
|
|
358
|
+
u.unavailable = null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
findings.push(...registryFindings(results, new Map(tree.map((p) => [`${p.name}@${p.version}`, p]))));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// In diff mode, report on what changed; otherwise on the whole tree.
|
|
365
|
+
const inScope = diff
|
|
366
|
+
? new Set([...diff.added.map((a) => a.pkg), ...diff.upgraded.map((u) => u.pkg)])
|
|
367
|
+
: new Set(tree);
|
|
368
|
+
for (const p of tree) if (inScope.has(p)) findings.push(...riskFindings(p));
|
|
369
|
+
if (diff) findings.push(...diffFindings(diff));
|
|
370
|
+
|
|
371
|
+
// Names you chose (direct deps), or names that are new in this change. A
|
|
372
|
+
// transitive dependency of an unchanged tree was picked by its parent's
|
|
373
|
+
// maintainers long ago; flagging those is mostly noise.
|
|
374
|
+
const nameScope = diff ? diff.added.map((a) => a.name) : tree.filter((p) => direct.has(p.name)).map((p) => p.name);
|
|
375
|
+
const typos = findTyposquats(nameScope);
|
|
376
|
+
findings.push(...typosquatFindings(typos, new Map(tree.map((p) => [p.name, p])), direct));
|
|
377
|
+
|
|
378
|
+
// License policy: the whole tree, or in diff mode only what changed.
|
|
379
|
+
let licenses = null;
|
|
380
|
+
if (opts.licensePolicy) {
|
|
381
|
+
const scope = tree.filter((p) => inScope.has(p));
|
|
382
|
+
onPhase(`Checking ${scope.length} package license(s) against the policy`);
|
|
383
|
+
const r = checkLicenses(scope, opts.licensePolicy, { treeRoot: opts.treeRoot || root, lockfileHasLicenses: (lock.lockfileVersion || 1) >= 2 });
|
|
384
|
+
findings.push(...r.findings);
|
|
385
|
+
licenses = r.summary;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 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;
|
|
391
|
+
const rel = (p) => {
|
|
392
|
+
const r = relative(process.cwd(), p);
|
|
393
|
+
return r === "" ? "." : r.startsWith("..") ? p : r;
|
|
394
|
+
};
|
|
395
|
+
return {
|
|
396
|
+
target: rel(root),
|
|
397
|
+
mode: "deps",
|
|
398
|
+
local: true,
|
|
399
|
+
reachable: true,
|
|
400
|
+
timestamp: new Date(started).toISOString(),
|
|
401
|
+
duration: Date.now() - started,
|
|
402
|
+
deps: {
|
|
403
|
+
lockfile: rel(lockPath),
|
|
404
|
+
lockfileVersion: lock.lockfileVersion || 1,
|
|
405
|
+
prod: !!opts.prod,
|
|
406
|
+
network: opts.online ? "online (registry metadata only)" : "offline",
|
|
407
|
+
total: tree.length,
|
|
408
|
+
scanned: tree.filter((p) => p.capabilities).length,
|
|
409
|
+
skippedOptional,
|
|
410
|
+
direct: [...direct],
|
|
411
|
+
notInstalled: unavailable.map((p) => `${p.name}@${p.version}`),
|
|
412
|
+
capabilityCounts: summarize(tree),
|
|
413
|
+
popularList: loadPopular().source,
|
|
414
|
+
packages: tree.map((p) => ({
|
|
415
|
+
name: p.name,
|
|
416
|
+
version: p.version,
|
|
417
|
+
path: p.path,
|
|
418
|
+
dev: p.dev,
|
|
419
|
+
license: p.license,
|
|
420
|
+
direct: direct.has(p.name),
|
|
421
|
+
source: p.source,
|
|
422
|
+
capabilities: p.capabilities,
|
|
423
|
+
})),
|
|
424
|
+
typosquats: typos,
|
|
425
|
+
licenses,
|
|
426
|
+
online,
|
|
427
|
+
diff: diff ? {
|
|
428
|
+
base: base.label,
|
|
429
|
+
added: diff.added.map(({ pkg, ...a }) => ({ ...a, installScripts: pkg.installScripts })),
|
|
430
|
+
upgraded: diff.upgraded.map(({ pkg, prev, ...u }) => ({ ...u, installScripts: pkg.installScripts, previousInstallScripts: prev?.installScripts || null })),
|
|
431
|
+
removed: diff.removed,
|
|
432
|
+
evidence: Object.fromEntries([...diff.added, ...diff.upgraded].map((x) => {
|
|
433
|
+
const ids = x.gained || x.capabilities || [];
|
|
434
|
+
return [`${x.name}@${x.version || x.to}`, Object.fromEntries(ids.map((id) => [id, x.pkg.capabilities?.[id]?.evidence || []]))];
|
|
435
|
+
})),
|
|
436
|
+
} : null,
|
|
437
|
+
},
|
|
438
|
+
findings,
|
|
439
|
+
};
|
|
440
|
+
}
|