penthera 1.4.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.
@@ -0,0 +1,525 @@
1
+ /**
2
+ * CRA Article 14 reporting helper (Cyber Resilience Act, Regulation (EU) 2024/2847).
3
+ *
4
+ * Given the facts of an actively exploited vulnerability or a severe incident
5
+ * affecting a product with digital elements, lay out the reporting timeline
6
+ * with real due dates, flag what is overdue, and draft each report against the
7
+ * content Article 14 requires. Fields the user hasn't provided are marked
8
+ * NEEDS INPUT, never invented. Optionally prefills the affected component from
9
+ * a Penthera SBOM and scan JSON.
10
+ *
11
+ * Offline. Drafts are starting points for the ENISA Single Reporting Platform,
12
+ * not the platform's official form and not legal advice.
13
+ *
14
+ * Sources (checked 2026-10-19):
15
+ * - Regulation (EU) 2024/2847, Art. 14 (reporting), Art. 69(3) (applies to
16
+ * products placed on the market before 11 Dec 2027), Art. 71(2) (Art. 14
17
+ * applies from 11 Sept 2026):
18
+ * https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng
19
+ * - ENISA CRA Single Reporting Platform and its FAQ (deadlines, one notification
20
+ * per vulnerability/incident, CSIRT designated as coordinator):
21
+ * https://www.enisa.europa.eu/cra-srp/
22
+ * https://www.enisa.europa.eu/topics/product-security/vulnerability-services/eu-incident-response-and-cyber-crisis-management/single-reporting-platform-srp/frequently-asked-questions
23
+ * - Regulation (EEC, Euratom) No 1182/71 on periods, dates and time limits
24
+ * (how EU periods in hours, days and months are counted):
25
+ * https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:31971R1182
26
+ *
27
+ * Deadline arithmetic is deliberately strict: hours are elapsed hours from
28
+ * `aware_at`; days and months are calendar periods in the manufacturer's time
29
+ * zone, ending at the same wall-clock time. Reg. 1182/71 can give a little more
30
+ * (the period runs to the end of the hour/day, and a days/months period ending
31
+ * on a weekend or holiday moves to the next working day); don't plan on it.
32
+ */
33
+ import { readFileSync } from "node:fs";
34
+ import { dirname, resolve } from "node:path";
35
+
36
+ /** Art. 14 applies from this date (Art. 71(2)); checked as the start of that day in Brussels time. */
37
+ export const CRA_ART14_APPLIES_FROM = "2026-09-11";
38
+
39
+ export const CRA_SOURCES = {
40
+ regulation: "https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng",
41
+ srp: "https://portal.cra-srp.enisa.europa.eu/",
42
+ srpGuidance: "https://www.enisa.europa.eu/cra-srp/",
43
+ csirtList: "https://www.enisa.europa.eu/topics/product-security/vulnerability-services/eu-incident-response-and-cyber-crisis-management/single-reporting-platform-srp/list-of-csirts-designated-as-coordinators",
44
+ periods: "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:31971R1182",
45
+ };
46
+
47
+ const TYPES = {
48
+ vulnerability: "Actively exploited vulnerability",
49
+ incident: "Severe incident having an impact on the security of the product",
50
+ };
51
+
52
+ /** Fields collected about a CRA event (all optional; blanks become NEEDS INPUT). */
53
+ export function craTemplate() {
54
+ return {
55
+ _about: "Penthera CRA Article 14 reporting helper. Set `type` to \"vulnerability\" (actively exploited vulnerability) or \"incident\" (severe incident). `aware_at` is when you became aware (ISO 8601, e.g. 2026-10-19T09:30:00+02:00); it drives the deadlines. A timestamp without an offset is read in `timezone`. Fill in what you know, leave the rest blank. Offline; nothing is sent anywhere.",
56
+ type: "vulnerability",
57
+ title: "",
58
+ aware_at: "",
59
+ timezone: "Europe/Amsterdam",
60
+ main_establishment: "NL",
61
+ contact: "",
62
+ product: { name: "", version: "", purl: "" },
63
+ component: { name: "", version: "", purl: "" },
64
+ member_states: "",
65
+ severity: "",
66
+ impact: "",
67
+ sensitivity: "",
68
+ corrective_measures_taken: "",
69
+ user_mitigations: "",
70
+ vulnerability_id: "",
71
+ vulnerability_nature: "",
72
+ exploit_nature: "",
73
+ malicious_actor: "",
74
+ corrective_measure_available_at: "",
75
+ security_update: "",
76
+ unlawful_or_malicious: "",
77
+ incident_nature: "",
78
+ initial_assessment: "",
79
+ root_cause: "",
80
+ mitigations_applied: "",
81
+ users_informed_at: "",
82
+ user_advisory_url: "",
83
+ submitted: { early_warning: "", notification: "", final_report: "" },
84
+ prefill: { scan: "", sbom: "" },
85
+ };
86
+ }
87
+
88
+ // ── Time zone arithmetic (no dependencies; Intl only) ──────────────────────
89
+
90
+ const pad = (n, w = 2) => String(n).padStart(w, "0");
91
+
92
+ export function isValidTimeZone(tz) {
93
+ if (!tz) return false;
94
+ try { new Intl.DateTimeFormat("en-US", { timeZone: tz }); return true; } catch { return false; }
95
+ }
96
+
97
+ const fmtCache = new Map();
98
+ function wallParts(ms, tz) {
99
+ let f = fmtCache.get(tz);
100
+ if (!f) {
101
+ f = new Intl.DateTimeFormat("en-US", {
102
+ timeZone: tz, hourCycle: "h23",
103
+ year: "numeric", month: "2-digit", day: "2-digit",
104
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
105
+ });
106
+ fmtCache.set(tz, f);
107
+ }
108
+ const p = Object.fromEntries(f.formatToParts(new Date(ms)).map((x) => [x.type, x.value]));
109
+ return { y: +p.year, mo: +p.month, d: +p.day, h: +p.hour, mi: +p.minute, s: +p.second, ms: ((ms % 1000) + 1000) % 1000 };
110
+ }
111
+
112
+ /** UTC offset of `tz` at instant `ms`, in milliseconds (positive east of UTC). */
113
+ function offsetAt(ms, tz) {
114
+ const w = wallParts(ms, tz);
115
+ return Date.UTC(w.y, w.mo - 1, w.d, w.h, w.mi, w.s, w.ms) - ms;
116
+ }
117
+
118
+ /**
119
+ * Wall-clock time in `tz` → UTC instant. An ambiguous time (DST fall-back)
120
+ * resolves to the earlier instant; a time that doesn't exist (spring-forward
121
+ * gap) moves forward past the gap, like Temporal's "compatible" mode.
122
+ */
123
+ export function wallTimeToInstant({ y, mo, d, h = 0, mi = 0, s = 0, ms = 0 }, tz) {
124
+ const guess = Date.UTC(y, mo - 1, d, h, mi, s, ms);
125
+ const day = 24 * 3600 * 1000;
126
+ const candidates = [...new Set([guess - offsetAt(guess - day, tz), guess - offsetAt(guess + day, tz)])];
127
+ const valid = candidates.filter((t) => offsetAt(t, tz) + t === guess);
128
+ if (valid.length) return Math.min(...valid);
129
+ return Math.max(...candidates);
130
+ }
131
+
132
+ /** Add calendar days/months in `tz`, keeping the wall-clock time; clamps to month end (Reg. 1182/71 Art. 3(2)(c)). */
133
+ export function addCalendar(ms, tz, { days = 0, months = 0 } = {}) {
134
+ const w = wallParts(ms, tz);
135
+ let { y, mo, d } = w;
136
+ if (months) {
137
+ const idx = y * 12 + (mo - 1) + months;
138
+ y = Math.floor(idx / 12);
139
+ mo = (idx % 12) + 1;
140
+ const last = new Date(Date.UTC(y, mo, 0)).getUTCDate();
141
+ if (d > last) d = last;
142
+ }
143
+ if (days) {
144
+ const t = new Date(Date.UTC(y, mo - 1, d + days));
145
+ y = t.getUTCFullYear(); mo = t.getUTCMonth() + 1; d = t.getUTCDate();
146
+ }
147
+ return wallTimeToInstant({ y, mo, d, h: w.h, mi: w.mi, s: w.s, ms: w.ms }, tz);
148
+ }
149
+
150
+ /**
151
+ * Parse an ISO 8601 timestamp. With an offset or Z it is an instant; without
152
+ * one (or date only) it is wall-clock time in `tz`. Returns ms or null.
153
+ */
154
+ export function parseInstant(value, tz) {
155
+ if (!value || !String(value).trim()) return null;
156
+ const s = String(value).trim();
157
+ const m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3})\d*)?)?)?$/);
158
+ if (m) {
159
+ const [, y, mo, d, h = "0", mi = "0", sec = "0", frac = "0"] = m;
160
+ if (+mo < 1 || +mo > 12 || +d < 1 || +d > 31) return null;
161
+ return wallTimeToInstant({ y: +y, mo: +mo, d: +d, h: +h, mi: +mi, s: +sec, ms: +frac.padEnd(3, "0") }, tz);
162
+ }
163
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(s)) return null; // only ISO 8601, not whatever Date.parse guesses
164
+ const t = Date.parse(s);
165
+ return Number.isNaN(t) ? null : t;
166
+ }
167
+
168
+ /** "2026-10-27 09:00 +01:00" in `tz`. */
169
+ export function formatLocal(ms, tz) {
170
+ const w = wallParts(ms, tz);
171
+ const off = Math.round(offsetAt(ms, tz) / 60000);
172
+ const sign = off < 0 ? "-" : "+";
173
+ const a = Math.abs(off);
174
+ return `${w.y}-${pad(w.mo)}-${pad(w.d)} ${pad(w.h)}:${pad(w.mi)} ${sign}${pad(Math.floor(a / 60))}:${pad(a % 60)}`;
175
+ }
176
+
177
+ function humanSpan(ms) {
178
+ const h = Math.floor(Math.abs(ms) / 3600000);
179
+ if (h >= 48) return `${Math.floor(h / 24)}d ${h % 24}h`;
180
+ const mi = Math.floor((Math.abs(ms) % 3600000) / 60000);
181
+ return `${h}h ${pad(mi)}m`;
182
+ }
183
+
184
+ // ── Prefill from Penthera artifacts ────────────────────────────────────────
185
+
186
+ const str = (x) => (x == null ? "" : String(x).trim());
187
+
188
+ /**
189
+ * Fill product/component details from a CycloneDX SBOM and a Penthera scan
190
+ * result, without overwriting anything the user wrote. Returns
191
+ * { cra, prefilled: [{ field, value, from }] }.
192
+ */
193
+ export function prefillCra(cra = {}, { sbom = null, scan = null } = {}) {
194
+ const out = structuredClone(cra);
195
+ out.product = { ...(out.product || {}) };
196
+ out.component = { ...(out.component || {}) };
197
+ const prefilled = [];
198
+ const set = (obj, key, field, value, from) => {
199
+ if (!str(obj[key]) && str(value)) { obj[key] = String(value); prefilled.push({ field, value: String(value), from }); }
200
+ };
201
+
202
+ if (sbom && typeof sbom === "object") {
203
+ const root = sbom.metadata?.component || {};
204
+ set(out.product, "name", "product.name", root.name, "SBOM metadata.component");
205
+ set(out.product, "version", "product.version", root.version, "SBOM metadata.component");
206
+ set(out.product, "purl", "product.purl", root.purl, "SBOM metadata.component");
207
+ const want = str(out.component.name).toLowerCase();
208
+ if (want) {
209
+ const comps = Array.isArray(sbom.components) ? sbom.components : [];
210
+ const pinned = str(out.component.version);
211
+ const hit = comps.find((c) => str(c.name).toLowerCase() === want && (!pinned || str(c.version) === pinned))
212
+ || comps.find((c) => str(c.name).toLowerCase() === want);
213
+ if (hit) {
214
+ set(out.component, "version", "component.version", hit.version, "SBOM components");
215
+ set(out.component, "purl", "component.purl", hit.purl, "SBOM components");
216
+ }
217
+ }
218
+ }
219
+
220
+ if (scan && typeof scan === "object") {
221
+ const findings = Array.isArray(scan.findings) ? scan.findings : [];
222
+ const want = str(out.component.name).toLowerCase();
223
+ const related = want
224
+ ? findings.filter((f) => `${f.title || ""} ${f.description || ""} ${f.package || ""}`.toLowerCase().includes(want))
225
+ : [];
226
+ const cves = [...new Set(related.flatMap((f) => `${f.title || ""} ${f.description || ""} ${(f.cves || []).join(" ")}`.match(/CVE-\d{4}-\d{4,}/g) || []))];
227
+ if (cves.length) set(out, "vulnerability_id", "vulnerability_id", cves.join(", "), "scan findings");
228
+ const order = ["critical", "high", "medium", "low", "info"];
229
+ const sev = related.map((f) => str(f.severity).toLowerCase()).filter((s) => order.includes(s)).sort((a, b) => order.indexOf(a) - order.indexOf(b))[0];
230
+ if (sev) set(out, "severity", "severity", `${sev} (scanner severity; review against observed impact)`, "scan findings");
231
+ if (related.length) {
232
+ set(out, "vulnerability_nature", "vulnerability_nature", related.slice(0, 3).map((f) => f.title).filter(Boolean).join("; "), "scan findings");
233
+ }
234
+ }
235
+ return { cra: out, prefilled };
236
+ }
237
+
238
+ /**
239
+ * Read a cra.json file and apply `prefill.scan` / `prefill.sbom` (paths
240
+ * relative to the cra.json). Returns { cra, prefilled, errors }.
241
+ */
242
+ export function readCraInput(path) {
243
+ const cra = JSON.parse(readFileSync(path, "utf-8"));
244
+ const errors = [];
245
+ const load = (p, what) => {
246
+ if (!str(p)) return null;
247
+ try { return JSON.parse(readFileSync(resolve(dirname(path), p), "utf-8")); } catch (e) {
248
+ errors.push(`Could not read prefill.${what} (${p}): ${e.message}`);
249
+ return null;
250
+ }
251
+ };
252
+ const sbom = load(cra.prefill?.sbom, "sbom");
253
+ const scan = load(cra.prefill?.scan, "scan");
254
+ if (!sbom && !scan) return { cra, prefilled: [], errors };
255
+ const { cra: filled, prefilled } = prefillCra(cra, { sbom, scan });
256
+ return { cra: filled, prefilled, errors };
257
+ }
258
+
259
+ // ── Timeline and drafts ────────────────────────────────────────────────────
260
+
261
+ const ART = "Regulation (EU) 2024/2847";
262
+
263
+ function deadlinesFor(type) {
264
+ if (type === "incident") {
265
+ return [
266
+ { id: "early-warning", title: "Early warning", within: "24 hours", legal: "Art. 14(4)(a)", hours: 24, base: "aware" },
267
+ { id: "notification", title: "Incident notification", within: "72 hours", legal: "Art. 14(4)(b)", hours: 72, base: "aware" },
268
+ { id: "final-report", title: "Final report", within: "1 month after the incident notification", legal: "Art. 14(4)(c)", months: 1, base: "notification" },
269
+ ];
270
+ }
271
+ return [
272
+ { id: "early-warning", title: "Early warning", within: "24 hours", legal: "Art. 14(2)(a)", hours: 24, base: "aware" },
273
+ { id: "notification", title: "Vulnerability notification", within: "72 hours", legal: "Art. 14(2)(b)", hours: 72, base: "aware" },
274
+ { id: "final-report", title: "Final report", within: "14 days after a corrective or mitigating measure is available", legal: "Art. 14(2)(c)", days: 14, base: "fix" },
275
+ ];
276
+ }
277
+
278
+ const SUBMITTED_KEY = { "early-warning": "early_warning", notification: "notification", "final-report": "final_report" };
279
+
280
+ // Which fields each draft surfaces: [field, label] (field may be a function).
281
+ function draftFields(type) {
282
+ const product = (c) => [str(c.product?.name), str(c.product?.version)].filter(Boolean).join(" ") || null;
283
+ const component = (c) => {
284
+ const s = [str(c.component?.name), str(c.component?.version)].filter(Boolean).join(" ");
285
+ if (!s) return null;
286
+ return str(c.component?.purl) ? `${s} (${c.component.purl})` : s;
287
+ };
288
+ const productLines = [
289
+ [product, "Product with digital elements (name and version)"],
290
+ [component, "Affected component (name, version, purl)"],
291
+ ];
292
+ if (type === "incident") {
293
+ return {
294
+ "early-warning": [
295
+ ...productLines,
296
+ ["unlawful_or_malicious", "Suspected of being caused by unlawful or malicious acts"],
297
+ ["member_states", "Member States where the product has been made available"],
298
+ ],
299
+ notification: [
300
+ ...productLines,
301
+ ["incident_nature", "General information about the nature of the incident"],
302
+ ["initial_assessment", "Initial assessment of the incident"],
303
+ ["corrective_measures_taken", "Corrective or mitigating measures taken"],
304
+ ["user_mitigations", "Corrective or mitigating measures users can take"],
305
+ ["sensitivity", "How sensitive you consider the notified information"],
306
+ ],
307
+ "final-report": [
308
+ ...productLines,
309
+ [(c) => [str(c.incident_nature), str(c.severity) && `Severity: ${str(c.severity)}`, str(c.impact) && `Impact: ${str(c.impact)}`].filter(Boolean).join(". ") || null, "Detailed description, including severity and impact"],
310
+ ["root_cause", "Type of threat or root cause that likely triggered the incident"],
311
+ ["mitigations_applied", "Applied and ongoing mitigation measures"],
312
+ ],
313
+ };
314
+ }
315
+ return {
316
+ "early-warning": [
317
+ ...productLines,
318
+ ["vulnerability_id", "Vulnerability identifier (CVE/GHSA, if known)"],
319
+ ["member_states", "Member States where the product has been made available"],
320
+ ],
321
+ notification: [
322
+ ...productLines,
323
+ ["vulnerability_nature", "General nature of the vulnerability"],
324
+ ["exploit_nature", "General nature of the exploit"],
325
+ ["corrective_measures_taken", "Corrective or mitigating measures taken"],
326
+ ["user_mitigations", "Corrective or mitigating measures users can take"],
327
+ ["sensitivity", "How sensitive you consider the notified information"],
328
+ ],
329
+ "final-report": [
330
+ ...productLines,
331
+ [(c) => [str(c.vulnerability_nature), str(c.severity) && `Severity: ${str(c.severity)}`, str(c.impact) && `Impact: ${str(c.impact)}`].filter(Boolean).join(". ") || null, "Description of the vulnerability, including severity and impact"],
332
+ ["malicious_actor", "Information on any malicious actor exploiting it (where available)"],
333
+ ["security_update", "Security update or other corrective measures made available"],
334
+ ],
335
+ };
336
+ }
337
+
338
+ /**
339
+ * Build the CRA reporting timeline and per-deadline drafts.
340
+ * @param {object} cra - fields from craTemplate() (ideally after prefillCra)
341
+ * @param {object} jurisdiction - nl.json (uses `cra`)
342
+ * @param {object} opts - { now, prefilled }
343
+ */
344
+ export function buildCraReports(cra = {}, jurisdiction = {}, opts = {}) {
345
+ const jur = jurisdiction.cra || {};
346
+ const warnings = [];
347
+ const type = cra.type === "incident" ? "incident" : "vulnerability";
348
+ if (cra.type && cra.type !== "incident" && cra.type !== "vulnerability") {
349
+ warnings.push(`Unknown type "${cra.type}"; treating it as "vulnerability". Use "vulnerability" or "incident".`);
350
+ }
351
+
352
+ let tz = str(cra.timezone) || jur.timezone || "UTC";
353
+ if (!isValidTimeZone(tz)) {
354
+ warnings.push(`Unknown time zone "${tz}"; using UTC. Use an IANA name such as Europe/Amsterdam.`);
355
+ tz = "UTC";
356
+ }
357
+
358
+ const aware = parseInstant(cra.aware_at, tz);
359
+ if (!aware) warnings.push("No valid `aware_at` timestamp, so deadlines can't be computed. Set when you became aware (ISO 8601) and re-run.");
360
+ const now = opts.now != null ? parseInstant(opts.now, "UTC") : null;
361
+
362
+ const applies = wallTimeToInstant({ y: 2026, mo: 9, d: 11 }, "Europe/Brussels");
363
+ if (aware && aware < applies) {
364
+ warnings.push(`aware_at is before ${CRA_ART14_APPLIES_FROM}, when Article 14 starts to apply (Art. 71(2)). Reporting may not be mandatory for this event; check with your CSIRT.`);
365
+ }
366
+
367
+ const submittedAt = (id) => parseInstant(cra.submitted?.[SUBMITTED_KEY[id]], tz);
368
+ const fixAt = parseInstant(cra.corrective_measure_available_at, tz);
369
+
370
+ const defs = deadlinesFor(type);
371
+ const due = {};
372
+ const basis = {};
373
+ for (const d of defs) {
374
+ let base = null;
375
+ if (d.base === "aware") { base = aware; basis[d.id] = aware != null ? "from aware_at" : null; }
376
+ else if (d.base === "fix") {
377
+ base = fixAt;
378
+ basis[d.id] = fixAt ? "from corrective_measure_available_at" : "set corrective_measure_available_at once a fix or mitigation is available";
379
+ } else if (d.base === "notification") {
380
+ const sub = submittedAt("notification");
381
+ if (sub) { base = sub; basis[d.id] = "from the notification's submission time"; }
382
+ else if (due.notification) { base = due.notification; basis[d.id] = "assuming the notification is sent at its 72h deadline; set submitted.notification for the exact date"; }
383
+ }
384
+ if (base == null) { due[d.id] = null; continue; }
385
+ due[d.id] = d.hours ? base + d.hours * 3600 * 1000 : addCalendar(base, tz, { days: d.days, months: d.months });
386
+ }
387
+
388
+ const timeline = defs.map((d) => {
389
+ const dueBy = due[d.id];
390
+ const sub = submittedAt(d.id);
391
+ let status = "unknown";
392
+ if (sub) status = dueBy != null && sub > dueBy ? "submitted late" : "submitted";
393
+ else if (dueBy == null) status = d.base === "fix" ? "waiting for a fix" : "set aware_at";
394
+ else if (now != null) {
395
+ status = now > dueBy ? "OVERDUE" : `due in ${humanSpan(dueBy - now)}`;
396
+ }
397
+ return {
398
+ id: d.id,
399
+ title: d.title,
400
+ within: d.within,
401
+ legal: d.legal,
402
+ dueBy: dueBy != null ? new Date(dueBy).toISOString() : null,
403
+ dueLocal: dueBy != null ? formatLocal(dueBy, tz) : null,
404
+ basis: basis[d.id] || null,
405
+ submittedAt: sub != null ? new Date(sub).toISOString() : null,
406
+ status,
407
+ overdue: status === "OVERDUE",
408
+ };
409
+ });
410
+
411
+ const fields = draftFields(type);
412
+ const prefilledFrom = Object.fromEntries((opts.prefilled || []).map((p) => [p.field, p.from]));
413
+ const reports = defs.map((d) => ({
414
+ id: d.id,
415
+ title: d.title,
416
+ legal: d.legal,
417
+ dueBy: timeline.find((t) => t.id === d.id).dueBy,
418
+ fields: fields[d.id].map(([field, label]) => {
419
+ const value = typeof field === "function" ? field(cra) : str(cra[field]) || null;
420
+ const src = typeof field === "string" ? prefilledFrom[field]
421
+ : label.startsWith("Affected component") ? prefilledFrom["component.purl"] || prefilledFrom["component.version"]
422
+ : label.startsWith("Product with") ? prefilledFrom["product.name"] : undefined;
423
+ return { label, value, ...(src ? { prefilledFrom: src } : {}) };
424
+ }),
425
+ }));
426
+
427
+ const usersInformed = parseInstant(cra.users_informed_at, tz);
428
+ const users = {
429
+ legal: "Art. 14(8)",
430
+ informedAt: usersInformed != null ? new Date(usersInformed).toISOString() : null,
431
+ status: usersInformed != null ? "informed" : "not yet informed",
432
+ advisoryUrl: str(cra.user_advisory_url) || null,
433
+ };
434
+
435
+ const est = str(cra.main_establishment).toUpperCase();
436
+ if (est && jurisdiction.id && est !== String(jurisdiction.id).toUpperCase()) {
437
+ warnings.push(`main_establishment is ${est}, but this profile is for ${jurisdiction.name || jurisdiction.id}. Report to the CSIRT designated as coordinator in ${est} (ENISA list: ${CRA_SOURCES.csirtList}).`);
438
+ }
439
+
440
+ return {
441
+ type,
442
+ typeLabel: TYPES[type],
443
+ title: str(cra.title) || null,
444
+ timezone: tz,
445
+ awareAt: aware != null ? new Date(aware).toISOString() : null,
446
+ awareLocal: aware != null ? formatLocal(aware, tz) : null,
447
+ contact: str(cra.contact) || null,
448
+ product: { name: str(cra.product?.name) || null, version: str(cra.product?.version) || null, purl: str(cra.product?.purl) || null },
449
+ component: { name: str(cra.component?.name) || null, version: str(cra.component?.version) || null, purl: str(cra.component?.purl) || null },
450
+ authority: {
451
+ coordinator: jur.csirtCoordinator || "the CSIRT designated as coordinator in your main establishment's Member State",
452
+ contact: jur.csirtContact || null,
453
+ srp: jur.srp || CRA_SOURCES.srp,
454
+ note: jur.note || null,
455
+ },
456
+ timeline,
457
+ reports,
458
+ users,
459
+ prefilled: opts.prefilled || [],
460
+ warnings,
461
+ generated_at: now != null ? new Date(now).toISOString() : null,
462
+ };
463
+ }
464
+
465
+ /** Render the timeline and drafts as Markdown. */
466
+ export function formatCraMarkdown(result) {
467
+ if (!result) return "";
468
+ const L = [];
469
+ L.push(`# CRA Article 14 report drafts${result.title ? `, ${result.title}` : ""}`);
470
+ L.push("");
471
+ L.push(`> **Draft reports to help you meet Cyber Resilience Act (${ART}) Article 14 deadlines. Not the ENISA Single Reporting Platform's official form, and not legal advice.** Fields marked NEEDS INPUT must be completed before you submit.`);
472
+ L.push("");
473
+ for (const w of result.warnings) { L.push(`> ⚠️ ${w}`); L.push(""); }
474
+
475
+ L.push(`**Type:** ${result.typeLabel} `);
476
+ L.push(`**Aware at:** ${result.awareAt || "NEEDS INPUT"}${result.awareAt ? ` (${result.awareLocal}, ${result.timezone})` : ""} `);
477
+ L.push(`**Submit via:** the ENISA Single Reporting Platform (${result.authority.srp}), using the end-point of ${result.authority.coordinator}${result.authority.contact ? ` (${result.authority.contact})` : ""}. ENISA receives the notification at the same time (Art. 14(1), 14(3), 14(7)).`);
478
+ if (result.authority.note) L.push(`_${result.authority.note}_`);
479
+ L.push("");
480
+
481
+ L.push("## Reporting timeline");
482
+ L.push("");
483
+ L.push(`| Report | Within | Due by (UTC) | Due by (${result.timezone}) | Status |`);
484
+ L.push("|--------|--------|--------------|------|--------|");
485
+ for (const t of result.timeline) {
486
+ const status = t.overdue ? "⚠️ OVERDUE" : t.status;
487
+ L.push(`| ${t.title} (${t.legal}) | ${t.within} | ${t.dueBy || "n/a"} | ${t.dueLocal || "n/a"} | ${status} |`);
488
+ }
489
+ L.push(`| Inform users (${result.users.legal}) | after becoming aware, in a timely manner | n/a | n/a | ${result.users.status} |`);
490
+ L.push("");
491
+ const notes = result.timeline.filter((t) => t.basis && t.basis !== "from aware_at");
492
+ for (const t of notes) L.push(`- ${t.title}: ${t.basis}.`);
493
+ if (notes.length) L.push("");
494
+ L.push("_Deadlines use the strict reading: hours are elapsed hours, days and months are calendar periods in your time zone ending at the same clock time. Earlier is always fine; one notification may combine stages if the information is already available._");
495
+ L.push("");
496
+
497
+ for (const r of result.reports) {
498
+ L.push(`## Draft: ${r.title} (${r.legal}${r.dueBy ? `, by ${r.dueBy}` : ""})`);
499
+ L.push("");
500
+ for (const f of r.fields) {
501
+ const src = f.prefilledFrom ? ` _(prefilled from ${f.prefilledFrom}; verify)_` : "";
502
+ L.push(`- **${f.label}:** ${f.value || "**NEEDS INPUT**"}${f.value ? src : ""}`);
503
+ }
504
+ if (result.contact) L.push(`- **Contact:** ${result.contact}`);
505
+ L.push("");
506
+ }
507
+
508
+ L.push(`## Draft: user advisory (${result.users.legal})`);
509
+ L.push("");
510
+ L.push("Inform impacted users (and where appropriate all users) of the issue and of the mitigations they can deploy, where appropriate in a structured, machine-readable format (for example a CSAF advisory or a CycloneDX VEX document).");
511
+ L.push("");
512
+ const product = [result.product.name, result.product.version].filter(Boolean).join(" ");
513
+ L.push(`- **Affected product and versions:** ${product || "**NEEDS INPUT**"}`);
514
+ const r = result.reports.find((x) => x.id === "notification");
515
+ const userMit = r?.fields.find((f) => f.label.startsWith("Corrective or mitigating measures users"));
516
+ L.push(`- **What users should do now:** ${userMit?.value || "**NEEDS INPUT**"}`);
517
+ L.push(`- **Published at:** ${result.users.advisoryUrl || "**NEEDS INPUT**"}`);
518
+ L.push("");
519
+
520
+ L.push("---");
521
+ L.push("");
522
+ L.push(`*Generated by Penthera${result.generated_at ? ` at ${result.generated_at}` : ""}. Sources: ${CRA_SOURCES.regulation} (Art. 14), ${CRA_SOURCES.srpGuidance} (ENISA SRP guidance). Complete every NEEDS INPUT field, confirm your CSIRT designated as coordinator, and submit within the deadlines above. Penthera does not certify CRA compliance.*`);
523
+ L.push("");
524
+ return L.join("\n");
525
+ }
package/lib/deps/index.js CHANGED
@@ -19,6 +19,7 @@ import { DirSource, sourceFromNpmCache, npmCacheDir } from "./source.js";
19
19
  import { scanPackage, CAPABILITIES, CAPABILITY_ORDER, maxSeverity } from "./capabilities.js";
