penthera 1.1.1 → 1.4.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 -3
- package/README.md +489 -286
- package/SECURITY.md +28 -6
- package/docs/METHODOLOGY.md +321 -0
- package/docs/compliance-nis2.md +166 -0
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +55 -0
- 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 +15 -3
- package/docs/local-loop.md +276 -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/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 +416 -0
- package/lib/deps/lockfile.js +106 -0
- package/lib/deps/online.js +132 -0
- package/lib/deps/popular-packages.json +1 -0
- package/lib/deps/report.js +173 -0
- package/lib/deps/source.js +171 -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/injections.js +19 -15
- package/lib/install/index.js +271 -0
- package/lib/install/policy.js +117 -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/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +49 -19
- package/package.json +19 -9
- package/skills/penthera/SKILL.md +104 -23
- package/skills/penthera/references/output-and-ci.md +12 -50
- package/skills/penthera/references/troubleshooting.md +1 -1
- package/skills/penthera/scripts/preflight.sh +4 -4
- 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 +84 -0
- package/src/cli/evidence.js +67 -0
- package/src/cli/fix.js +191 -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 +152 -6
- 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
- package/scripts/package-skill.sh +0 -16
|
@@ -0,0 +1,416 @@
|
|
|
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
|
+
|
|
23
|
+
const SOURCE = "deps-scan";
|
|
24
|
+
const CATEGORY = "dependency-risk";
|
|
25
|
+
const KNOWN_BAD = ["ai-config-write", "ai-cli-invoke", "token-harvest"];
|
|
26
|
+
// Owners of these scopes legitimately manage their own AI tool config.
|
|
27
|
+
const KNOWN_BAD_EXEMPT = { "ai-config-write": [/^@anthropic-ai\//], "ai-cli-invoke": [/^@anthropic-ai\//] };
|
|
28
|
+
|
|
29
|
+
// ── Resolving package contents ────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function installedVersion(dir) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")).version || null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Find a package's files offline: the tree's node_modules (when the installed
|
|
41
|
+
* version matches the lockfile), else npm's cache by integrity.
|
|
42
|
+
*/
|
|
43
|
+
function resolveSource(pkg, treeRoot, cacheDirs) {
|
|
44
|
+
if (treeRoot) {
|
|
45
|
+
const dir = join(treeRoot, pkg.path);
|
|
46
|
+
if (existsSync(dir) && installedVersion(dir) === pkg.version) return new DirSource(dir);
|
|
47
|
+
}
|
|
48
|
+
if (!pkg.integrity) return null;
|
|
49
|
+
for (const d of cacheDirs) {
|
|
50
|
+
const src = sourceFromNpmCache(pkg.integrity, d);
|
|
51
|
+
if (src) return src;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Scan a list of lockfile packages. Results cached by name@version+integrity. */
|
|
57
|
+
function scanTree(pkgs, treeRoot, ctx) {
|
|
58
|
+
return pkgs.map((p) => {
|
|
59
|
+
const key = `${p.name}@${p.version}#${p.integrity || ""}`;
|
|
60
|
+
if (!ctx.cache.has(key)) {
|
|
61
|
+
const src = resolveSource(p, treeRoot, ctx.cacheDirs);
|
|
62
|
+
ctx.cache.set(key, src ? { ...scanPackage(src, { prefix: `${p.path}/` }), source: src.kind } : null);
|
|
63
|
+
}
|
|
64
|
+
const r = ctx.cache.get(key);
|
|
65
|
+
return {
|
|
66
|
+
...p,
|
|
67
|
+
source: r?.source || null,
|
|
68
|
+
capabilities: r ? r.capabilities : null,
|
|
69
|
+
installScripts: r?.installScripts || {},
|
|
70
|
+
installRisk: r?.installRisk || [],
|
|
71
|
+
exfil: r?.exfil || [],
|
|
72
|
+
filesScanned: r?.filesScanned || 0,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Findings ──────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
const capList = (ids) => ids.map((id) => CAPABILITIES[id]?.label || id).join(", ");
|
|
80
|
+
|
|
81
|
+
function evidenceText(caps, ids) {
|
|
82
|
+
const lines = [];
|
|
83
|
+
for (const id of ids) {
|
|
84
|
+
for (const e of (caps?.[id]?.evidence || []).slice(0, 2)) {
|
|
85
|
+
lines.push(`${CAPABILITIES[id].label}: ${e.file}:${e.line} \`${e.snippet}\``);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return lines.join("; ");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function firstEvidence(caps, ids) {
|
|
92
|
+
for (const id of ids) {
|
|
93
|
+
const e = caps?.[id]?.evidence?.[0];
|
|
94
|
+
if (e) return e;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function finding({ title, severity, confidence, method, description, pkg, ev, capabilities, extra = {} }) {
|
|
100
|
+
return {
|
|
101
|
+
title,
|
|
102
|
+
severity,
|
|
103
|
+
category: CATEGORY,
|
|
104
|
+
source: SOURCE,
|
|
105
|
+
method,
|
|
106
|
+
confidence,
|
|
107
|
+
description,
|
|
108
|
+
url: ev?.file || pkg.path,
|
|
109
|
+
line: ev?.line || undefined,
|
|
110
|
+
package: pkg.name,
|
|
111
|
+
version: pkg.version,
|
|
112
|
+
capabilities,
|
|
113
|
+
...extra,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Risk findings that stand on their own, without a diff. */
|
|
118
|
+
function riskFindings(pkg) {
|
|
119
|
+
const out = [];
|
|
120
|
+
const caps = pkg.capabilities;
|
|
121
|
+
if (!caps) return out;
|
|
122
|
+
const at = `${pkg.name}@${pkg.version}`;
|
|
123
|
+
|
|
124
|
+
for (const id of KNOWN_BAD) {
|
|
125
|
+
if (!caps[id]) continue;
|
|
126
|
+
if ((KNOWN_BAD_EXEMPT[id] || []).some((re) => re.test(pkg.name))) continue;
|
|
127
|
+
const ev = caps[id].evidence[0];
|
|
128
|
+
out.push(finding({
|
|
129
|
+
title: `${at}: ${CAPABILITIES[id].label}`,
|
|
130
|
+
severity: "critical",
|
|
131
|
+
confidence: CAPABILITIES[id].confidence,
|
|
132
|
+
method: "deps-capability-scan",
|
|
133
|
+
description: `${CAPABILITIES[id].why} Evidence: ${evidenceText(caps, [id])}`,
|
|
134
|
+
pkg, ev, capabilities: [id],
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
if (caps.obfuscated) {
|
|
138
|
+
const ev = caps.obfuscated.evidence[0];
|
|
139
|
+
out.push(finding({
|
|
140
|
+
title: `${at}: obfuscated code`,
|
|
141
|
+
severity: "high",
|
|
142
|
+
confidence: "potential",
|
|
143
|
+
method: "deps-capability-scan",
|
|
144
|
+
description: `${CAPABILITIES.obfuscated.why} Signals at ${ev.file}:${ev.line}: ${ev.snippet}. Read the file before trusting it; minified code alone does not trigger this.`,
|
|
145
|
+
pkg, ev, capabilities: ["obfuscated"],
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
if (pkg.exfil?.length) {
|
|
149
|
+
const ev = pkg.exfil[0];
|
|
150
|
+
out.push(finding({
|
|
151
|
+
title: `${at}: serialises the whole environment next to network code`,
|
|
152
|
+
severity: "high",
|
|
153
|
+
confidence: "likely",
|
|
154
|
+
method: "deps-capability-scan",
|
|
155
|
+
description: `process.env (tokens, keys, CI secrets) is serialised within a few lines of a network call, the shape of credential exfiltration. Evidence: ${ev.file}:${ev.line} \`${ev.snippet}\`. ${evidenceText(caps, ["network"])}`,
|
|
156
|
+
pkg, ev, capabilities: ["env-bulk", "network"],
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
if (caps["install-scripts"]) {
|
|
160
|
+
const ev = caps["install-scripts"].evidence[0];
|
|
161
|
+
const hooks = Object.entries(pkg.installScripts).map(([h, cmd]) => `${h}: \`${cmd.slice(0, 100)}\``).join("; ");
|
|
162
|
+
const risky = pkg.installRisk.length > 0;
|
|
163
|
+
out.push(finding({
|
|
164
|
+
title: `${at}: runs ${risky ? "a risky " : ""}install script`,
|
|
165
|
+
severity: risky ? "medium" : "low",
|
|
166
|
+
confidence: "confirmed",
|
|
167
|
+
method: "deps-capability-scan",
|
|
168
|
+
description: `${CAPABILITIES["install-scripts"].why} ${hooks}.${risky ? ` Why it stands out: ${pkg.installRisk.join("; ")}.` : ""} Install with \`npm ci --ignore-scripts\` if the package works without it.`,
|
|
169
|
+
pkg, ev, capabilities: ["install-scripts"],
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Capabilities that riskFindings already reports on their own.
|
|
176
|
+
const OWN_FINDING = new Set([...KNOWN_BAD, "obfuscated", "install-scripts"]);
|
|
177
|
+
|
|
178
|
+
function diffFindings(diff) {
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const a of diff.added) {
|
|
181
|
+
const ids = a.capabilities || [];
|
|
182
|
+
const notable = ids.filter((id) => id !== "env" && id !== "dynamic-require" && !OWN_FINDING.has(id));
|
|
183
|
+
if (notable.length === 0) continue;
|
|
184
|
+
out.push(finding({
|
|
185
|
+
title: `New dependency ${a.name}@${a.version}: ${capList(notable)}`,
|
|
186
|
+
severity: maxSeverity(notable.map((id) => CAPABILITIES[id].severity)),
|
|
187
|
+
confidence: "likely",
|
|
188
|
+
method: "deps-capability-diff",
|
|
189
|
+
description: `A package that was not in the base tree. It can: ${notable.map((id) => CAPABILITIES[id].why).join(" ")} Evidence: ${evidenceText(a.pkg.capabilities, notable)}`,
|
|
190
|
+
pkg: a.pkg, ev: firstEvidence(a.pkg.capabilities, notable), capabilities: notable,
|
|
191
|
+
extra: { change: "added" },
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
for (const u of diff.upgraded) {
|
|
195
|
+
if (!u.gained || u.gained.length === 0) continue;
|
|
196
|
+
const sev = maxSeverity(u.gained.map((id) => CAPABILITIES[id].severity));
|
|
197
|
+
out.push(finding({
|
|
198
|
+
title: `${u.name} ${u.from} → ${u.to} gained ${capList(u.gained)}`,
|
|
199
|
+
severity: sev,
|
|
200
|
+
confidence: u.gained.some((id) => CAPABILITIES[id].confidence === "potential") && u.gained.length === 1 ? "potential" : "likely",
|
|
201
|
+
method: "deps-capability-diff",
|
|
202
|
+
description: `This upgrade can do things the previous version could not. ${u.gained.map((id) => CAPABILITIES[id].why).join(" ")} Evidence: ${evidenceText(u.pkg.capabilities, u.gained)}`,
|
|
203
|
+
pkg: u.pkg, ev: firstEvidence(u.pkg.capabilities, u.gained), capabilities: u.gained,
|
|
204
|
+
extra: { change: "upgraded", from: u.from },
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
const missing = diff.upgraded.filter((u) => u.unavailable);
|
|
208
|
+
if (missing.length) {
|
|
209
|
+
out.push({
|
|
210
|
+
title: `${missing.length} upgraded package(s) not compared: old version not available offline`,
|
|
211
|
+
severity: "info",
|
|
212
|
+
category: CATEGORY,
|
|
213
|
+
source: SOURCE,
|
|
214
|
+
method: "deps-capability-diff",
|
|
215
|
+
confidence: "confirmed",
|
|
216
|
+
description: `No local copy to compare against for: ${missing.map((u) => `${u.name} ${u.from} → ${u.to}`).join(", ")}. Install the base tree (\`npm ci --ignore-scripts\` in a checkout of the base) and pass that folder to --diff, or rerun with --online to fetch the old tarballs from the registry.`,
|
|
217
|
+
url: "package-lock.json",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function typosquatFindings(hits, pkgByName, direct) {
|
|
224
|
+
return hits.map((h) => {
|
|
225
|
+
const pkg = pkgByName.get(h.name) || { name: h.name, version: null, path: "package-lock.json" };
|
|
226
|
+
const isDirect = direct.has(h.name);
|
|
227
|
+
return finding({
|
|
228
|
+
title: `${h.name} looks like ${h.target} (${h.kind})`,
|
|
229
|
+
severity: isDirect ? "medium" : "low",
|
|
230
|
+
confidence: "potential",
|
|
231
|
+
method: "deps-typosquat",
|
|
232
|
+
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`}.`,
|
|
233
|
+
pkg, ev: { file: pkg.path, line: undefined }, capabilities: [],
|
|
234
|
+
extra: { typosquatOf: h.target, direct: isDirect },
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function registryFindings(results, pkgByKey) {
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const [key, r] of [...results].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
242
|
+
const pkg = pkgByKey.get(key);
|
|
243
|
+
if (!pkg) continue;
|
|
244
|
+
for (const s of r.signals) {
|
|
245
|
+
out.push(finding({
|
|
246
|
+
title: `${key}: ${s.text}`,
|
|
247
|
+
severity: s.severity,
|
|
248
|
+
confidence: "confirmed",
|
|
249
|
+
method: "deps-registry",
|
|
250
|
+
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." : ""}`,
|
|
251
|
+
pkg, ev: { file: pkg.path }, capabilities: [],
|
|
252
|
+
extra: { registrySignal: s.id },
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Entry point ───────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function summarize(tree) {
|
|
262
|
+
const counts = {};
|
|
263
|
+
for (const id of CAPABILITY_ORDER) counts[id] = 0;
|
|
264
|
+
for (const p of tree) for (const id of Object.keys(p.capabilities || {})) counts[id]++;
|
|
265
|
+
return counts;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Load the old tree for --diff (lockfile path or project folder) or --base (git ref). */
|
|
269
|
+
function loadBase(root, opts) {
|
|
270
|
+
if (opts.base) {
|
|
271
|
+
return { lock: readLockfileAtRef(root, opts.base), treeRoot: null, label: opts.base };
|
|
272
|
+
}
|
|
273
|
+
const p = resolve(opts.diff);
|
|
274
|
+
if (!existsSync(p)) throw new Error(`--diff path not found: ${opts.diff}`);
|
|
275
|
+
const isDir = statSync(p).isDirectory();
|
|
276
|
+
const lockPath = isDir ? findLockfile(p) : p;
|
|
277
|
+
if (!lockPath) throw new Error(`No package-lock.json in ${opts.diff}`);
|
|
278
|
+
const treeRoot = dirname(lockPath);
|
|
279
|
+
// A lockfile beside the project's own node_modules describes another tree;
|
|
280
|
+
// only trust node_modules that sit next to the old lockfile.
|
|
281
|
+
return { lock: readLockfile(lockPath), treeRoot: treeRoot === resolve(root) ? null : treeRoot, label: opts.diff };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Scan a project's dependencies.
|
|
286
|
+
* @param {string} root project folder containing package-lock.json
|
|
287
|
+
* @param {{ diff?: string, base?: string, online?: boolean, prod?: boolean,
|
|
288
|
+
* onPhase?: Function, cacheDir?: string|string[], now?: number, fetchImpl?: Function,
|
|
289
|
+
* treeRoot?: string }} opts treeRoot: where installed packages live, when
|
|
290
|
+
* the lockfile describes a tree that is not installed beside it (penthera install)
|
|
291
|
+
*/
|
|
292
|
+
export async function scanDeps(root, opts = {}) {
|
|
293
|
+
const started = Date.now();
|
|
294
|
+
const onPhase = opts.onPhase || (() => {});
|
|
295
|
+
const lockPath = findLockfile(root);
|
|
296
|
+
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);
|
|
298
|
+
const ctx = { cache: new Map(), cacheDirs: [].concat(opts.cacheDir || npmCacheDir()) };
|
|
299
|
+
const keep = (p) => !p.link && p.version && (!opts.prod || !p.dev);
|
|
300
|
+
|
|
301
|
+
const pkgs = parseLockfile(lock).filter(keep);
|
|
302
|
+
onPhase(`Scanning ${pkgs.length} packages from ${basename(lockPath)} (offline)`);
|
|
303
|
+
const tree = scanTree(pkgs, opts.treeRoot || root, ctx);
|
|
304
|
+
const direct = new Set(directDependencies(lock));
|
|
305
|
+
|
|
306
|
+
let diff = null;
|
|
307
|
+
let base = null;
|
|
308
|
+
if (opts.diff || opts.base) {
|
|
309
|
+
base = loadBase(root, opts);
|
|
310
|
+
const oldPkgs = parseLockfile(base.lock).filter(keep);
|
|
311
|
+
const byVersion = new Map(tree.map((p) => [`${p.name}@${p.version}`, p]));
|
|
312
|
+
const newNames = new Set(tree.map((p) => p.name));
|
|
313
|
+
// Only old versions that are gone but whose name remains need inspecting:
|
|
314
|
+
// a version present in both trees has the same contents (same lockfile
|
|
315
|
+
// integrity), and a removed name is not compared.
|
|
316
|
+
const needOld = oldPkgs.filter((p) => !byVersion.has(`${p.name}@${p.version}`) && newNames.has(p.name));
|
|
317
|
+
onPhase(`Comparing against ${base.label}: ${needOld.length} replaced version(s) to inspect`);
|
|
318
|
+
const oldScanned = new Map(scanTree(needOld, base.treeRoot, ctx).map((p) => [p.path, p]));
|
|
319
|
+
const oldTree = oldPkgs.map((p) => oldScanned.get(p.path)
|
|
320
|
+
|| { ...p, capabilities: byVersion.get(`${p.name}@${p.version}`)?.capabilities || {}, installScripts: byVersion.get(`${p.name}@${p.version}`)?.installScripts || {} });
|
|
321
|
+
diff = diffTrees(oldTree, tree);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --online: registry metadata, and old tarballs the cache did not have.
|
|
325
|
+
let online = null;
|
|
326
|
+
const findings = [];
|
|
327
|
+
if (opts.online) {
|
|
328
|
+
const { checkRegistry, fetchTarballSource } = await import("./online.js");
|
|
329
|
+
const changed = diff ? [...diff.added.map((a) => a.pkg), ...diff.upgraded.map((u) => ({ ...u.pkg, previous: u.from }))] : [];
|
|
330
|
+
const items = diff ? changed : tree.filter((p) => direct.has(p.name));
|
|
331
|
+
onPhase(`--online: fetching registry metadata for ${items.length} package(s) (the only network use)`);
|
|
332
|
+
const results = await checkRegistry(items.map((p) => ({ name: p.name, version: p.version, previous: p.previous })), { fetchImpl: opts.fetchImpl, now: opts.now });
|
|
333
|
+
online = { checked: items.length, unreachable: [...results.values()].filter((r) => !r.reachable).length, fetchedTarballs: 0 };
|
|
334
|
+
if (diff) {
|
|
335
|
+
const oldPkgs = parseLockfile(base.lock);
|
|
336
|
+
for (const u of diff.upgraded.filter((x) => x.unavailable === "old")) {
|
|
337
|
+
const oldPkg = oldPkgs.find((p) => p.name === u.name && p.version === u.from);
|
|
338
|
+
// Verified against the base lockfile's integrity before anything is read.
|
|
339
|
+
const src = await fetchTarballSource(oldPkg?.resolved, oldPkg?.integrity, { fetchImpl: opts.fetchImpl });
|
|
340
|
+
if (!src) continue;
|
|
341
|
+
online.fetchedTarballs++;
|
|
342
|
+
const o = new Set(Object.keys(scanPackage(src, { prefix: `${oldPkg.path}/` }).capabilities));
|
|
343
|
+
const n = new Set(Object.keys(u.pkg.capabilities || {}));
|
|
344
|
+
u.gained = CAPABILITY_ORDER.filter((id) => n.has(id) && !o.has(id));
|
|
345
|
+
u.lost = CAPABILITY_ORDER.filter((id) => o.has(id) && !n.has(id));
|
|
346
|
+
u.unavailable = null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
findings.push(...registryFindings(results, new Map(tree.map((p) => [`${p.name}@${p.version}`, p]))));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// In diff mode, report on what changed; otherwise on the whole tree.
|
|
353
|
+
const inScope = diff
|
|
354
|
+
? new Set([...diff.added.map((a) => a.pkg), ...diff.upgraded.map((u) => u.pkg)])
|
|
355
|
+
: new Set(tree);
|
|
356
|
+
for (const p of tree) if (inScope.has(p)) findings.push(...riskFindings(p));
|
|
357
|
+
if (diff) findings.push(...diffFindings(diff));
|
|
358
|
+
|
|
359
|
+
// Names you chose (direct deps), or names that are new in this change. A
|
|
360
|
+
// transitive dependency of an unchanged tree was picked by its parent's
|
|
361
|
+
// maintainers long ago; flagging those is mostly noise.
|
|
362
|
+
const nameScope = diff ? diff.added.map((a) => a.name) : tree.filter((p) => direct.has(p.name)).map((p) => p.name);
|
|
363
|
+
const typos = findTyposquats(nameScope);
|
|
364
|
+
findings.push(...typosquatFindings(typos, new Map(tree.map((p) => [p.name, p])), direct));
|
|
365
|
+
|
|
366
|
+
// 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;
|
|
369
|
+
const rel = (p) => {
|
|
370
|
+
const r = relative(process.cwd(), p);
|
|
371
|
+
return r === "" ? "." : r.startsWith("..") ? p : r;
|
|
372
|
+
};
|
|
373
|
+
return {
|
|
374
|
+
target: rel(root),
|
|
375
|
+
mode: "deps",
|
|
376
|
+
local: true,
|
|
377
|
+
reachable: true,
|
|
378
|
+
timestamp: new Date(started).toISOString(),
|
|
379
|
+
duration: Date.now() - started,
|
|
380
|
+
deps: {
|
|
381
|
+
lockfile: rel(lockPath),
|
|
382
|
+
lockfileVersion: lock.lockfileVersion || 1,
|
|
383
|
+
prod: !!opts.prod,
|
|
384
|
+
network: opts.online ? "online (registry metadata only)" : "offline",
|
|
385
|
+
total: tree.length,
|
|
386
|
+
scanned: tree.filter((p) => p.capabilities).length,
|
|
387
|
+
skippedOptional,
|
|
388
|
+
direct: [...direct],
|
|
389
|
+
notInstalled: unavailable.map((p) => `${p.name}@${p.version}`),
|
|
390
|
+
capabilityCounts: summarize(tree),
|
|
391
|
+
popularList: loadPopular().source,
|
|
392
|
+
packages: tree.map((p) => ({
|
|
393
|
+
name: p.name,
|
|
394
|
+
version: p.version,
|
|
395
|
+
path: p.path,
|
|
396
|
+
dev: p.dev,
|
|
397
|
+
direct: direct.has(p.name),
|
|
398
|
+
source: p.source,
|
|
399
|
+
capabilities: p.capabilities,
|
|
400
|
+
})),
|
|
401
|
+
typosquats: typos,
|
|
402
|
+
online,
|
|
403
|
+
diff: diff ? {
|
|
404
|
+
base: base.label,
|
|
405
|
+
added: diff.added.map(({ pkg, ...a }) => ({ ...a, installScripts: pkg.installScripts })),
|
|
406
|
+
upgraded: diff.upgraded.map(({ pkg, prev, ...u }) => ({ ...u, installScripts: pkg.installScripts, previousInstallScripts: prev?.installScripts || null })),
|
|
407
|
+
removed: diff.removed,
|
|
408
|
+
evidence: Object.fromEntries([...diff.added, ...diff.upgraded].map((x) => {
|
|
409
|
+
const ids = x.gained || x.capabilities || [];
|
|
410
|
+
return [`${x.name}@${x.version || x.to}`, Object.fromEntries(ids.map((id) => [id, x.pkg.capabilities?.[id]?.evidence || []]))];
|
|
411
|
+
})),
|
|
412
|
+
} : null,
|
|
413
|
+
},
|
|
414
|
+
findings,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package-lock.json reader for `penthera deps`.
|
|
3
|
+
*
|
|
4
|
+
* Turns a lockfile (v1, v2 or v3) into a flat, sorted list of installed
|
|
5
|
+
* packages with the fields the capability scanner and the diff need: where the
|
|
6
|
+
* package lives in node_modules, its exact version, and its integrity hash (the
|
|
7
|
+
* key into npm's offline content cache).
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
/** Package name from a lockfile v2/v3 key like node_modules/a/node_modules/@s/b. */
|
|
14
|
+
export function nameFromLockPath(key) {
|
|
15
|
+
const i = key.lastIndexOf("node_modules/");
|
|
16
|
+
return i === -1 ? key : key.slice(i + "node_modules/".length);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse lockfile JSON into packages.
|
|
21
|
+
* @returns {{ name, version, path, integrity, resolved, dev, optional, hasInstallScript, link }[]}
|
|
22
|
+
*/
|
|
23
|
+
export function parseLockfile(lock) {
|
|
24
|
+
const out = [];
|
|
25
|
+
if (lock && lock.packages) {
|
|
26
|
+
for (const [key, v] of Object.entries(lock.packages)) {
|
|
27
|
+
if (key === "" || !v) continue;
|
|
28
|
+
// Workspace members appear twice: a link entry under node_modules and the
|
|
29
|
+
// real folder. Keep the node_modules view, skip the source folder entry.
|
|
30
|
+
if (!key.includes("node_modules/")) continue;
|
|
31
|
+
const name = v.name || nameFromLockPath(key);
|
|
32
|
+
out.push({
|
|
33
|
+
name,
|
|
34
|
+
version: v.version || null,
|
|
35
|
+
path: key,
|
|
36
|
+
integrity: v.integrity || null,
|
|
37
|
+
resolved: v.resolved || null,
|
|
38
|
+
dev: !!v.dev,
|
|
39
|
+
optional: !!(v.optional || v.devOptional),
|
|
40
|
+
hasInstallScript: !!v.hasInstallScript,
|
|
41
|
+
link: !!v.link,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
} else if (lock && lock.dependencies) {
|
|
45
|
+
const walk = (deps, prefix) => {
|
|
46
|
+
for (const [name, v] of Object.entries(deps)) {
|
|
47
|
+
if (!v) continue;
|
|
48
|
+
const path = `${prefix}node_modules/${name}`;
|
|
49
|
+
out.push({
|
|
50
|
+
name,
|
|
51
|
+
version: v.version || null,
|
|
52
|
+
path,
|
|
53
|
+
integrity: v.integrity || null,
|
|
54
|
+
resolved: v.resolved || null,
|
|
55
|
+
dev: !!v.dev,
|
|
56
|
+
optional: !!v.optional,
|
|
57
|
+
hasInstallScript: false,
|
|
58
|
+
link: String(v.version || "").startsWith("file:"),
|
|
59
|
+
});
|
|
60
|
+
if (v.dependencies) walk(v.dependencies, `${path}/`);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
walk(lock.dependencies, "");
|
|
64
|
+
}
|
|
65
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Direct dependency names declared by the root project (from the lockfile root entry). */
|
|
69
|
+
export function directDependencies(lock) {
|
|
70
|
+
const root = lock?.packages?.[""] || {};
|
|
71
|
+
const names = new Set([
|
|
72
|
+
...Object.keys(root.dependencies || {}),
|
|
73
|
+
...Object.keys(root.devDependencies || {}),
|
|
74
|
+
...Object.keys(root.optionalDependencies || {}),
|
|
75
|
+
]);
|
|
76
|
+
return [...names].sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Read and parse a lockfile from disk. */
|
|
80
|
+
export function readLockfile(path) {
|
|
81
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Locate package-lock.json (or npm-shrinkwrap.json) in a project directory. */
|
|
85
|
+
export function findLockfile(dir) {
|
|
86
|
+
for (const f of ["package-lock.json", "npm-shrinkwrap.json"]) {
|
|
87
|
+
const p = join(dir, f);
|
|
88
|
+
if (existsSync(p)) return p;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read the lockfile as it was at a git ref (for `--base <ref>`).
|
|
95
|
+
* Only runs `git show`; no fetch, no checkout.
|
|
96
|
+
*/
|
|
97
|
+
export function readLockfileAtRef(dir, ref) {
|
|
98
|
+
const git = (args) => execFileSync("git", args, { cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 });
|
|
99
|
+
const prefix = git(["rev-parse", "--show-prefix"]).trim();
|
|
100
|
+
for (const f of ["package-lock.json", "npm-shrinkwrap.json"]) {
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(git(["show", `${ref}:${prefix}${f}`]));
|
|
103
|
+
} catch { /* try the next name */ }
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`No package-lock.json at ${ref} in ${dir}`);
|
|
106
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera deps --online`: registry metadata checks.
|
|
3
|
+
*
|
|
4
|
+
* THE ONLY NETWORK USE IN `penthera deps`, and it is off by default. When the
|
|
5
|
+
* flag is set it makes plain GET requests to the public npm registry
|
|
6
|
+
* (registry.npmjs.org, or npm_config_registry) for package metadata, and, in
|
|
7
|
+
* diff mode, for old tarballs that are not in the local npm cache. Nothing
|
|
8
|
+
* about your project is sent: the requests name public packages only.
|
|
9
|
+
*
|
|
10
|
+
* Signals:
|
|
11
|
+
* - version published under 30 days ago (fresh releases are where hijacks land),
|
|
12
|
+
* - package created under 30 days ago (slopsquat / brand-new name),
|
|
13
|
+
* - publisher changed from the previous version to someone who was not a maintainer,
|
|
14
|
+
* - version deprecated.
|
|
15
|
+
*/
|
|
16
|
+
import { sourceFromTarball } from "./source.js";
|
|
17
|
+
import { request } from "../../src/utils/http.js";
|
|
18
|
+
|
|
19
|
+
// Registry calls go through the shared client: same User-Agent, same egress
|
|
20
|
+
// accounting (and refusal under --offline) as every other request.
|
|
21
|
+
const registryFetch = (url, opts) => request(url, { ...opts, redirect: "follow", probe: "deps-online" });
|
|
22
|
+
|
|
23
|
+
const DAY_MS = 24 * 3600 * 1000;
|
|
24
|
+
export const FRESH_DAYS = 30;
|
|
25
|
+
|
|
26
|
+
export function registryUrl(env = process.env) {
|
|
27
|
+
return String(env.npm_config_registry || env.NPM_CONFIG_REGISTRY || "https://registry.npmjs.org/").replace(/\/?$/, "/");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Fetch a packument. Returns parsed JSON or null. */
|
|
31
|
+
export async function fetchPackument(name, { fetchImpl = registryFetch, registry = registryUrl(), timeout = 15000 } = {}) {
|
|
32
|
+
// Registry form: "@scope%2fname". Encoding the whole name also keeps a hostile
|
|
33
|
+
// lockfile entry from adding path segments or a query to the request.
|
|
34
|
+
const url = registry + encodeURIComponent(name).replace(/^%40/, "@");
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetchImpl(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeout) });
|
|
37
|
+
if (!res.ok) return null;
|
|
38
|
+
return await res.json();
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Version published right before `version`, by publish time. */
|
|
45
|
+
function previousVersion(doc, version) {
|
|
46
|
+
const times = Object.entries(doc.time || {})
|
|
47
|
+
.filter(([v]) => v !== "created" && v !== "modified" && doc.versions?.[v])
|
|
48
|
+
.sort((a, b) => Date.parse(a[1]) - Date.parse(b[1]));
|
|
49
|
+
const i = times.findIndex(([v]) => v === version);
|
|
50
|
+
return i > 0 ? times[i - 1][0] : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const publisher = (v) => v?._npmUser?.name || null;
|
|
54
|
+
const maintainers = (v) => (v?.maintainers || []).map((m) => (typeof m === "string" ? m : m.name)).filter(Boolean);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Metadata signals for one name@version.
|
|
58
|
+
* @param {object} doc packument
|
|
59
|
+
* @param {string} version
|
|
60
|
+
* @param {{ previous?: string, now?: number }} opts previous = version this one replaces (diff mode)
|
|
61
|
+
*/
|
|
62
|
+
export function registrySignals(doc, version, { previous = null, now = Date.now() } = {}) {
|
|
63
|
+
const signals = [];
|
|
64
|
+
if (!doc || !doc.versions) return signals;
|
|
65
|
+
const v = doc.versions[version];
|
|
66
|
+
if (!v) {
|
|
67
|
+
signals.push({ id: "missing-version", severity: "high", text: `${version} is not on the registry (unpublished or never published)` });
|
|
68
|
+
return signals;
|
|
69
|
+
}
|
|
70
|
+
if (v.deprecated) signals.push({ id: "deprecated", severity: "low", text: `deprecated: ${String(v.deprecated).slice(0, 160)}` });
|
|
71
|
+
|
|
72
|
+
const published = Date.parse(doc.time?.[version] || "");
|
|
73
|
+
if (published && now - published < FRESH_DAYS * DAY_MS) {
|
|
74
|
+
const days = Math.max(0, Math.floor((now - published) / DAY_MS));
|
|
75
|
+
signals.push({ id: "fresh-version", severity: "low", text: `published ${days} day(s) ago` });
|
|
76
|
+
}
|
|
77
|
+
const created = Date.parse(doc.time?.created || "");
|
|
78
|
+
if (created && now - created < FRESH_DAYS * DAY_MS) {
|
|
79
|
+
const days = Math.max(0, Math.floor((now - created) / DAY_MS));
|
|
80
|
+
signals.push({ id: "new-package", severity: "medium", text: `package created ${days} day(s) ago` });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const prev = previous && doc.versions[previous] ? previous : previousVersion(doc, version);
|
|
84
|
+
if (prev) {
|
|
85
|
+
const before = doc.versions[prev];
|
|
86
|
+
const was = publisher(before);
|
|
87
|
+
const now_ = publisher(v);
|
|
88
|
+
if (was && now_ && was !== now_ && !maintainers(before).includes(now_)) {
|
|
89
|
+
signals.push({ id: "publisher-change", severity: "medium", text: `published by ${now_}; ${prev} was published by ${was}, and ${now_} was not a maintainer then` });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return signals;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Look up registry signals for a set of packages.
|
|
97
|
+
* @param {{ name, version, previous? }[]} items
|
|
98
|
+
* @returns {Promise<Map<string, { signals, doc }>>} keyed by name@version
|
|
99
|
+
*/
|
|
100
|
+
export async function checkRegistry(items, opts = {}) {
|
|
101
|
+
const concurrency = opts.concurrency || 8;
|
|
102
|
+
const docs = new Map();
|
|
103
|
+
const names = [...new Set(items.map((i) => i.name))].sort();
|
|
104
|
+
for (let i = 0; i < names.length; i += concurrency) {
|
|
105
|
+
const batch = names.slice(i, i + concurrency);
|
|
106
|
+
const got = await Promise.all(batch.map((n) => fetchPackument(n, opts)));
|
|
107
|
+
batch.forEach((n, k) => docs.set(n, got[k]));
|
|
108
|
+
}
|
|
109
|
+
const out = new Map();
|
|
110
|
+
for (const it of items) {
|
|
111
|
+
const doc = docs.get(it.name);
|
|
112
|
+
out.set(`${it.name}@${it.version}`, {
|
|
113
|
+
reachable: !!doc,
|
|
114
|
+
signals: doc ? registrySignals(doc, it.version, { previous: it.previous, now: opts.now }) : [],
|
|
115
|
+
tarball: doc?.versions?.[it.version]?.dist?.tarball || null,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Download a tarball and verify it against the lockfile integrity. */
|
|
122
|
+
export async function fetchTarballSource(url, integrity, { fetchImpl = registryFetch, timeout = 30000 } = {}) {
|
|
123
|
+
if (!url || !/^https:\/\//.test(url) || !integrity) return null;
|
|
124
|
+
try {
|
|
125
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeout) });
|
|
126
|
+
if (!res.ok) return null;
|
|
127
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
128
|
+
return sourceFromTarball(buf, integrity, "registry");
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|