penthera 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +148 -575
  3. package/bin/scan.js +19 -0
  4. package/docs/commands/README.md +23 -0
  5. package/docs/commands/action.md +21 -0
  6. package/docs/commands/agents.md +38 -0
  7. package/docs/commands/deps.md +115 -0
  8. package/docs/commands/fix.md +53 -0
  9. package/docs/commands/hook.md +69 -0
  10. package/docs/commands/install.md +53 -0
  11. package/docs/commands/scan.md +100 -0
  12. package/docs/commands/skill.md +58 -0
  13. package/docs/compare/README.md +25 -0
  14. package/docs/compare/github.md +60 -0
  15. package/docs/compare/semgrep.md +54 -0
  16. package/docs/compare/snyk.md +62 -0
  17. package/docs/compare/socket.md +78 -0
  18. package/docs/compare/zap.md +59 -0
  19. package/docs/compliance-cra.md +141 -0
  20. package/docs/compliance-nis2.md +3 -1
  21. package/docs/dashboard.md +244 -0
  22. package/docs/evidence.md +8 -4
  23. package/docs/examples/github-actions-deps.yml +4 -4
  24. package/docs/examples/github-actions-scan.yml +4 -4
  25. package/docs/index.md +11 -2
  26. package/docs/internal-audit.md +83 -0
  27. package/docs/marketing.md +151 -0
  28. package/docs/plan.md +101 -0
  29. package/docs/privacy.md +38 -0
  30. package/docs/references.bib +10 -0
  31. package/docs/references.md +1 -0
  32. package/docs/research/socket-positioning-notes.md +183 -0
  33. package/docs/use-cases/README.md +14 -0
  34. package/docs/use-cases/agencies.md +71 -0
  35. package/docs/use-cases/ai-coding-teams.md +81 -0
  36. package/docs/use-cases/eu-sme-compliance.md +68 -0
  37. package/docs/use-cases/open-source-maintainers.md +75 -0
  38. package/docs/use-cases/solo-founder.md +65 -0
  39. package/lib/blackbox/email-dns.js +5 -0
  40. package/lib/compliance/cra.js +525 -0
  41. package/lib/compliance/questionnaire.js +4 -2
  42. package/lib/deps/capabilities.js +49 -12
  43. package/lib/deps/index.js +314 -30
  44. package/lib/deps/license.js +329 -0
  45. package/lib/deps/lockfile.js +29 -3
  46. package/lib/deps/popular-pypi.json +1 -0
  47. package/lib/deps/python/capabilities.js +447 -0
  48. package/lib/deps/python/index.js +234 -0
  49. package/lib/deps/python/lockfiles.js +359 -0
  50. package/lib/deps/python/source.js +396 -0
  51. package/lib/deps/python/toml.js +219 -0
  52. package/lib/deps/report.js +54 -6
  53. package/lib/deps/source.js +149 -24
  54. package/lib/deps/spdx.json +1 -0
  55. package/lib/deps/typosquat.js +24 -13
  56. package/lib/fix/apply.js +83 -19
  57. package/lib/hook/index.js +345 -0
  58. package/lib/hook/policy.js +62 -0
  59. package/lib/install/index.js +59 -18
  60. package/lib/install/policy.js +27 -7
  61. package/lib/jurisdictions/nl.json +9 -0
  62. package/lib/memory/repo-suppress.js +11 -3
  63. package/lib/references.json +77 -0
  64. package/lib/remediation/index.js +9 -0
  65. package/lib/retirejs.js +69 -17
  66. package/lib/rules/regex-guard.js +368 -0
  67. package/lib/templates.js +13 -6
  68. package/lib/tls.js +7 -0
  69. package/lib/whitebox/aibom.js +384 -0
  70. package/lib/whitebox/frameworks.js +8 -5
  71. package/lib/whitebox/sbom.js +45 -10
  72. package/lib/whitebox/secrets.js +31 -8
  73. package/package.json +8 -3
  74. package/skills/penthera/SKILL.md +55 -8
  75. package/src/cli/deps.js +81 -6
  76. package/src/cli/evidence.js +14 -4
  77. package/src/cli/hook.js +131 -0
  78. package/src/cli/install.js +14 -5
  79. package/src/cli/report-outputs.js +25 -2
  80. package/src/cli/ui.js +89 -0
  81. package/src/cli.js +81 -11
  82. package/src/report/audience.js +10 -10
  83. package/src/report/evidence.js +33 -14
  84. package/src/report/footer.js +11 -0
  85. package/src/report/markdown.js +6 -3
  86. package/src/report/md-escape.js +16 -0
  87. package/src/reporter.js +1 -0
  88. package/src/scan-url.js +5 -1
  89. package/src/ui/api/data.js +309 -0
  90. package/src/ui/api/demo.js +464 -0
  91. package/src/ui/api/routes.js +134 -0
  92. package/src/ui/api/runs.js +243 -0
  93. package/src/ui/api/summarize.js +451 -0
  94. package/src/ui/security.js +153 -0
  95. package/src/ui/server.js +186 -0
  96. package/src/ui/static/app.css +723 -0
  97. package/src/ui/static/favicon.svg +1 -0
  98. package/src/ui/static/index.html +57 -0
  99. package/src/ui/static/js/api.js +29 -0
  100. package/src/ui/static/js/app.js +138 -0
  101. package/src/ui/static/js/charts.js +207 -0
  102. package/src/ui/static/js/copy.js +79 -0
  103. package/src/ui/static/js/dom.js +120 -0
  104. package/src/ui/static/js/format.js +42 -0
  105. package/src/ui/static/js/theme.js +10 -0
  106. package/src/ui/static/js/typer.js +99 -0
  107. package/src/ui/static/js/views/ai.js +68 -0
  108. package/src/ui/static/js/views/deps.js +96 -0
  109. package/src/ui/static/js/views/evidence.js +115 -0
  110. package/src/ui/static/js/views/findings.js +165 -0
  111. package/src/ui/static/js/views/history.js +92 -0
  112. package/src/ui/static/js/views/overview.js +184 -0
  113. package/src/ui/static/js/views/run.js +349 -0
  114. package/src/ui/static/js/views/settings.js +67 -0
