@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
@@ -0,0 +1,634 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.safeIcal
4
+ * @nav Parsers
5
+ * @title Safe iCalendar
6
+ * @order 125
7
+ *
8
+ * @intro
9
+ * Bounded RFC 5545 iCalendar parser. Walks the content-line grammar
10
+ * (`BEGIN:VCALENDAR` ... `END:VCALENDAR`) into a JSON AST that the
11
+ * mail / DAV / scheduling stacks can reason about without giving an
12
+ * attacker access to the parser's recursion / expansion machinery.
13
+ *
14
+ * Substrate for the calendar storage protocol (`b.mail.dav`),
15
+ * delivery-time iTIP processing, and the scheduling primitives that
16
+ * compose against ical bytes.
17
+ *
18
+ * Defends `CVE-2024-39687` (ical4j RRULE recursion / "Outlook
19
+ * calendar bomb" — a hostile RRULE with unbounded COUNT and
20
+ * recursive BYxxx expansion can pin a CalDAV server's CPU at 100%
21
+ * until the request times out). Caps:
22
+ *
23
+ * - Total bytes (256 KiB strict / 1 MiB balanced / 4 MiB
24
+ * permissive) — refused before parsing begins.
25
+ * - BEGIN/END nesting depth (16 / 32 / 64) — refused when a
26
+ * hostile blob nests VALARM-in-VEVENT-in-VEVENT-in-… past the
27
+ * cap.
28
+ * - Total content lines (16k / 65k / 262k) — refused after
29
+ * line-unfolding when a hostile blob ships gigabytes of
30
+ * single-property repetitions.
31
+ * - Per-line bytes after unfolding (8 KiB strict / 32 KiB balanced
32
+ * / 128 KiB permissive). RFC 5545 §3.1 recommends 75 octets per
33
+ * unfolded segment but folding is unbounded.
34
+ * - RRULE COUNT cap (10000 entries) — refused regardless of
35
+ * profile. The recurrence expander never materializes more
36
+ * instances than this cap.
37
+ * - RRULE BYDAY / BYMONTH / BYMONTHDAY / BYHOUR / BYMINUTE /
38
+ * BYSECOND / BYSETPOS / BYWEEKNO / BYYEARDAY list-length cap
39
+ * (24 entries) — refused regardless of profile. CVE-2024-39687
40
+ * achieves expansion blow-up by stacking long BYxxx lists.
41
+ *
42
+ * Header-injection / control-char defense: refuses NUL, C0 control
43
+ * bytes (other than TAB inside QUOTED-PRINTABLE-shaped values), and
44
+ * DEL (0x7F) inside property values. Defends against downstream
45
+ * consumers that splice ical fields into HTTP / SMTP / log headers.
46
+ *
47
+ * Property allowlist: every property name in the AST must either
48
+ * appear in the RFC 5545 / 5546 / 7986 property registry or carry
49
+ * the `X-` experimental prefix per §3.8.8.2. Unknown bare property
50
+ * names are refused regardless of profile — that path has been a
51
+ * reliable detection bypass on legacy parsers.
52
+ *
53
+ * The parser is purely functional — no I/O, no async, no side
54
+ * effects. Operators run it inside `b.workerPool` workers for any
55
+ * PUT body above an operator-chosen byte threshold.
56
+ *
57
+ * Explicit non-goals (deferred — operator escape hatch noted):
58
+ *
59
+ * - **JSCalendar (RFC 8984)** — JSON-native calendar grammar. The
60
+ * parser ships the AST in a JSON-shaped tree, but full
61
+ * JSCalendar conversion (timezone resolution, recurrence
62
+ * expansion to ISO 8601 instances, byday-string → enum) lights
63
+ * up when an operator requests it. Today's AST gives operators
64
+ * the raw `RRULE` / `RDATE` / `EXDATE` strings.
65
+ * - **VTIMEZONE inline composition** — operators reference IANA
66
+ * tzdb names via `TZID` and let the consuming layer resolve.
67
+ * Inline VTIMEZONE blocks parse (their components are walked
68
+ * into the AST) but the parser does not synthesize a missing
69
+ * VTIMEZONE from `TZID=…`.
70
+ * - **iTIP / iMIP (RFC 5546 / 6047)** — the SCHEDULE-AGENT /
71
+ * METHOD vocabulary parses fine; the cross-mail delivery hook
72
+ * that turns an iTIP message into a calendar update lives in
73
+ * the mail-server slice.
74
+ *
75
+ * @card
76
+ * Bounded RFC 5545 iCalendar parser — caps total bytes, nesting
77
+ * depth, RRULE COUNT and BYxxx list-lengths; refuses NUL / C0 / DEL
78
+ * inside property values; allowlists property names; defends
79
+ * CVE-2024-39687 (ical4j RRULE recursion / Outlook calendar-bomb).
80
+ */
81
+
82
+ var C = require("./constants");
83
+ var { defineClass } = require("./framework-error");
84
+
85
+ var SafeIcalError = defineClass("SafeIcalError", { alwaysPermanent: true });
86
+
87
+ // RRULE caps are enforced regardless of profile — CVE-2024-39687 has
88
+ // no safe permissive posture.
89
+ var RRULE_MAX_COUNT = 10000; // allow:raw-byte-literal — RFC 5545 §3.3.10 recurrence-count cap
90
+ var RRULE_MAX_BY_ENTRIES = 24; // allow:raw-byte-literal — BYxxx list-length cap
91
+
92
+ var PROFILES = Object.freeze({
93
+ strict: Object.freeze({
94
+ maxBytes: C.BYTES.kib(256),
95
+ maxLineBytes: C.BYTES.kib(8),
96
+ maxLines: 16384, // allow:raw-byte-literal — line count cap, not byte size
97
+ maxNestingDepth: 16, // allow:raw-byte-literal — nesting depth cap, not bytes
98
+ maxComponents: 4096, // allow:raw-byte-literal — total component count cap, not bytes
99
+ maxPropertiesPerComponent: 256, // allow:raw-byte-literal — per-component prop count, not bytes
100
+ }),
101
+ balanced: Object.freeze({
102
+ maxBytes: C.BYTES.mib(1),
103
+ maxLineBytes: C.BYTES.kib(32),
104
+ maxLines: 65536, // allow:raw-byte-literal — line count cap, not byte size
105
+ maxNestingDepth: 32, // allow:raw-byte-literal — nesting depth cap, not bytes
106
+ maxComponents: 16384, // allow:raw-byte-literal — total component count cap, not bytes
107
+ maxPropertiesPerComponent: 1024, // allow:raw-byte-literal — per-component prop count, not bytes
108
+ }),
109
+ permissive: Object.freeze({
110
+ maxBytes: C.BYTES.mib(4),
111
+ maxLineBytes: C.BYTES.kib(128),
112
+ maxLines: 262144, // allow:raw-byte-literal — line count cap, not byte size
113
+ maxNestingDepth: 64, // allow:raw-byte-literal — nesting depth cap, not bytes
114
+ maxComponents: 65536, // allow:raw-byte-literal — total component count cap, not bytes
115
+ maxPropertiesPerComponent: 4096, // allow:raw-byte-literal — per-component prop count, not bytes
116
+ }),
117
+ });
118
+
119
+ var COMPLIANCE_POSTURES = Object.freeze({
120
+ hipaa: "strict",
121
+ "pci-dss": "strict",
122
+ gdpr: "strict",
123
+ soc2: "strict",
124
+ });
125
+
126
+ // Property-name allowlist per RFC 5545 §8.7 (Property Registry) +
127
+ // RFC 5546 §4.3 (iTIP additions) + RFC 7986 §5 (new calendar
128
+ // properties). Unknown bare names are refused; `X-` experimental
129
+ // names are admitted regardless. The allowlist is conservative — when
130
+ // a missing property surfaces in production, the operator extends via
131
+ // `opts.extraProperties`.
132
+ var KNOWN_PROPERTIES = Object.freeze({
133
+ // Calendar-level (RFC 5545 §3.7)
134
+ CALSCALE: true, METHOD: true, PRODID: true, VERSION: true,
135
+ // Descriptive (RFC 5545 §3.8.1)
136
+ ATTACH: true, CATEGORIES: true, CLASS: true, COMMENT: true,
137
+ DESCRIPTION: true, GEO: true, LOCATION: true, "PERCENT-COMPLETE": true,
138
+ PRIORITY: true, RESOURCES: true, STATUS: true, SUMMARY: true,
139
+ // Date / time (RFC 5545 §3.8.2)
140
+ COMPLETED: true, DTEND: true, DUE: true, DTSTART: true, DURATION: true,
141
+ FREEBUSY: true, TRANSP: true,
142
+ // Time zone (RFC 5545 §3.8.3)
143
+ TZID: true, TZNAME: true, TZOFFSETFROM: true, TZOFFSETTO: true, TZURL: true,
144
+ // Relationship (RFC 5545 §3.8.4)
145
+ ATTENDEE: true, CONTACT: true, ORGANIZER: true, "RECURRENCE-ID": true,
146
+ "RELATED-TO": true, URL: true, UID: true,
147
+ // Recurrence (RFC 5545 §3.8.5)
148
+ EXDATE: true, EXRULE: true, RDATE: true, RRULE: true,
149
+ // Alarm (RFC 5545 §3.8.6)
150
+ ACTION: true, REPEAT: true, TRIGGER: true,
151
+ // Change management (RFC 5545 §3.8.7)
152
+ CREATED: true, "DTSTAMP": true, "LAST-MODIFIED": true, SEQUENCE: true,
153
+ // Miscellaneous (RFC 5545 §3.8.8)
154
+ "REQUEST-STATUS": true,
155
+ // RFC 7986 — new calendar properties
156
+ NAME: true, "REFRESH-INTERVAL": true, SOURCE: true, COLOR: true, IMAGE: true,
157
+ CONFERENCE: true,
158
+ });
159
+
160
+ // Component-name allowlist per RFC 5545 §3.6 + §3.6.7 (VFREEBUSY) +
161
+ // §3.6.4 (VJOURNAL) + RFC 7953 (VAVAILABILITY).
162
+ var KNOWN_COMPONENTS = Object.freeze({
163
+ VCALENDAR: true,
164
+ VEVENT: true, VTODO: true, VJOURNAL: true, VFREEBUSY: true,
165
+ VTIMEZONE: true, STANDARD: true, DAYLIGHT: true,
166
+ VALARM: true,
167
+ VAVAILABILITY: true, AVAILABLE: true,
168
+ });
169
+
170
+ /**
171
+ * @primitive b.safeIcal.parse
172
+ * @signature b.safeIcal.parse(text, opts?)
173
+ * @since 0.9.81
174
+ * @status stable
175
+ * @related b.safeVcard.parse, b.mail.dav.create
176
+ *
177
+ * Parse RFC 5545 iCalendar text into a JSON AST. Returns
178
+ * `{ vcalendar: { properties: {...}, vevent: [...], vtodo: [...],
179
+ * vjournal: [...], vfreebusy: [...], vtimezone: [...] } }`.
180
+ *
181
+ * Throws `SafeIcalError` with codes:
182
+ * `safe-ical/oversize-bytes` /
183
+ * `oversize-line-bytes` / `oversize-lines` / `oversize-nesting` /
184
+ * `oversize-components` / `oversize-properties-per-component` /
185
+ * `oversize-rrule-count` / `oversize-rrule-by` /
186
+ * `missing-vcalendar` / `unterminated-component` /
187
+ * `unknown-property` / `unknown-component` /
188
+ * `control-char-in-value` / `bad-line` / `bad-input` / `bad-opt`.
189
+ *
190
+ * @opts
191
+ * profile: "strict" | "balanced" | "permissive", // default strict
192
+ * compliancePosture: "hipaa" | "pci-dss" | "gdpr" | "soc2", // → strict
193
+ * extraProperties: string[], // operator-extended allowlist
194
+ * extraComponents: string[], // operator-extended allowlist
195
+ *
196
+ * @example
197
+ * var ast = b.safeIcal.parse(
198
+ * "BEGIN:VCALENDAR\r\n" +
199
+ * "VERSION:2.0\r\n" +
200
+ * "PRODID:-//Example//1.0//EN\r\n" +
201
+ * "BEGIN:VEVENT\r\n" +
202
+ * "UID:abc@example.com\r\n" +
203
+ * "DTSTAMP:20260101T120000Z\r\n" +
204
+ * "DTSTART:20260101T130000Z\r\n" +
205
+ * "SUMMARY:Team meeting\r\n" +
206
+ * "END:VEVENT\r\n" +
207
+ * "END:VCALENDAR\r\n"
208
+ * );
209
+ * ast.vcalendar.vevent[0].properties.SUMMARY[0].value; // → "Team meeting"
210
+ */
211
+ function parse(text, opts) {
212
+ opts = opts || {};
213
+ var caps = _resolveCaps(opts);
214
+ var extraProps = _toSet(opts.extraProperties);
215
+ var extraComps = _toSet(opts.extraComponents);
216
+
217
+ if (typeof text !== "string" && !Buffer.isBuffer(text)) {
218
+ throw new SafeIcalError("safe-ical/bad-input",
219
+ "safeIcal.parse: input must be string or Buffer (got " + typeof text + ")");
220
+ }
221
+ var s = typeof text === "string" ? text : text.toString("utf8");
222
+ var byteLen = Buffer.byteLength(s, "utf8");
223
+ if (byteLen > caps.maxBytes) {
224
+ throw new SafeIcalError("safe-ical/oversize-bytes",
225
+ "safeIcal.parse: input " + byteLen + " bytes exceeds maxBytes=" + caps.maxBytes +
226
+ " (CVE-2024-39687-class defense)");
227
+ }
228
+
229
+ var lines = _unfold(s, caps);
230
+
231
+ // Top-level walk — must open with BEGIN:VCALENDAR. RFC 5545 §3.4
232
+ // permits multiple VCALENDAR objects in a stream; we accept the
233
+ // first one and require the rest to round-trip cleanly.
234
+ var ctx = {
235
+ caps: caps,
236
+ extraProps: extraProps,
237
+ extraComps: extraComps,
238
+ componentCount: 0,
239
+ };
240
+ var idx = 0;
241
+ while (idx < lines.length && lines[idx].name !== "BEGIN") idx++;
242
+ if (idx >= lines.length) {
243
+ throw new SafeIcalError("safe-ical/missing-vcalendar",
244
+ "safeIcal.parse: no BEGIN:VCALENDAR line found");
245
+ }
246
+ if (lines[idx].value !== "VCALENDAR") {
247
+ throw new SafeIcalError("safe-ical/missing-vcalendar",
248
+ "safeIcal.parse: first BEGIN line must be VCALENDAR (got '" + lines[idx].value + "')");
249
+ }
250
+ var consumed = _parseComponent(lines, idx, ctx, 0);
251
+ var vcal = consumed.component;
252
+ // RFC 5545 §3.4 — a stream may carry multiple VCALENDAR objects.
253
+ // Walk the remainder so trailing objects are validated under the
254
+ // same caps + control-char + property allowlist (Codex P2 — without
255
+ // this, CalDAV ingest can pass validation on the first object while
256
+ // trailing malformed objects ride through untouched).
257
+ var vcalendars = [_shapeVcalendar(vcal)];
258
+ var cursor = consumed.nextIdx;
259
+ while (cursor < lines.length) {
260
+ while (cursor < lines.length && lines[cursor].name !== "BEGIN") cursor++;
261
+ if (cursor >= lines.length) break;
262
+ if (lines[cursor].value !== "VCALENDAR") {
263
+ throw new SafeIcalError("safe-ical/missing-vcalendar",
264
+ "safeIcal.parse: BEGIN at line " + (lines[cursor].lineNo || cursor) +
265
+ " must be VCALENDAR (got '" + lines[cursor].value + "')");
266
+ }
267
+ var more = _parseComponent(lines, cursor, ctx, 0);
268
+ vcalendars.push(_shapeVcalendar(more.component));
269
+ cursor = more.nextIdx;
270
+ }
271
+ return vcalendars.length === 1
272
+ ? { vcalendar: vcalendars[0] }
273
+ : { vcalendar: vcalendars[0], vcalendars: vcalendars };
274
+ }
275
+
276
+ /**
277
+ * @primitive b.safeIcal.compliancePosture
278
+ * @signature b.safeIcal.compliancePosture(name)
279
+ * @since 0.9.81
280
+ * @status stable
281
+ * @related b.safeIcal.parse
282
+ *
283
+ * Map a compliance-posture name to its profile. Returns the profile
284
+ * string for a known posture, `null` for unknown names.
285
+ *
286
+ * @example
287
+ * b.safeIcal.compliancePosture("hipaa"); // → "strict"
288
+ * b.safeIcal.compliancePosture("loose"); // → null
289
+ */
290
+ function compliancePosture(name) {
291
+ return COMPLIANCE_POSTURES[name] || null;
292
+ }
293
+
294
+ // ---- Profile / opt resolution ----
295
+
296
+ function _resolveCaps(opts) {
297
+ var name = "strict";
298
+ if (typeof opts.profile === "string") {
299
+ name = opts.profile;
300
+ } else if (typeof opts.compliancePosture === "string") {
301
+ name = COMPLIANCE_POSTURES[opts.compliancePosture] || "strict";
302
+ }
303
+ var caps = PROFILES[name];
304
+ if (!caps) {
305
+ throw new SafeIcalError("safe-ical/bad-opt",
306
+ "safeIcal.parse: unknown profile '" + name +
307
+ "' (expected strict|balanced|permissive)");
308
+ }
309
+ return caps;
310
+ }
311
+
312
+ function _toSet(arr) {
313
+ var set = Object.create(null);
314
+ if (!Array.isArray(arr)) return set;
315
+ for (var i = 0; i < arr.length; i++) {
316
+ if (typeof arr[i] === "string") set[arr[i].toUpperCase()] = true;
317
+ }
318
+ return set;
319
+ }
320
+
321
+ // ---- Line unfolding (RFC 5545 §3.1) ----
322
+ //
323
+ // "Lines of text SHOULD NOT be longer than 75 octets, excluding the
324
+ // line break. Long content lines SHOULD be split into a multiple
325
+ // line representations using a line 'folding' technique. That is, a
326
+ // long line can be split between any two characters by inserting a
327
+ // CRLF immediately followed by a single linear white-space character
328
+ // (i.e., SPACE or HTAB)."
329
+ //
330
+ // We unfold by joining a continuation line (one starting with SPACE
331
+ // or HTAB) onto the prior line after stripping the leading whitespace
332
+ // character.
333
+
334
+ function _unfold(s, caps) {
335
+ // Normalize line endings — RFC 5545 specifies CRLF but real-world
336
+ // ical bytes also use bare LF (and occasionally bare CR on legacy
337
+ // emitters). Treat \r\n / \n / \r identically.
338
+ var raw = s.replace(/\r\n?|\n/g, "\n").split("\n");
339
+ var unfolded = [];
340
+ for (var i = 0; i < raw.length; i++) {
341
+ var line = raw[i];
342
+ if (line.length === 0) {
343
+ // Blank lines are tolerated between the closing END line and
344
+ // EOF (some emitters add a trailing newline); skip them.
345
+ continue;
346
+ }
347
+ var firstChar = line.charCodeAt(0);
348
+ if (firstChar === 0x20 || firstChar === 0x09) { // allow:raw-byte-literal — SPACE / HTAB are RFC 5545 §3.1 fold markers
349
+ if (unfolded.length === 0) {
350
+ throw new SafeIcalError("safe-ical/bad-line",
351
+ "safeIcal.parse: continuation line before any content line");
352
+ }
353
+ unfolded[unfolded.length - 1] += line.slice(1);
354
+ } else {
355
+ unfolded.push(line);
356
+ }
357
+ if (unfolded.length > caps.maxLines) {
358
+ throw new SafeIcalError("safe-ical/oversize-lines",
359
+ "safeIcal.parse: line count exceeds maxLines=" + caps.maxLines);
360
+ }
361
+ }
362
+
363
+ var parsed = [];
364
+ for (var j = 0; j < unfolded.length; j++) {
365
+ var u = unfolded[j];
366
+ if (Buffer.byteLength(u, "utf8") > caps.maxLineBytes) {
367
+ throw new SafeIcalError("safe-ical/oversize-line-bytes",
368
+ "safeIcal.parse: unfolded line " + (j + 1) + " exceeds maxLineBytes=" + caps.maxLineBytes);
369
+ }
370
+ parsed.push(_parseContentLine(u));
371
+ }
372
+ return parsed;
373
+ }
374
+
375
+ // ---- Content-line parser (RFC 5545 §3.1) ----
376
+ //
377
+ // `contentline = name *(";" param) ":" value CRLF`
378
+ // `name = iana-token / x-name`
379
+ // `param = param-name "=" param-value *("," param-value)`
380
+
381
+ function _parseContentLine(line) {
382
+ // Split off the value at the first un-quoted `:`.
383
+ var colonIdx = _findUnquotedColon(line);
384
+ if (colonIdx < 0) {
385
+ throw new SafeIcalError("safe-ical/bad-line",
386
+ "safeIcal.parse: content line missing ':' separator: " +
387
+ _preview(line));
388
+ }
389
+ var head = line.slice(0, colonIdx);
390
+ var value = line.slice(colonIdx + 1);
391
+
392
+ // Refuse NUL, C0 control bytes (other than TAB), and DEL in the
393
+ // value. Header-injection / log-poisoning defense.
394
+ for (var k = 0; k < value.length; k++) {
395
+ var cc = value.charCodeAt(k);
396
+ if ((cc < 0x20 && cc !== 0x09) || cc === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal
397
+ throw new SafeIcalError("safe-ical/control-char-in-value",
398
+ "safeIcal.parse: control char 0x" + cc.toString(16) +
399
+ " in property value (header-injection defense)");
400
+ }
401
+ }
402
+
403
+ // Split params off the property name.
404
+ var segs = _splitUnquoted(head, ";");
405
+ var name = segs[0].toUpperCase();
406
+ var params = Object.create(null);
407
+ for (var p = 1; p < segs.length; p++) {
408
+ var seg = segs[p];
409
+ var eq = seg.indexOf("=");
410
+ if (eq < 0) {
411
+ throw new SafeIcalError("safe-ical/bad-line",
412
+ "safeIcal.parse: malformed parameter '" + seg + "'");
413
+ }
414
+ var pname = seg.slice(0, eq).toUpperCase();
415
+ var pvalue = seg.slice(eq + 1);
416
+ if (pname === "__proto__" || pname === "constructor" || pname === "prototype") continue;
417
+ if (params[pname]) {
418
+ params[pname].push(_stripDoubleQuotes(pvalue));
419
+ } else {
420
+ params[pname] = [_stripDoubleQuotes(pvalue)];
421
+ }
422
+ }
423
+ return { name: name, params: params, value: value };
424
+ }
425
+
426
+ function _findUnquotedColon(line) {
427
+ var inQ = false;
428
+ for (var i = 0; i < line.length; i++) {
429
+ var c = line.charCodeAt(i);
430
+ if (c === 0x22) { inQ = !inQ; continue; } // allow:raw-byte-literal — DQUOTE per RFC 5545 §3.1 quoted-string
431
+ if (c === 0x3A && !inQ) return i; // allow:raw-byte-literal — colon separator per RFC 5545 §3.1
432
+ }
433
+ return -1;
434
+ }
435
+
436
+ function _splitUnquoted(s, sep) {
437
+ var out = [];
438
+ var inQ = false;
439
+ var start = 0;
440
+ for (var i = 0; i < s.length; i++) {
441
+ var c = s.charAt(i);
442
+ if (c === '"') { inQ = !inQ; continue; }
443
+ if (c === sep && !inQ) {
444
+ out.push(s.slice(start, i));
445
+ start = i + 1;
446
+ }
447
+ }
448
+ out.push(s.slice(start));
449
+ return out;
450
+ }
451
+
452
+ function _stripDoubleQuotes(s) {
453
+ if (s.length >= 2 && s.charAt(0) === '"' && s.charAt(s.length - 1) === '"') {
454
+ return s.slice(1, -1);
455
+ }
456
+ return s;
457
+ }
458
+
459
+ // ---- Component parser (RFC 5545 §3.6) ----
460
+ //
461
+ // Each component is bracketed by `BEGIN:<NAME>` and `END:<NAME>`
462
+ // lines. Components nest (VALARM inside VEVENT; STANDARD / DAYLIGHT
463
+ // inside VTIMEZONE; AVAILABLE inside VAVAILABILITY).
464
+
465
+ function _parseComponent(lines, startIdx, ctx, depth) {
466
+ if (depth > ctx.caps.maxNestingDepth) {
467
+ throw new SafeIcalError("safe-ical/oversize-nesting",
468
+ "safeIcal.parse: nesting depth exceeds maxNestingDepth=" +
469
+ ctx.caps.maxNestingDepth + " (CVE-2024-39687-class defense)");
470
+ }
471
+ ctx.componentCount += 1;
472
+ if (ctx.componentCount > ctx.caps.maxComponents) {
473
+ throw new SafeIcalError("safe-ical/oversize-components",
474
+ "safeIcal.parse: component count exceeds maxComponents=" +
475
+ ctx.caps.maxComponents);
476
+ }
477
+ var begin = lines[startIdx];
478
+ if (begin.name !== "BEGIN") {
479
+ throw new SafeIcalError("safe-ical/bad-line",
480
+ "safeIcal.parse: expected BEGIN, got '" + begin.name + "'");
481
+ }
482
+ var compName = begin.value.toUpperCase();
483
+ if (!KNOWN_COMPONENTS[compName] && !ctx.extraComps[compName] &&
484
+ compName.indexOf("X-") !== 0) {
485
+ throw new SafeIcalError("safe-ical/unknown-component",
486
+ "safeIcal.parse: unknown component '" + compName +
487
+ "' (extend via opts.extraComponents or use X- prefix)");
488
+ }
489
+
490
+ var properties = Object.create(null);
491
+ var children = [];
492
+ var propertyCount = 0;
493
+ var i = startIdx + 1;
494
+ while (i < lines.length) {
495
+ var ln = lines[i];
496
+ if (ln.name === "BEGIN") {
497
+ var child = _parseComponent(lines, i, ctx, depth + 1);
498
+ children.push(child.component);
499
+ i = child.nextIdx;
500
+ continue;
501
+ }
502
+ if (ln.name === "END") {
503
+ if (ln.value.toUpperCase() !== compName) {
504
+ throw new SafeIcalError("safe-ical/unterminated-component",
505
+ "safeIcal.parse: BEGIN:" + compName + " closed by END:" + ln.value);
506
+ }
507
+ return {
508
+ component: { name: compName, properties: properties, children: children },
509
+ nextIdx: i + 1,
510
+ };
511
+ }
512
+ // Validate property name.
513
+ var pn = ln.name;
514
+ if (!KNOWN_PROPERTIES[pn] && !ctx.extraProps[pn] && pn.indexOf("X-") !== 0) {
515
+ throw new SafeIcalError("safe-ical/unknown-property",
516
+ "safeIcal.parse: unknown property '" + pn +
517
+ "' (extend via opts.extraProperties or use X- prefix)");
518
+ }
519
+ // RRULE caps — CVE-2024-39687 defense.
520
+ if (pn === "RRULE" || pn === "EXRULE") {
521
+ _validateRrule(ln.value);
522
+ }
523
+ propertyCount += 1;
524
+ if (propertyCount > ctx.caps.maxPropertiesPerComponent) {
525
+ throw new SafeIcalError("safe-ical/oversize-properties-per-component",
526
+ "safeIcal.parse: property count in " + compName +
527
+ " exceeds maxPropertiesPerComponent=" + ctx.caps.maxPropertiesPerComponent);
528
+ }
529
+ if (pn === "__proto__" || pn === "constructor" || pn === "prototype") {
530
+ i += 1;
531
+ continue;
532
+ }
533
+ if (!properties[pn]) properties[pn] = [];
534
+ properties[pn].push({ params: ln.params, value: ln.value });
535
+ i += 1;
536
+ }
537
+ throw new SafeIcalError("safe-ical/unterminated-component",
538
+ "safeIcal.parse: BEGIN:" + compName + " never closed (missing END)");
539
+ }
540
+
541
+ // ---- RRULE validation (RFC 5545 §3.3.10) ----
542
+ //
543
+ // `recur = "FREQ"=freq *( ";" rulepart )`
544
+ // `rulepart = "UNTIL" / "COUNT" / "INTERVAL" / "BYSECOND" /
545
+ // "BYMINUTE" / "BYHOUR" / "BYDAY" / "BYMONTHDAY" /
546
+ // "BYYEARDAY" / "BYWEEKNO" / "BYMONTH" / "BYSETPOS" /
547
+ // "WKST"`
548
+
549
+ function _validateRrule(value) {
550
+ var parts = value.split(";");
551
+ for (var i = 0; i < parts.length; i++) {
552
+ var kv = parts[i].split("=");
553
+ if (kv.length !== 2) continue;
554
+ var key = kv[0].toUpperCase();
555
+ var val = kv[1];
556
+ if (key === "COUNT") {
557
+ var n = parseInt(val, 10);
558
+ if (!isFinite(n) || n < 0 || n > RRULE_MAX_COUNT) {
559
+ throw new SafeIcalError("safe-ical/oversize-rrule-count",
560
+ "safeIcal.parse: RRULE COUNT=" + val + " exceeds cap=" +
561
+ RRULE_MAX_COUNT + " (CVE-2024-39687 defense)");
562
+ }
563
+ } else if (key === "BYDAY" || key === "BYMONTH" || key === "BYMONTHDAY" ||
564
+ key === "BYHOUR" || key === "BYMINUTE" || key === "BYSECOND" ||
565
+ key === "BYSETPOS" || key === "BYWEEKNO" || key === "BYYEARDAY") {
566
+ var entries = val.split(",");
567
+ if (entries.length > RRULE_MAX_BY_ENTRIES) {
568
+ throw new SafeIcalError("safe-ical/oversize-rrule-by",
569
+ "safeIcal.parse: RRULE " + key + " list length " + entries.length +
570
+ " exceeds cap=" + RRULE_MAX_BY_ENTRIES + " (CVE-2024-39687 defense)");
571
+ }
572
+ }
573
+ }
574
+ }
575
+
576
+ // ---- AST shaping (post-parse convenience) ----
577
+ //
578
+ // Re-shapes the raw component tree into a dispatcher-friendly form
579
+ // where each child component kind has its own array:
580
+ // { properties, vevent, vtodo, vjournal, vfreebusy, vtimezone, ... }
581
+ //
582
+ // Unknown child component kinds collect into `.other` so operators
583
+ // can still see them when their allowlist via extraComponents kicks
584
+ // in.
585
+
586
+ function _shapeVcalendar(comp) {
587
+ var out = {
588
+ properties: comp.properties,
589
+ vevent: [],
590
+ vtodo: [],
591
+ vjournal: [],
592
+ vfreebusy: [],
593
+ vtimezone: [],
594
+ vavailability: [],
595
+ other: [],
596
+ };
597
+ for (var i = 0; i < comp.children.length; i++) {
598
+ var ch = comp.children[i];
599
+ var shaped = _shapeComponent(ch);
600
+ switch (ch.name) {
601
+ case "VEVENT": out.vevent.push(shaped); break;
602
+ case "VTODO": out.vtodo.push(shaped); break;
603
+ case "VJOURNAL": out.vjournal.push(shaped); break;
604
+ case "VFREEBUSY": out.vfreebusy.push(shaped); break;
605
+ case "VTIMEZONE": out.vtimezone.push(shaped); break;
606
+ case "VAVAILABILITY": out.vavailability.push(shaped); break;
607
+ default: out.other.push(shaped); break;
608
+ }
609
+ }
610
+ return out;
611
+ }
612
+
613
+ function _shapeComponent(comp) {
614
+ var out = { name: comp.name, properties: comp.properties, children: [] };
615
+ for (var i = 0; i < comp.children.length; i++) {
616
+ out.children.push(_shapeComponent(comp.children[i]));
617
+ }
618
+ return out;
619
+ }
620
+
621
+ function _preview(s) {
622
+ if (typeof s !== "string") s = String(s);
623
+ return s.length > 64 ? s.slice(0, 64) + "..." : s; // allow:raw-byte-literal — log-preview length cap
624
+ }
625
+
626
+ module.exports = {
627
+ parse: parse,
628
+ compliancePosture: compliancePosture,
629
+ PROFILES: PROFILES,
630
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
631
+ KNOWN_PROPERTIES: KNOWN_PROPERTIES,
632
+ KNOWN_COMPONENTS: KNOWN_COMPONENTS,
633
+ SafeIcalError: SafeIcalError,
634
+ };