@blamejs/core 0.9.49 → 0.10.2

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 (82) hide show
  1. package/CHANGELOG.md +952 -908
  2. package/index.js +25 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +78 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/cli.js +13 -0
  25. package/lib/compliance.js +176 -8
  26. package/lib/crypto-field.js +114 -14
  27. package/lib/crypto.js +216 -20
  28. package/lib/db.js +1 -0
  29. package/lib/guard-graphql.js +37 -0
  30. package/lib/guard-jmap.js +321 -0
  31. package/lib/guard-managesieve-command.js +566 -0
  32. package/lib/guard-pop3-command.js +317 -0
  33. package/lib/guard-regex.js +138 -1
  34. package/lib/guard-smtp-command.js +58 -3
  35. package/lib/guard-xml.js +39 -1
  36. package/lib/mail-agent.js +20 -7
  37. package/lib/mail-arc-sign.js +12 -8
  38. package/lib/mail-auth.js +323 -34
  39. package/lib/mail-crypto-pgp.js +934 -0
  40. package/lib/mail-crypto-smime.js +340 -0
  41. package/lib/mail-crypto.js +108 -0
  42. package/lib/mail-dav.js +1224 -0
  43. package/lib/mail-deploy.js +492 -0
  44. package/lib/mail-dkim.js +431 -26
  45. package/lib/mail-journal.js +435 -0
  46. package/lib/mail-scan.js +502 -0
  47. package/lib/mail-server-imap.js +64 -26
  48. package/lib/mail-server-jmap.js +488 -0
  49. package/lib/mail-server-managesieve.js +853 -0
  50. package/lib/mail-server-mx.js +40 -30
  51. package/lib/mail-server-pop3.js +836 -0
  52. package/lib/mail-server-rate-limit.js +13 -0
  53. package/lib/mail-server-submission.js +70 -24
  54. package/lib/mail-server-tls.js +445 -0
  55. package/lib/mail-sieve.js +557 -0
  56. package/lib/mail-spam-score.js +284 -0
  57. package/lib/mail.js +99 -0
  58. package/lib/metrics.js +80 -3
  59. package/lib/middleware/dpop.js +58 -3
  60. package/lib/middleware/idempotency-key.js +255 -42
  61. package/lib/middleware/protected-resource-metadata.js +114 -2
  62. package/lib/network-dns-resolver.js +33 -0
  63. package/lib/network-tls.js +46 -0
  64. package/lib/otel-export.js +13 -4
  65. package/lib/outbox.js +62 -12
  66. package/lib/pqc-agent.js +13 -5
  67. package/lib/retry.js +23 -9
  68. package/lib/router.js +23 -1
  69. package/lib/safe-ical.js +634 -0
  70. package/lib/safe-icap.js +502 -0
  71. package/lib/safe-mime.js +15 -0
  72. package/lib/safe-sieve.js +684 -0
  73. package/lib/safe-smtp.js +57 -0
  74. package/lib/safe-url.js +37 -0
  75. package/lib/safe-vcard.js +473 -0
  76. package/lib/self-update-standalone-verifier.js +32 -3
  77. package/lib/self-update.js +153 -33
  78. package/lib/vendor/MANIFEST.json +161 -156
  79. package/lib/vendor-data.js +127 -9
  80. package/lib/vex.js +324 -59
  81. package/package.json +1 -1
  82. package/sbom.cdx.json +6 -6
package/lib/vex.js CHANGED
@@ -7,35 +7,38 @@
7
7
  *
8
8
  * @intro
9
9
  * VEX (Vulnerability Exploitability eXchange) statement builder per
10
- * OASIS CSAF 2.1 §4.4. Operators ship a `vex.cdx.json` alongside
11
- * `sbom.cdx.json` declaring per-vulnerability exploitability state
12
- * for the framework's component set. Status vocabulary:
10
+ * OASIS CSAF 2.1 §4.4 (CSAF VEX profile). Operators ship a
11
+ * `vex.cdx.json` alongside `sbom.cdx.json` declaring per-vulnerability
12
+ * exploitability state for the framework's component set. Status
13
+ * vocabulary follows the CSAF VEX profile §4.4 restriction (a strict
14
+ * subset of the full CSAF 2.1 §3.2.3.13 product_status vocabulary):
13
15
  *
