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.
Files changed (101) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +493 -45
  3. package/docs/METHODOLOGY.md +321 -0
  4. package/docs/compliance-cra.md +141 -0
  5. package/docs/compliance-nis2.md +3 -1
  6. package/docs/evidence.md +158 -0
  7. package/docs/examples/github-actions-deps.yml +105 -0
  8. package/docs/examples/github-actions-scan.yml +1 -1
  9. package/docs/examples/pr-dependency-review.yml +56 -0
  10. package/docs/funding/due-diligence-checklist.md +79 -0
  11. package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
  12. package/docs/funding/partner-one-pager.md +84 -0
  13. package/docs/funding/sponsor-tiers.md +81 -0
  14. package/docs/index.md +12 -1
  15. package/docs/local-loop.md +276 -0
  16. package/docs/plan.md +100 -0
  17. package/docs/references.bib +539 -0
  18. package/docs/references.md +77 -0
  19. package/docs/release-plan.md +228 -0
  20. package/lib/adaptive-probes.js +8 -5
  21. package/lib/agents/discover.js +384 -0
  22. package/lib/agents/hidden-content.js +387 -0
  23. package/lib/agents/index.js +286 -0
  24. package/lib/agents/lock.js +115 -0
  25. package/lib/agents/owasp.js +83 -0
  26. package/lib/agents/rules.js +376 -0
  27. package/lib/compliance/cra.js +525 -0
  28. package/lib/crawler.js +5 -15
  29. package/lib/data/retire-jsrepository.json.gz +0 -0
  30. package/lib/data/retire-jsrepository.meta.json +7 -0
  31. package/lib/deps/capabilities.js +523 -0
  32. package/lib/deps/diff.js +84 -0
  33. package/lib/deps/index.js +440 -0
  34. package/lib/deps/license.js +329 -0
  35. package/lib/deps/lockfile.js +109 -0
  36. package/lib/deps/online.js +132 -0
  37. package/lib/deps/popular-packages.json +1 -0
  38. package/lib/deps/report.js +190 -0
  39. package/lib/deps/source.js +171 -0
  40. package/lib/deps/spdx.json +1 -0
  41. package/lib/deps/typosquat.js +113 -0
  42. package/lib/fix/apply.js +95 -0
  43. package/lib/fix/diff.js +199 -0
  44. package/lib/fix/index.js +129 -0
  45. package/lib/fix/model.js +108 -0
  46. package/lib/fix/recipes/cookies.js +130 -0
  47. package/lib/fix/recipes/cors.js +116 -0
  48. package/lib/fix/recipes/headers.js +250 -0
  49. package/lib/fix/recipes/rate-limit.js +211 -0
  50. package/lib/fix/recipes/secrets.js +110 -0
  51. package/lib/fix/util.js +231 -0
  52. package/lib/fuzzer.js +14 -8
  53. package/lib/hook/index.js +312 -0
  54. package/lib/hook/policy.js +62 -0
  55. package/lib/injections.js +19 -15
  56. package/lib/install/index.js +271 -0
  57. package/lib/install/policy.js +125 -0
  58. package/lib/jurisdictions/nl.json +9 -0
  59. package/lib/memory/history.js +52 -0
  60. package/lib/memory/index.js +111 -0
  61. package/lib/memory/repo-suppress.js +64 -0
  62. package/lib/memory/store.js +325 -0
  63. package/lib/memory/views.js +198 -0
  64. package/lib/params.js +4 -12
  65. package/lib/provenance.js +2 -0
  66. package/lib/recon.js +12 -20
  67. package/lib/references.js +31 -0
  68. package/lib/references.json +374 -0
  69. package/lib/remediation/index.js +28 -0
  70. package/lib/retirejs.js +50 -22
  71. package/lib/templates.js +7 -10
  72. package/lib/whitebox/aibom.js +384 -0
  73. package/lib/whitebox/frameworks.js +1 -1
  74. package/lib/whitebox/sbom.js +45 -10
  75. package/lib/whitebox/secrets.js +11 -1
  76. package/package.json +17 -4
  77. package/skills/penthera/SKILL.md +132 -22
  78. package/src/agent/git.js +87 -0
  79. package/src/agent/loop.js +519 -0
  80. package/src/agent/tests.js +48 -0
  81. package/src/cli/agent.js +183 -0
  82. package/src/cli/deps.js +104 -0
  83. package/src/cli/evidence.js +67 -0
  84. package/src/cli/fix.js +191 -0
  85. package/src/cli/hook.js +131 -0
  86. package/src/cli/install.js +225 -0
  87. package/src/cli/memory.js +146 -0
  88. package/src/cli/merge-results.js +2 -0
  89. package/src/cli/report-outputs.js +72 -0
  90. package/src/cli/run-scan.js +2 -0
  91. package/src/cli.js +212 -8
  92. package/src/report/audience.js +270 -0
  93. package/src/report/evidence.js +253 -0
  94. package/src/report/markdown.js +65 -2
  95. package/src/report/model.js +212 -0
  96. package/src/report/template.js +175 -0
  97. package/src/report/timeline.js +145 -0
  98. package/src/reporter.js +109 -5
  99. package/src/scan-url.js +31 -7
  100. package/src/utils/http.js +252 -11
  101. package/src/utils/url.js +23 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * License policy for `penthera deps --license-policy` and the `licenses`
