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.
- package/CHANGELOG.md +57 -0
- package/README.md +141 -33
- package/docs/compliance-cra.md +141 -0
- package/docs/compliance-nis2.md +3 -1
- package/docs/examples/github-actions-scan.yml +1 -1
- package/docs/index.md +2 -2
- package/docs/plan.md +100 -0
- package/lib/compliance/cra.js +525 -0
- package/lib/deps/index.js +32 -8
- package/lib/deps/license.js +329 -0
- package/lib/deps/lockfile.js +4 -1
- package/lib/deps/report.js +17 -0
- package/lib/deps/spdx.json +1 -0
- package/lib/hook/index.js +312 -0
- package/lib/hook/policy.js +62 -0
- package/lib/install/policy.js +10 -2
- package/lib/jurisdictions/nl.json +9 -0
- package/lib/whitebox/aibom.js +384 -0
- package/lib/whitebox/sbom.js +45 -10
- package/lib/whitebox/secrets.js +11 -1
- package/package.json +3 -2
- package/skills/penthera/SKILL.md +33 -4
- package/src/cli/deps.js +22 -2
- package/src/cli/hook.js +131 -0
- package/src/cli.js +64 -6
|
@@ -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
|
|
290
|
-
*
|
|
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
|
|
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
|
-
|
|
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,
|