20
20
  import { diffTrees } from "./diff.js";
21
21
  import { findTyposquats, loadPopular } from "./typosquat.js";
22
+ import { checkLicenses } from "./license.js";
22
23
 
23
24
  const SOURCE = "deps-scan";
24
25
  const CATEGORY = "dependency-risk";
@@ -267,6 +268,7 @@ function summarize(tree) {
267
268
 
268
269
  /** Load the old tree for --diff (lockfile path or project folder) or --base (git ref). */
269
270
  function loadBase(root, opts) {
271
+ if (opts.baseLock) return { lock: opts.baseLock, treeRoot: null, label: opts.baseLabel || "base" };
270
272
  if (opts.base) {
271
273
  return { lock: readLockfileAtRef(root, opts.base), treeRoot: null, label: opts.base };
272
274
  }
@@ -286,27 +288,37 @@ function loadBase(root, opts) {
286
288
  * @param {string} root project folder containing package-lock.json
287
289
  * @param {{ diff?: string, base?: string, online?: boolean, prod?: boolean,
288
290
  * 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
+ * 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
291
299
  */
292
300
  export async function scanDeps(root, opts = {}) {
293
301
  const started = Date.now();
294
302
  const onPhase = opts.onPhase || (() => {});
295
- const lockPath = findLockfile(root);
303
+ const lockPath = opts.lock ? join(root, "package-lock.json") : findLockfile(root);
296
304
  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);
305
+ const lock = opts.lock || readLockfile(lockPath);
298
306
  const ctx = { cache: new Map(), cacheDirs: [].concat(opts.cacheDir || npmCacheDir()) };
299
307
  const keep = (p) => !p.link && p.version && (!opts.prod || !p.dev);
300
308
 
301
- const pkgs = parseLockfile(lock).filter(keep);
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
+ }
302
316
  onPhase(`Scanning ${pkgs.length} packages from ${basename(lockPath)} (offline)`);
303
317
  const tree = scanTree(pkgs, opts.treeRoot || root, ctx);
304
318
  const direct = new Set(directDependencies(lock));
305
319
 
306
320
  let diff = null;
307
- let base = null;
308
- if (opts.diff || opts.base) {
309
- base = loadBase(root, opts);
321
+ if (diffing) {
310
322
  const oldPkgs = parseLockfile(base.lock).filter(keep);
311
323
  const byVersion = new Map(tree.map((p) => [`${p.name}@${p.version}`, p]));
312
324
  const newNames = new Set(tree.map((p) => p.name));
@@ -363,6 +375,16 @@ export async function scanDeps(root, opts = {}) {
363
375
  const typos = findTyposquats(nameScope);
364
376
  findings.push(...typosquatFindings(typos, new Map(tree.map((p) => [p.name, p])), direct));
365
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
+
366
388
  // Optional packages for other platforms (esbuild-style binaries) are expected to be absent.
367
389
  const unavailable = tree.filter((p) => !p.capabilities && !p.optional);
368
390
  const skippedOptional = tree.filter((p) => !p.capabilities && p.optional).length;
@@ -394,11 +416,13 @@ export async function scanDeps(root, opts = {}) {
394
416
  version: p.version,
395
417
  path: p.path,
396
418
  dev: p.dev,
419
+ license: p.license,
397
420
  direct: direct.has(p.name),
398
421
  source: p.source,
399
422
  capabilities: p.capabilities,
400
423
  })),
401
424
  typosquats: typos,
425
+ licenses,
402
426
  online,
403
427
  diff: diff ? {
404
428
  base: base.label,