@@ -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
+ }
@@ -10,6 +10,8 @@
10
10
  * not a certification, and the owner must review every answer before sending.
11
11
  */
12
12
 
13
+ import { mdInline } from "../../src/report/md-escape.js";
14
+
13
15
  // Keyword -> NIS2 measure. First match wins; order specific before general.
14
16
  const RULES = [
15
17
  { re: /\b(mfa|multi.?factor|two.?factor|2fa)\b/i, measure: "j" },
@@ -99,14 +101,14 @@ export function formatQuestionnaireMarkdown(result) {
99
101
  const basis = a.measure
100
102
  ? `(${a.measure}) ${a.status || "?"}${a.evidence?.length ? `: ${a.evidence.slice(0, 3).join(", ")}` : ""}`
101
103
  : "unmapped";
102
- L.push(`| ${a.question.replace(/\|/g, "\\|")} | ${a.answer} | ${basis.replace(/\|/g, "\\|")} |`);
104
+ L.push(`| ${mdInline(a.question)} | ${mdInline(a.answer)} | ${mdInline(basis)} |`);
103
105
  }
104
106
  L.push("");
105
107
  const gaps = result.answers.filter((a) => a.answer === "NEEDS HUMAN INPUT").map((a) => a.question);
106
108
  if (gaps.length) {
107
109
  L.push("## Needs human input");
108
110
  L.push("");
109
- gaps.forEach((q) => L.push(`- ${q}`));
111
+ gaps.forEach((q) => L.push(`- ${mdInline(q)}`));
110
112
  L.push("");
111
113
  }
112
114
  return L.join("\n");