3
+ * section of penthera.policy.json.
4
+ *
5
+ * "licenses": {
6
+ * "preset": "permissive", // permissive | weak-copyleft-ok | no-agpl
7
+ * "allow": ["Python-2.0"], // added to the preset's allow list
8
+ * "deny": ["WTFPL"], // added to the preset's deny list
9
+ * "allowPackages": ["@acme/*"], // packages exempt from the policy (globs, @version pins)
10
+ * "unknown": "warn" // missing / non-SPDX licenses: "warn" (medium) or "block" (high)
11
+ * }
12
+ *
13
+ * An allow list, when non-empty, is exhaustive: every license in a package's
14
+ * expression must be on it (for OR, one branch is enough). A deny list always
15
+ * wins. `UNLICENSED` (npm's word for "no rights granted") breaches any policy
16
+ * unless the package is in allowPackages. Everything here is pure and offline:
17
+ * license fields come from the lockfile, else the installed package.json.
18
+ */
19
+ import { readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ const SPDX = JSON.parse(readFileSync(new URL("./spdx.json", import.meta.url), "utf-8"));
23
+ const byLower = (ids) => new Map(ids.map((id) => [id.toLowerCase(), id]));
24
+ const LICENSES = byLower([...SPDX.licenses, ...SPDX.deprecated]);
25
+ const EXCEPTIONS = byLower(SPDX.exceptions);
26
+
27
+ // Deprecated GNU ids mean "-only"; "+" means "-or-later". Normalising both
28
+ // sides lets a deny of "GPL-3.0-only" catch a package that says "GPL-3.0".
29
+ const GNU = /^(A?GPL|LGPL|GFDL)-(\d\.\d)$/;
30
+ const LEGACY_WITH = /^(GPL-[23]\.0)-with-(GCC|autoconf|bison|classpath|font)-exception$/i;
31
+
32
+ // ── SPDX expression parser ────────────────────────────────────────────────
33
+
34
+ export class SpdxError extends Error {}
35
+
36
+ function tokenize(s) {
37
+ const out = [];
38
+ const re = /\s*(?:(\()|(\))|([A-Za-z0-9][A-Za-z0-9.+:-]*))\s*/y;
39
+ let i = 0;
40
+ while (i < s.length) {
41
+ re.lastIndex = i;
42
+ const m = re.exec(s);
43
+ if (!m || m[0].length === 0) {
44
+ if (/^\s*$/.test(s.slice(i))) break;
45
+ throw new SpdxError(`unexpected character "${s[i]}" at ${i}`);
46
+ }
47
+ i = re.lastIndex;
48
+ out.push(m[1] || m[2] || m[3]);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ const OPS = new Set(["AND", "OR", "WITH"]);
54
+ // SPDX operators are uppercase; lowercase "and"/"or" are common enough on npm
55
+ // ("MIT or Apache-2.0") that rejecting them only produces noise.
56
+ const op = (t) => (t && OPS.has(t.toUpperCase()) ? t.toUpperCase() : null);
57
+
58
+ /**
59
+ * Parse an SPDX license expression into a tree.
60
+ * { type: "license", id, plus, exception, known } (id canonical-cased when known)
61
+ * { type: "and" | "or", args: [...] }
62
+ * Precedence: WITH binds tightest, then AND, then OR. Throws SpdxError.
63
+ */
64
+ export function parseSpdx(input) {
65
+ if (typeof input !== "string" || !input.trim()) throw new SpdxError("empty expression");
66
+ const tokens = tokenize(input);
67
+ let pos = 0;
68
+ const peek = () => tokens[pos];
69
+
70
+ function license() {
71
+ const t = tokens[pos++];
72
+ if (t === undefined) throw new SpdxError("expression ends early");
73
+ if (t === "(" ) {
74
+ const e = orExpr();
75
+ if (tokens[pos++] !== ")") throw new SpdxError("missing )");
76
+ return e;
77
+ }
78
+ if (t === ")" || op(t)) throw new SpdxError(`expected a license, got "${t}"`);
79
+ let id = t;
80
+ let plus = false;
81
+ if (id.endsWith("+")) { plus = true; id = id.slice(0, -1); }
82
+ if (!id || id.includes("+")) throw new SpdxError(`bad license id "${t}"`);
83
+ const ref = /^(?:DocumentRef-[A-Za-z0-9.-]+:)?LicenseRef-[A-Za-z0-9.-]+$/.test(id);
84
+ const canon = LICENSES.get(id.toLowerCase());
85
+ const node = { type: "license", id: canon || id, plus, exception: null, known: !!canon || ref, ref };
86
+ if (op(peek()) === "WITH") {
87
+ pos++;
88
+ const ex = tokens[pos++];
89
+ if (ex === undefined || ex === "(" || ex === ")" || op(ex)) throw new SpdxError("WITH needs an exception id");
90
+ node.exception = EXCEPTIONS.get(ex.toLowerCase()) || ex;
91
+ if (!EXCEPTIONS.has(ex.toLowerCase())) node.known = false;
92
+ }
93
+ return node;
94
+ }
95
+ function chain(type, next) {
96
+ const args = [next()];
97
+ while (op(peek()) === type.toUpperCase()) { pos++; args.push(next()); }
98
+ if (args.length === 1) return args[0];
99
+ // Flatten (a AND (b AND c)) into one list.
100
+ return { type, args: args.flatMap((a) => (a.type === type ? a.args : [a])) };
101
+ }
102
+ const andExpr = () => chain("and", license);
103
+ const orExpr = () => chain("or", andExpr);
104
+
105
+ const tree = orExpr();
106
+ if (pos < tokens.length) throw new SpdxError(`unexpected "${tokens[pos]}"`);
107
+ return tree;
108
+ }
109
+
110
+ /** Every license leaf of a tree. */
111
+ export function leaves(tree) {
112
+ return tree.type === "license" ? [tree] : tree.args.flatMap(leaves);
113
+ }
114
+
115
+ /** Print a tree back as a canonical expression. */
116
+ export function formatSpdx(tree, parent = null) {
117
+ if (tree.type === "license") return `${tree.id}${tree.plus ? "+" : ""}${tree.exception ? ` WITH ${tree.exception}` : ""}`;
118
+ const s = tree.args.map((a) => formatSpdx(a, tree.type)).join(` ${tree.type.toUpperCase()} `);
119
+ return parent && parent !== tree.type ? `(${s})` : s;
120
+ }
121
+
122
+ /** The id a policy compares: deprecated GNU forms and "+" resolved. */
123
+ export function canonicalId(id, plus = false) {
124
+ const legacy = LEGACY_WITH.exec(id);
125
+ if (legacy) return `${legacy[1]}-only`;
126
+ const gnu = GNU.exec(id.replace(/\+$/, ""));
127
+ if (gnu) return `${gnu[1]}-${gnu[2]}-${plus || id.endsWith("+") ? "or-later" : "only"}`;
128
+ return (LICENSES.get(id.toLowerCase()) || id).replace(/\+$/, "");
129
+ }
130
+
131
+ // ── Reading a package's license field ─────────────────────────────────────
132
+
133
+ /** package.json `license` / legacy `licenses: [{ type }]` → an expression string or null. */
134
+ export function licenseField(meta) {
135
+ if (!meta || typeof meta !== "object") return null;
136
+ const one = (v) => (typeof v === "string" ? v : v && typeof v === "object" && typeof v.type === "string" ? v.type : null);
137
+ const l = one(meta.license);
138
+ if (l && l.trim()) return l.trim();
139
+ if (Array.isArray(meta.licenses)) {
140
+ const all = meta.licenses.map(one).filter(Boolean);
141
+ if (all.length) return all.length === 1 ? all[0] : `(${all.join(" OR ")})`;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * Classify a raw license string.
148
+ * @returns {{ kind: "spdx"|"unlicensed"|"missing"|"custom"|"unknown", raw, expression?, tree? }}
149
+ * unknown: not valid SPDX, or names an id the SPDX list does not have
150
+ */
151
+ export function classifyLicense(raw) {
152
+ const s = typeof raw === "string" ? raw.trim() : "";
153
+ if (!s || /^(NONE|NOASSERTION)$/i.test(s)) return { kind: "missing", raw: s || null };
154
+ if (/^UNLICEN[SC]ED$/i.test(s)) return { kind: "unlicensed", raw: s };
155
+ if (/^SEE LICEN[SC]E IN\b/i.test(s)) return { kind: "custom", raw: s };
156
+ let tree;
157
+ try {
158
+ tree = parseSpdx(s);
159
+ } catch (e) {
160
+ return { kind: "unknown", raw: s, error: e.message };
161
+ }
162
+ const expression = formatSpdx(tree);
163
+ const unknownIds = leaves(tree).filter((l) => !l.known).map((l) => l.id + (l.exception ? ` WITH ${l.exception}` : ""));
164
+ if (unknownIds.length) return { kind: "unknown", raw: s, expression, tree, unknownIds };
165
+ return { kind: "spdx", raw: s, expression, tree };
166
+ }
167
+
168
+ // ── Presets and policy ────────────────────────────────────────────────────
169
+
170
+ const PERMISSIVE = [
171
+ "MIT", "MIT-0", "ISC", "BSD-2-Clause", "BSD-3-Clause", "0BSD", "Apache-2.0", "Zlib",
172
+ "Unlicense", "CC0-1.0", "BlueOak-1.0.0", "BSL-1.0", "Python-2.0", "PSF-2.0",
173
+ "CC-BY-3.0", "CC-BY-4.0", "Unicode-DFS-2016", "Unicode-3.0", "X11", "WTFPL",
174
+ ];
175
+ const WEAK_COPYLEFT = [
176
+ "LGPL-2.0-only", "LGPL-2.0-or-later", "LGPL-2.1-only", "LGPL-2.1-or-later", "LGPL-3.0-only", "LGPL-3.0-or-later",
177
+ "MPL-1.1", "MPL-2.0", "EPL-1.0", "EPL-2.0", "CDDL-1.0", "CDDL-1.1", "Artistic-2.0", "CC-BY-SA-4.0",
178
+ ];
179
+ const NETWORK_COPYLEFT = ["AGPL-1.0-only", "AGPL-1.0-or-later", "AGPL-3.0-only", "AGPL-3.0-or-later", "SSPL-1.0"];
180
+
181
+ export const LICENSE_PRESETS = Object.freeze({
182
+ permissive: { allow: PERMISSIVE, deny: [], about: "permissive licenses only (MIT, ISC, BSD, Apache-2.0, ...)" },
183
+ "weak-copyleft-ok": { allow: [...PERMISSIVE, ...WEAK_COPYLEFT], deny: [], about: "permissive plus weak copyleft (LGPL, MPL, EPL, CDDL)" },
184
+ "no-agpl": { allow: [], deny: NETWORK_COPYLEFT, about: "anything except AGPL and SSPL (network copyleft)" },
185
+ });
186
+
187
+ const LICENSE_FIELDS = ["preset", "allow", "deny", "allowPackages", "unknown"];
188
+
189
+ /** Validate and complete a `licenses` policy section. Throws on bad input. */
190
+ export function normalizeLicensePolicy(raw, label = "licenses") {
191
+ if (typeof raw === "string") raw = { preset: raw };
192
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${label}: expected an object or a preset name`);
193
+ const extra = Object.keys(raw).filter((k) => !LICENSE_FIELDS.includes(k));
194
+ if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected ${LICENSE_FIELDS.join(", ")})`);
195
+ const preset = raw.preset ?? null;
196
+ if (preset !== null && !LICENSE_PRESETS[preset]) {
197
+ throw new Error(`${label}: unknown preset "${preset}". Known: ${Object.keys(LICENSE_PRESETS).join(", ")}`);
198
+ }
199
+ const list = (field) => {
200
+ const v = raw[field] ?? [];
201
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string" || !x.trim())) throw new Error(`${label}: "${field}" must be an array of non-empty strings`);
202
+ return v.map((x) => x.trim());
203
+ };
204
+ const unknown = raw.unknown ?? "warn";
205
+ if (!["warn", "block"].includes(unknown)) throw new Error(`${label}: "unknown" must be "warn" or "block"`);
206
+ const base = preset ? LICENSE_PRESETS[preset] : { allow: [], deny: [] };
207
+ const allow = [...new Set([...base.allow, ...list("allow")])];
208
+ const deny = [...new Set([...base.deny, ...list("deny")])];
209
+ if (!allow.length && !deny.length) throw new Error(`${label}: give a preset, an allow list or a deny list`);
210
+ return { preset, allow, deny, allowPackages: list("allowPackages"), unknown };
211
+ }
212
+
213
+ const globRe = (pattern) => new RegExp(`^${pattern.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
214
+
215
+ /** Does one policy entry ("MIT", "GPL-3.0", "AGPL-*", "GPL-2.0-only WITH Classpath-exception-2.0") match a leaf? */
216
+ function entryMatches(entry, leaf) {
217
+ const id = canonicalId(leaf.id, leaf.plus);
218
+ const [e, ex] = entry.split(/\s+WITH\s+/i);
219
+ if (ex && (!leaf.exception || leaf.exception.toLowerCase() !== ex.toLowerCase())) return false;
220
+ if (e.includes("*")) return globRe(e).test(id) || globRe(e).test(leaf.id);
221
+ return canonicalId(e).toLowerCase() === id.toLowerCase();
222
+ }
223
+
224
+ /** Is a package exempt via allowPackages (name globs, optional @version)? */
225
+ export function packageExempt(allowPackages, name, version) {
226
+ return allowPackages.find((entry) => {
227
+ const at = entry.lastIndexOf("@");
228
+ const [n, v] = at > 0 ? [entry.slice(0, at), entry.slice(at + 1)] : [entry, null];
229
+ return globRe(n).test(name) && (!v || v === "*" || v === version);
230
+ }) || null;
231
+ }
232
+
233
+ /**
234
+ * Check one parsed expression against a policy.
235
+ * @returns {{ ok: boolean, offending: string[] }} offending: the licenses that broke it
236
+ */
237
+ export function evaluateExpression(tree, policy) {
238
+ if (tree.type === "license") {
239
+ const denied = policy.deny.some((e) => entryMatches(e, tree));
240
+ const allowed = !policy.allow.length || policy.allow.some((e) => entryMatches(e, tree));
241
+ return denied || !allowed ? { ok: false, offending: [formatSpdx(tree)], denied } : { ok: true, offending: [] };
242
+ }
243
+ const parts = tree.args.map((a) => evaluateExpression(a, policy));
244
+ if (tree.type === "or") {
245
+ return parts.some((p) => p.ok) ? { ok: true, offending: [] } : { ok: false, offending: [...new Set(parts.flatMap((p) => p.offending))], denied: parts.some((p) => p.denied) };
246
+ }
247
+ const bad = parts.filter((p) => !p.ok);
248
+ return bad.length ? { ok: false, offending: [...new Set(bad.flatMap((p) => p.offending))], denied: bad.some((p) => p.denied) } : { ok: true, offending: [] };
249
+ }
250
+
251
+ // ── Findings ──────────────────────────────────────────────────────────────
252
+
253
+ const policyName = (p) => (p.preset ? `the "${p.preset}" preset` : "the license policy");
254
+
255
+ /** Read a package's license: lockfile field first, else its installed package.json. */
256
+ export function packageLicense(pkg, treeRoot) {
257
+ if (pkg.license) return { raw: pkg.license, from: "lockfile" };
258
+ if (treeRoot) {
259
+ try {
260
+ const meta = JSON.parse(readFileSync(join(treeRoot, pkg.path, "package.json"), "utf-8"));
261
+ return { raw: licenseField(meta), from: "package.json" };
262
+ } catch { /* not installed */ }
263
+ }
264
+ return { raw: null, from: null };
265
+ }
266
+
267
+ /**
268
+ * Apply a license policy to lockfile packages.
269
+ * @param {{ name, version, path, license? }[]} pkgs
270
+ * @param {object} policy from normalizeLicensePolicy
271
+ * @param {{ treeRoot?: string, lockfileHasLicenses?: boolean }} opts
272
+ * @returns {{ findings: object[], summary: object }}
273
+ */
274
+ export function checkLicenses(pkgs, policy, opts = {}) {
275
+ const findings = [];
276
+ const counts = {};
277
+ const summary = { policy: policy.preset || "custom", checked: 0, notChecked: 0, exempt: 0, breaches: 0, unlicensed: 0, missing: 0, unknown: 0, counts };
278
+ const unknownSeverity = policy.unknown === "block" ? "high" : "medium";
279
+ const seen = new Set();
280
+ for (const p of pkgs) {
281
+ const at = `${p.name}@${p.version}`;
282
+ if (seen.has(at)) continue;
283
+ seen.add(at);
284
+ const { raw, from } = packageLicense(p, opts.treeRoot);
285
+ // A v1 lockfile has no license fields: an uninstalled package was simply not checked.
286
+ if (!from && !opts.lockfileHasLicenses) { summary.notChecked++; continue; }
287
+ summary.checked++;
288
+ const c = classifyLicense(raw);
289
+ const shown = c.expression || c.raw || "none";
290
+ counts[shown] = (counts[shown] || 0) + 1;
291
+ const exemptBy = packageExempt(policy.allowPackages, p.name, p.version);
292
+ if (exemptBy) { summary.exempt++; continue; }
293
+
294
+ const base = { category: "license", source: "license-policy", method: "license-policy", url: p.path, package: p.name, version: p.version, license: c.raw };
295
+ const fix = `Replace ${p.name}, or exempt it with "allowPackages": ["${p.name}"] in the licenses section of penthera.policy.json if your legal review accepts it.`;
296
+ if (c.kind === "spdx") {
297
+ const r = evaluateExpression(c.tree, policy);
298
+ if (r.ok) continue;
299
+ summary.breaches++;
300
+ findings.push({
301
+ ...base,
302
+ title: `${at}: license ${c.expression} ${r.denied ? "denied by the license policy" : `not allowed by ${policyName(policy)}`}`,
303
+ severity: "high",
304
+ confidence: "confirmed",
305
+ description: `${p.name} is licensed ${c.expression}; ${r.offending.join(", ")} ${r.denied ? "is on the deny list" : "is not on the allow list"} (${from === "lockfile" ? "license field in the lockfile" : "installed package.json"}). ${fix}`,
306
+ });
307
+ } else if (c.kind === "unlicensed") {
308
+ summary.unlicensed++;
309
+ findings.push({
310
+ ...base,
311
+ title: `${at}: UNLICENSED (no rights granted)`,
312
+ severity: "high",
313
+ confidence: "confirmed",
314
+ description: `${p.name} declares "UNLICENSED": its authors grant no right to use, copy or ship it. This is normal for private packages and a problem for anything else. ${fix}`,
315
+ });
316
+ } else {
317
+ summary[c.kind === "missing" ? "missing" : "unknown"]++;
318
+ const what = c.kind === "missing" ? "no license" : c.kind === "custom" ? `a custom license (${c.raw})` : `a license that is not valid SPDX (${c.raw}${c.unknownIds ? `: unknown ${c.unknownIds.join(", ")}` : ""})`;
319
+ findings.push({
320
+ ...base,
321
+ title: `${at}: ${c.kind === "missing" ? "no license declared" : c.kind === "custom" ? "custom license" : `unknown license ${c.raw}`}`,
322
+ severity: unknownSeverity,
323
+ confidence: "likely",
324
+ description: `${p.name} declares ${what}, so the policy cannot be checked automatically. Read its license file before shipping it. ${fix}`,
325
+ });
326
+ }
327
+ }
328
+ return { findings, summary };
329
+ }
@@ -0,0 +1,109 @@
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, license }[]}
22
+ * license: the lockfile's license field (v2/v3 only; v1 lockfiles have none)
23
+ */
24
+ export function parseLockfile(lock) {
25
+ const out = [];
26
+ if (lock && lock.packages) {
27
+ for (const [key, v] of Object.entries(lock.packages)) {
28
+ if (key === "" || !v) continue;
29
+ // Workspace members appear twice: a link entry under node_modules and the
30
+ // real folder. Keep the node_modules view, skip the source folder entry.
31
+ if (!key.includes("node_modules/")) continue;
32
+ const name = v.name || nameFromLockPath(key);
33
+ out.push({
34
+ name,
35
+ version: v.version || null,
36
+ path: key,
37
+ integrity: v.integrity || null,
38
+ resolved: v.resolved || null,
39
+ dev: !!v.dev,
40
+ optional: !!(v.optional || v.devOptional),
41
+ hasInstallScript: !!v.hasInstallScript,
42
+ link: !!v.link,
43
+ license: typeof v.license === "string" ? v.license : null,
44
+ });
45
+ }
46
+ } else if (lock && lock.dependencies) {
47
+ const walk = (deps, prefix) => {
48
+ for (const [name, v] of Object.entries(deps)) {
49
+ if (!v) continue;
50
+ const path = `${prefix}node_modules/${name}`;
51
+ out.push({
52
+ name,
53
+ version: v.version || null,
54
+ path,
55
+ integrity: v.integrity || null,
56
+ resolved: v.resolved || null,
57
+ dev: !!v.dev,
58
+ optional: !!v.optional,
59
+ hasInstallScript: false,
60
+ link: String(v.version || "").startsWith("file:"),
61
+ license: null,
62
+ });
63
+ if (v.dependencies) walk(v.dependencies, `${path}/`);
64
+ }
65
+ };
66
+ walk(lock.dependencies, "");
67
+ }
68
+ return out.sort((a, b) => a.path.localeCompare(b.path));
69
+ }
70
+
71
+ /** Direct dependency names declared by the root project (from the lockfile root entry). */
72
+ export function directDependencies(lock) {
73
+ const root = lock?.packages?.[""] || {};
74
+ const names = new Set([
75
+ ...Object.keys(root.dependencies || {}),
76
+ ...Object.keys(root.devDependencies || {}),
77
+ ...Object.keys(root.optionalDependencies || {}),
78
+ ]);
79
+ return [...names].sort();
80
+ }
81
+
82
+ /** Read and parse a lockfile from disk. */
83
+ export function readLockfile(path) {
84
+ return JSON.parse(readFileSync(path, "utf-8"));
85
+ }
86
+
87
+ /** Locate package-lock.json (or npm-shrinkwrap.json) in a project directory. */
88
+ export function findLockfile(dir) {
89
+ for (const f of ["package-lock.json", "npm-shrinkwrap.json"]) {
90
+ const p = join(dir, f);
91
+ if (existsSync(p)) return p;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Read the lockfile as it was at a git ref (for `--base <ref>`).
98
+ * Only runs `git show`; no fetch, no checkout.
99
+ */
100
+ export function readLockfileAtRef(dir, ref) {
101
+ const git = (args) => execFileSync("git", args, { cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 });
102
+ const prefix = git(["rev-parse", "--show-prefix"]).trim();
103
+ for (const f of ["package-lock.json", "npm-shrinkwrap.json"]) {
104
+ try {
105
+ return JSON.parse(git(["show", `${ref}:${prefix}${f}`]));
106
+ } catch { /* try the next name */ }
107
+ }
108
+ throw new Error(`No package-lock.json at ${ref} in ${dir}`);
109
+ }
@@ -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
+ }