14
- * "not_affected" — framework does not include / does not use
15
- * the vulnerable component
16
- * "affected" — framework includes and uses the vulnerable
17
- * component; remediation required
18
16
  * "fixed" — framework included the vulnerable component
19
17
  * previously; the cited version ships the fix
18
+ * "known_affected" — framework includes and uses the vulnerable
19
+ * component; remediation required
20
+ * "known_not_affected" — framework does not include / does not use
21
+ * the vulnerable component
20
22
  * "under_investigation" — disclosure is being evaluated
21
23
  *
22
- * Justifications (when status=not_affected): `component_not_present`,
24
+ * Justifications (when status=known_not_affected): `component_not_present`,
23
25
  * `vulnerable_code_not_present`, `vulnerable_code_not_in_execute_path`,
24
26
  * `vulnerable_code_cannot_be_controlled_by_adversary`,
25
- * `inline_mitigations_already_exist`.
27
+ * `inline_mitigations_already_exist` (CSAF 2.1 §3.2.2.7).
26
28
  *
27
29
  * `b.vex.statement({...})` produces a single VEX vulnerability
28
30
  * record. `b.vex.document({...})` assembles a complete CSAF 2.1
29
- * document with the framework's distributor metadata. `b.vex.serialize`
30
- * round-trips to canonical JSON (RFC 8785 / sorted keys) for
31
- * signing. Output is operator-shippable alongside SBOM.
31
+ * document with the framework's distributor metadata + an
32
+ * auto-emitted `product_tree.full_product_names` resolving every
33
+ * `product_ids` reference used by the statements (CSAF 2.1 §3.1).
34
+ * `b.vex.serialize` round-trips to canonical JSON (RFC 8785 / sorted
35
+ * keys) for signing.
32
36
  *
33
- * Why the framework ships VEX: the exceptd 2026-05-12 gap analysis
34
- * surfaced VEX-CSAF-v2.1 as a 49-gap framework-control gap. The
35
- * framework-side closure is "vendor-supplied VEX statements for
36
- * every disclosed CVE the framework has been audited against."
37
- * Operators consume the framework's VEX to populate their own
38
- * organisational VEX without re-auditing each framework dependency.
37
+ * Why the framework ships VEX: operators consuming the framework's
38
+ * VEX populate their own organisational VEX without re-auditing
39
+ * each framework dependency. Downstream consumers (Dependency-Track,
40
+ * csaf-validator-service, FIRST.org CSAF) reject malformed docs;
41
+ * shipping a spec-conformant doc is the cost of admission.
39
42
  *
40
43
  * @card
41
44
  * OASIS CSAF 2.1 VEX statement + document builder. Operators ship
@@ -54,11 +57,15 @@ var VexError = defineClass("VexError", { alwaysPermanent: true });
54
57
  var CSAF_VERSION = "2.1";
55
58
  var DOCUMENT_CATEGORY_VEX = "csaf_vex";
56
59
 
57
- // CSAF 2.1 §3.2.2.10 product_status vocabulary (relevant subset for VEX).
60
+ // CSAF VEX profile §4.4 product_status vocabulary restricted subset.
61
+ // The full CSAF 2.1 §3.2.3.13 vocabulary includes 8 values
62
+ // (first_affected, first_fixed, last_affected, recommended, etc.); the
63
+ // VEX profile restricts to these four. Spec-conformant VEX validators
64
+ // (csaf-validator-service, FIRST.org CSAF) reject documents whose
65
+ // product_status keys are outside this subset. The framework emits
66
+ // `csaf_vex` documents, so STATUS_VALUES tracks the VEX profile.
58
67
  var STATUS_VALUES = Object.freeze([
59
- "first_affected", "first_fixed", "fixed", "known_affected",
60
- "known_not_affected", "last_affected", "recommended",
61
- "under_investigation",
68
+ "fixed", "known_affected", "known_not_affected", "under_investigation",
62
69
  ]);
63
70
 
64
71
  // CSAF 2.1 §3.2.2.7 — justifications for known_not_affected.
@@ -77,6 +84,38 @@ var JUSTIFICATION_VALUES = Object.freeze([
77
84
  // downstream validation failures against spec-conformant tooling.
78
85
  var TLP_LABELS = Object.freeze(["CLEAR", "GREEN", "AMBER", "AMBER+STRICT", "RED"]);
79
86
 
87
+ // CSAF 2.1 §3.2.1.6 — document.tracking.status enumeration.
88
+ var TRACKING_STATUS_VALUES = Object.freeze(["draft", "interim", "final"]);
89
+
90
+ // CSAF 2.1 §3.2.1.10 — references[].category enumeration. `external`
91
+ // for third-party advisory URLs (NVD, GHSA, vendor pages OTHER than
92
+ // the document publisher); `self` for the publisher's own URLs
93
+ // (advisory hosted on the publisher's domain).
94
+ var REFERENCE_CATEGORY_VALUES = Object.freeze(["external", "self"]);
95
+
96
+ // CSAF 2.1 §3.2.3.7 — notes[].category enumeration. The framework
97
+ // accepts the full enumeration; operators pick per note. Default for
98
+ // `impactStatement` shorthand stays `details` (operator-readable
99
+ // impact / mitigation summary).
100
+ var NOTE_CATEGORY_VALUES = Object.freeze([
101
+ "description", "details", "faq", "general", "legal_disclaimer",
102
+ "other", "summary",
103
+ ]);
104
+
105
+ // CSAF 2.1 §3.2.1.12.1.1.1 — TLP 2.0 boilerplate text required when
106
+ // distribution.tlp.label is RED or AMBER+STRICT (FIRST TLP 2.0). The
107
+ // document MUST carry the canonical distribution prose so consumers
108
+ // know the redistribution constraint. Operators may override via
109
+ // opts.distributionText; the framework's defaults match FIRST TLP 2.0
110
+ // verbatim so a spec-conformant doc is the no-effort default.
111
+ var TLP_DEFAULT_TEXTS = Object.freeze({
112
+ "CLEAR": "TLP:CLEAR information may be distributed without restriction.",
113
+ "GREEN": "TLP:GREEN information may be shared with peers and partner organizations within the community, but not via publicly accessible channels.",
114
+ "AMBER": "TLP:AMBER information may be shared with members of the recipient's organization, and clients or customers who need to know, but only on a need-to-know basis.",
115
+ "AMBER+STRICT": "TLP:AMBER+STRICT information is restricted to the recipient organization only; no onward sharing without explicit permission.",
116
+ "RED": "TLP:RED information is restricted to named individual recipients. No further sharing is permitted.",
117
+ });
118
+
80
119
  /**
81
120
  * @primitive b.vex.statement
82
121
  * @signature b.vex.statement(opts)
@@ -95,24 +134,27 @@ var TLP_LABELS = Object.freeze(["CLEAR", "GREEN", "AMBER", "AMBER+STRICT", "RED"
95
134
  * CVE). A `cweId` alone is NOT a valid CSAF vulnerability identity
96
135
  * (CWE is a weakness classification, not a per-vulnerability id);
97
136
  * supply `ids` alongside `cweId` when issuing a non-CVE statement.
98
- * Also required: `status` (one of STATUS_VALUES), `productIds`
99
- * (array of product identifiers the statement applies to).
137
+ * Also required: `status` (one of STATUS_VALUES — CSAF VEX profile
138
+ * §4.4 subset), `productIds` (array of product identifiers the
139
+ * statement applies to).
100
140
  *
101
141
  * When `status === "known_not_affected"`, `justification` is required
102
142
  * per CSAF 2.1 §3.2.3.13.
103
143
  *
104
144
  * @opts
105
- * cveId: string, // CVE-YYYY-NNNN
106
- * cweId: string, // CWE-NNN (emitted as cwes[0] per CSAF §3.2.3.4)
107
- * ids: object[], // [{ systemName, text }] non-CVE tracking ids
108
- * title: string, // human-readable vulnerability title
109
- * status: string, // one of STATUS_VALUES
110
- * productIds: string[], // CSAF product identifiers
111
- * justification: string, // required when status=known_not_affected
112
- * impactStatement: string, // operator-readable impact / mitigation note
113
- * references: string[], // URIs to advisories / vendor pages
114
- * firstReleased: string, // ISO 8601 timestamp
115
- * lastUpdated: string, // ISO 8601 timestamp
145
+ * cveId: string, // CVE-YYYY-NNNN
146
+ * cweId: string, // CWE-NNN (emitted as cwes[0] per CSAF §3.2.3.4)
147
+ * cweName: string, // human-readable CWE name (e.g. "Cross-site Scripting"); when omitted, cwes[].name is omitted (avoids CWE-ID-as-name antipattern flagged by csaf-validator)
148
+ * ids: object[], // [{ systemName, text }] non-CVE tracking ids
149
+ * title: string, // human-readable vulnerability title
150
+ * status: string, // one of STATUS_VALUES (VEX profile subset)
151
+ * productIds: string[], // CSAF product identifiers
152
+ * justification: string, // required when status=known_not_affected
153
+ * impactStatement: string, // operator-readable impact / mitigation note (shorthand for notes[{category:"details",...}])
154
+ * notes: object[], // [{ category, text, title? }] full CSAF notes channel (CSAF §3.2.3.7)
155
+ * references: array, // [string] or [{ url, summary?, category? }] — CSAF §3.2.3.10
156
+ * firstReleased: string, // ISO 8601 timestamp
157
+ * lastUpdated: string, // ISO 8601 timestamp
116
158
  *
117
159
  * @example
118
160
  * b.vex.statement({
@@ -150,6 +192,7 @@ function statement(opts) {
150
192
  "statement: cweId must match `CWE-NNN` (got '" + opts.cweId + "')");
151
193
  }
152
194
  }
195
+ validateOpts.optionalNonEmptyString(opts.cweName, "statement.cweName", VexError, "vex/bad-cwe-name");
153
196
  if (opts.ids !== undefined) {
154
197
  if (!Array.isArray(opts.ids)) {
155
198
  throw new VexError("vex/bad-ids",
@@ -167,7 +210,8 @@ function statement(opts) {
167
210
  }
168
211
  if (STATUS_VALUES.indexOf(opts.status) === -1) {
169
212
  throw new VexError("vex/bad-status",
170
- "statement: status must be one of " + STATUS_VALUES.join(" / "));
213
+ "statement: status must be one of " + STATUS_VALUES.join(" / ") +
214
+ " (CSAF VEX profile §4.4 subset)");
171
215
  }
172
216
  if (opts.productIds === undefined || opts.productIds === null) {
173
217
  throw new VexError("vex/missing-product-ids",
@@ -187,16 +231,105 @@ function statement(opts) {
187
231
  }
188
232
  }
189
233
 
234
+ // CSAF 2.1 §3.2.3.7 — notes channel. Operators supplying `notes`
235
+ // directly get the full CSAF notes vocabulary (description,
236
+ // details, faq, general, legal_disclaimer, other, summary). The
237
+ // `impactStatement` shorthand stays for the common "single details
238
+ // note" case (back-compat with v0.9.6 callers).
239
+ var compiledNotes = null;
240
+ if (opts.notes !== undefined) {
241
+ if (!Array.isArray(opts.notes)) {
242
+ throw new VexError("vex/bad-notes",
243
+ "statement: notes must be an array of { category, text, title? }");
244
+ }
245
+ compiledNotes = [];
246
+ for (var ni = 0; ni < opts.notes.length; ni++) {
247
+ var n = opts.notes[ni];
248
+ if (!n || typeof n !== "object" ||
249
+ typeof n.text !== "string" || n.text.length === 0 ||
250
+ typeof n.category !== "string" ||
251
+ NOTE_CATEGORY_VALUES.indexOf(n.category) === -1) {
252
+ throw new VexError("vex/bad-notes",
253
+ "statement: notes[" + ni + "] must be { category: one of " +
254
+ NOTE_CATEGORY_VALUES.join("/") + ", text: string, title?: string }");
255
+ }
256
+ var noteOut = { category: n.category, text: n.text };
257
+ if (n.title !== undefined) {
258
+ if (typeof n.title !== "string" || n.title.length === 0) {
259
+ throw new VexError("vex/bad-notes",
260
+ "statement: notes[" + ni + "].title must be a non-empty string when supplied");
261
+ }
262
+ noteOut.title = n.title;
263
+ }
264
+ compiledNotes.push(noteOut);
265
+ }
266
+ }
267
+
268
+ // CSAF 2.1 §3.2.3.10 — references channel. Two operator shapes:
269
+ // - bare string url → { category: "external", url, summary: url }
270
+ // - { url, summary?, category? } → preserved verbatim after validation
271
+ // Operator-supplied category defaults to "external" only when the
272
+ // caller doesn't disambiguate; legitimate "self" references (URL on
273
+ // the publisher's own domain) require operator opt-in to avoid the
274
+ // framework auto-classifying.
275
+ var compiledRefs = null;
276
+ if (opts.references !== undefined) {
277
+ if (!Array.isArray(opts.references)) {
278
+ throw new VexError("vex/bad-references",
279
+ "statement: references must be an array of strings or { url, summary?, category? } objects");
280
+ }
281
+ compiledRefs = [];
282
+ for (var ri = 0; ri < opts.references.length; ri++) {
283
+ var r = opts.references[ri];
284
+ var refUrl;
285
+ var refSummary;
286
+ var refCategory = "external";
287
+ if (typeof r === "string") {
288
+ if (r.length === 0) {
289
+ throw new VexError("vex/bad-references",
290
+ "statement: references[" + ri + "] empty string");
291
+ }
292
+ refUrl = r;
293
+ refSummary = r;
294
+ } else if (r && typeof r === "object" && !Array.isArray(r)) {
295
+ if (typeof r.url !== "string" || r.url.length === 0) {
296
+ throw new VexError("vex/bad-references",
297
+ "statement: references[" + ri + "].url must be a non-empty string");
298
+ }
299
+ refUrl = r.url;
300
+ refSummary = typeof r.summary === "string" && r.summary.length > 0 ? r.summary : refUrl;
301
+ if (r.category !== undefined) {
302
+ if (typeof r.category !== "string" ||
303
+ REFERENCE_CATEGORY_VALUES.indexOf(r.category) === -1) {
304
+ throw new VexError("vex/bad-references",
305
+ "statement: references[" + ri + "].category must be one of " +
306
+ REFERENCE_CATEGORY_VALUES.join(" / "));
307
+ }
308
+ refCategory = r.category;
309
+ }
310
+ } else {
311
+ throw new VexError("vex/bad-references",
312
+ "statement: references[" + ri + "] must be a string url or { url, summary?, category? }");
313
+ }
314
+ compiledRefs.push({ category: refCategory, summary: refSummary, url: refUrl });
315
+ }
316
+ }
317
+
190
318
  var vuln = {};
191
319
  if (opts.cveId) vuln.cve = opts.cveId;
192
- // CSAF 2.1 §3.2.3.4 — cwes is a LIST of { id, name }, not a
193
- // singleton field. The previous shape (`cwe: {...}`) failed
194
- // validation against spec-conformant CSAF tooling.
195
- if (opts.cweId) vuln.cwes = [{ id: opts.cweId, name: opts.cweId }];
320
+ // CSAF 2.1 §3.2.3.4 — cwes is a LIST of { id, name }. The `name`
321
+ // field is the human-readable weakness title (e.g. "Cross-site
322
+ // Scripting" for CWE-79). Using the CWE-ID as the name is the
323
+ // antipattern csaf-validator-service flags; we now omit `name`
324
+ // when the operator hasn't supplied `cweName`. CSAF schema treats
325
+ // `name` as optional within the cwes[] array entry.
326
+ if (opts.cweId) {
327
+ var cweEntry = { id: opts.cweId };
328
+ if (opts.cweName) cweEntry.name = opts.cweName;
329
+ vuln.cwes = [cweEntry];
330
+ }
196
331
  if (hasIds) {
197
- vuln.ids = opts.ids.map(function (entry) {
198
- return { system_name: entry.systemName, text: entry.text };
199
- });
332
+ vuln.ids = opts.ids.map(_toCsafId);
200
333
  }
201
334
  if (opts.title) vuln.title = opts.title;
202
335
  vuln.product_status = {};
@@ -208,23 +341,89 @@ function statement(opts) {
208
341
  product_ids: opts.productIds.slice(),
209
342
  }];
210
343
  }
344
+ // Merge impactStatement shorthand + explicit notes[]. The shorthand
345
+ // prepends a single `{category:"details",title:"Impact",text:...}`
346
+ // entry to preserve v0.9.6 caller ordering; operators wanting full
347
+ // control supply `notes` directly.
348
+ var allNotes = [];
211
349
  if (opts.impactStatement) {
212
- vuln.notes = [{
350
+ allNotes.push({
213
351
  category: "details",
214
352
  text: opts.impactStatement,
215
353
  title: "Impact",
216
- }];
217
- }
218
- if (Array.isArray(opts.references) && opts.references.length > 0) {
219
- vuln.references = opts.references.map(function (url) {
220
- return { summary: "Advisory reference", url: url, category: "external" };
221
354
  });
222
355
  }
356
+ if (compiledNotes) allNotes = allNotes.concat(compiledNotes);
357
+ if (allNotes.length > 0) vuln.notes = allNotes;
358
+ if (compiledRefs) vuln.references = compiledRefs;
223
359
  if (opts.firstReleased) vuln.first_released = opts.firstReleased;
224
360
  if (opts.lastUpdated) vuln.last_updated = opts.lastUpdated;
225
361
  return vuln;
226
362
  }
227
363
 
364
+ // _toCsafId — converts the operator-facing camelCase ids[] entries
365
+ // (`{ systemName, text }`) to the snake_case shape CSAF 2.1 §3.2.3.5
366
+ // requires (`{ system_name, text }`). One-liner, but lifted to a
367
+ // named helper so the camelCase→snake_case conversion is grepable and
368
+ // documented in one place (no other field on the statement output
369
+ // switches case — this is the only one).
370
+ function _toCsafId(entry) {
371
+ return { system_name: entry.systemName, text: entry.text };
372
+ }
373
+
374
+ // _collectProductIds — walks every supplied statement and harvests
375
+ // the union of product_ids referenced under product_status[*] and
376
+ // flags[].product_ids. Emitted under document.product_tree.full_product_names
377
+ // (CSAF 2.1 §3.1) so flags[].product_ids and product_status keys
378
+ // resolve against the document's own product tree — the spec
379
+ // requires every referenced product_id to be defined in the
380
+ // product_tree. Without this, Dependency-Track + csaf-validator-service
381
+ // reject the document as "unresolved product reference."
382
+ function _collectProductIds(statements, productTreeNames) {
383
+ var seen = Object.create(null);
384
+ var ordered = [];
385
+ function _add(id) {
386
+ if (typeof id !== "string" || id.length === 0) return;
387
+ if (seen[id]) return;
388
+ seen[id] = true;
389
+ ordered.push(id);
390
+ }
391
+ for (var si = 0; si < statements.length; si++) {
392
+ var s = statements[si] || {};
393
+ if (s.product_status && typeof s.product_status === "object") {
394
+ var statusKeys = Object.keys(s.product_status);
395
+ for (var ki = 0; ki < statusKeys.length; ki++) {
396
+ var arr = s.product_status[statusKeys[ki]];
397
+ if (Array.isArray(arr)) {
398
+ for (var ai = 0; ai < arr.length; ai++) _add(arr[ai]);
399
+ }
400
+ }
401
+ }
402
+ if (Array.isArray(s.flags)) {
403
+ for (var fi = 0; fi < s.flags.length; fi++) {
404
+ var fl = s.flags[fi];
405
+ if (fl && Array.isArray(fl.product_ids)) {
406
+ for (var fai = 0; fai < fl.product_ids.length; fai++) _add(fl.product_ids[fai]);
407
+ }
408
+ }
409
+ }
410
+ }
411
+ // Operator may supply product display-names via productTreeNames
412
+ // ({ "<productId>": "<display name>" }). When omitted, the
413
+ // product_id doubles as the display name so the emitted
414
+ // full_product_names entry resolves consistently.
415
+ var fpn = [];
416
+ for (var oi = 0; oi < ordered.length; oi++) {
417
+ var pid = ordered[oi];
418
+ var displayName = (productTreeNames && typeof productTreeNames[pid] === "string" &&
419
+ productTreeNames[pid].length > 0)
420
+ ? productTreeNames[pid]
421
+ : pid;
422
+ fpn.push({ product_id: pid, name: displayName });
423
+ }
424
+ return fpn;
425
+ }
426
+
228
427
  /**
229
428
  * @primitive b.vex.document
230
429
  * @signature b.vex.document(opts)
@@ -233,13 +432,23 @@ function statement(opts) {
233
432
  * @related b.vex.statement, b.vex.serialize
234
433
  *
235
434
  * Assemble a complete CSAF 2.1 VEX document with the supplied
236
- * vulnerability statements + framework distributor metadata.
435
+ * vulnerability statements + framework distributor metadata. The
436
+ * document auto-emits `product_tree.full_product_names` resolving
437
+ * every `product_ids` reference used by the statements (CSAF 2.1
438
+ * §3.1) so the document is self-contained — spec-conformant VEX
439
+ * validators (csaf-validator-service, Dependency-Track) require
440
+ * every `product_ids` reference to resolve against the document's
441
+ * own product_tree.
237
442
  *
238
443
  * @opts
239
444
  * documentId: string, // unique per-publication id (e.g. "blamejs-vex-2026-05-12")
240
445
  * title: string, // document title
241
446
  * publisher: { name, namespace, contactDetails? },
242
447
  * tlp: string, // one of TLP_LABELS; default "CLEAR"
448
+ * distributionText: string, // overrides TLP_DEFAULT_TEXTS[tlp]; required when TLP RED or AMBER+STRICT and operator wants non-default prose
449
+ * lang: string, // BCP 47 language tag (CSAF §3.2.1.13); default "en"
450
+ * trackingStatus: string, // CSAF §3.2.1.6 tracking.status — "draft" | "interim" | "final"; default "final"
451
+ * productTreeNames: object, // optional { "<productId>": "<display name>" } — when omitted, productId doubles as display name
243
452
  * statements: object[], // array of b.vex.statement output
244
453
  * distributor: { ... }, // optional CSAF distributor block
245
454
  * trackingId: string, // CSAF tracking id (e.g. version-pinned)
@@ -287,15 +496,63 @@ function document(opts) {
287
496
  throw new VexError("vex/bad-tlp",
288
497
  "document: tlp must be one of " + TLP_LABELS.join(" / "));
289
498
  }
499
+ // CSAF 2.1 §3.2.1.13 — document.lang is a BCP 47 (RFC 5646)
500
+ // language tag. The spec marks it optional but downstream
501
+ // localisation pipelines + csaf-validator-service emit a warning
502
+ // when absent; default to "en" so framework-issued docs always
503
+ // carry the field. Operators publishing in other languages set
504
+ // explicitly.
505
+ var lang = "en";
506
+ if (opts.lang !== undefined) {
507
+ if (typeof opts.lang !== "string" || !/^[A-Za-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(opts.lang)) {
508
+ throw new VexError("vex/bad-lang",
509
+ "document: lang must be a BCP 47 language tag (RFC 5646; e.g. 'en' / 'en-US' / 'de-DE')");
510
+ }
511
+ lang = opts.lang;
512
+ }
513
+ // CSAF 2.1 §3.2.1.6 — document.tracking.status enumeration. Default
514
+ // "final" matches the previously-hardcoded value; operators
515
+ // shipping in-progress disclosures set explicitly to "draft" /
516
+ // "interim". csaf-validator rejects values outside this enum.
517
+ var trackingStatus = "final";
518
+ if (opts.trackingStatus !== undefined) {
519
+ if (typeof opts.trackingStatus !== "string" ||
520
+ TRACKING_STATUS_VALUES.indexOf(opts.trackingStatus) === -1) {
521
+ throw new VexError("vex/bad-tracking-status",
522
+ "document: trackingStatus must be one of " +
523
+ TRACKING_STATUS_VALUES.join(" / ") + " (CSAF §3.2.1.6)");
524
+ }
525
+ trackingStatus = opts.trackingStatus;
526
+ }
527
+ if (opts.productTreeNames !== undefined &&
528
+ (typeof opts.productTreeNames !== "object" || opts.productTreeNames === null ||
529
+ Array.isArray(opts.productTreeNames))) {
530
+ throw new VexError("vex/bad-product-tree-names",
531
+ "document: productTreeNames must be a { <productId>: <displayName> } object");
532
+ }
533
+ validateOpts.optionalNonEmptyString(opts.distributionText, "document.distributionText", VexError, "vex/bad-distribution-text");
534
+ // FIRST TLP 2.0 + CSAF §3.2.1.12.1.1.1 — distribution.text is
535
+ // REQUIRED when TLP label is RED or AMBER+STRICT (the
536
+ // recipient-restricted tiers carry mandatory boilerplate prose).
537
+ // Other tiers benefit from the prose for downstream consumers;
538
+ // emit unconditionally with the operator-overridable default.
539
+ var distributionText = opts.distributionText || TLP_DEFAULT_TEXTS[tlp];
540
+
541
+ // CSAF 2.1 §3.1 — product_tree.full_product_names. Auto-derive
542
+ // from every productId referenced in statements so flags[] +
543
+ // product_status[] entries resolve against a real product node.
544
+ var fullProductNames = _collectProductIds(opts.statements, opts.productTreeNames || null);
545
+
290
546
  var doc = {
291
547
  document: {
292
548
  category: DOCUMENT_CATEGORY_VEX,
293
549
  csaf_version: CSAF_VERSION,
550
+ lang: lang,
294
551
  title: opts.title,
295
552
  tracking: {
296
553
  id: opts.trackingId,
297
554
  version: opts.trackingVersion,
298
- status: "final",
555
+ status: trackingStatus,
299
556
  initial_release_date: opts.initialReleaseDate,
300
557
  current_release_date: opts.currentReleaseDate,
301
558
  revision_history: [
@@ -304,6 +561,7 @@ function document(opts) {
304
561
  },
305
562
  distribution: {
306
563
  tlp: { label: tlp },
564
+ text: distributionText,
307
565
  },
308
566
  publisher: {
309
567
  name: opts.publisher.name,
@@ -311,6 +569,9 @@ function document(opts) {
311
569
  category: "vendor",
312
570
  },
313
571
  },
572
+ product_tree: {
573
+ full_product_names: fullProductNames,
574
+ },
314
575
  vulnerabilities: opts.statements,
315
576
  };
316
577
  if (opts.publisher.contactDetails) {
@@ -354,12 +615,16 @@ function serialize(doc) {
354
615
  }
355
616
 
356
617
  module.exports = {
357
- statement: statement,
358
- document: document,
359
- serialize: serialize,
360
- STATUS_VALUES: STATUS_VALUES,
361
- JUSTIFICATION_VALUES: JUSTIFICATION_VALUES,
362
- TLP_LABELS: TLP_LABELS,
363
- CSAF_VERSION: CSAF_VERSION,
364
- VexError: VexError,
618
+ statement: statement,
619
+ document: document,
620
+ serialize: serialize,
621
+ STATUS_VALUES: STATUS_VALUES,
622
+ JUSTIFICATION_VALUES: JUSTIFICATION_VALUES,
623
+ TLP_LABELS: TLP_LABELS,
624
+ TRACKING_STATUS_VALUES: TRACKING_STATUS_VALUES,
625
+ REFERENCE_CATEGORY_VALUES: REFERENCE_CATEGORY_VALUES,
626
+ NOTE_CATEGORY_VALUES: NOTE_CATEGORY_VALUES,
627
+ TLP_DEFAULT_TEXTS: TLP_DEFAULT_TEXTS,
628
+ CSAF_VERSION: CSAF_VERSION,
629
+ VexError: VexError,
365
630
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.9.49",
3
+ "version": "0.10.2",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.6",
5
- "serialNumber": "urn:uuid:21323cdb-5bfe-4b88-a63f-906795a497e6",
5
+ "serialNumber": "urn:uuid:c1165275-044f-4a5a-b7dc-061727bfd076",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-16T03:34:31.380Z",
8
+ "timestamp": "2026-05-16T23:28:39.659Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.9.49",
22
+ "bom-ref": "@blamejs/core@0.10.2",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.9.49",
25
+ "version": "0.10.2",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.9.49",
29
+ "purl": "pkg:npm/%40blamejs/core@0.10.2",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.9.49",
57
+ "ref": "@blamejs/core@0.10.2",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]