@blamejs/core 0.7.4 → 0.7.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,529 @@
1
+ "use strict";
2
+ /**
3
+ * guard-yaml — YAML content-safety primitive (b.guardYaml).
4
+ *
5
+ * Threat catalog grounded in current research (multiple 2025-2026
6
+ * deserialization + DoS CVEs in popular YAML libraries):
7
+ * - CVE-2026-24009 Docling / PyYAML unsafe load → RCE
8
+ * - CVE-2026-27807 MarkUs YAML alias billion-laughs DoS
9
+ * - CVE-2025-68664 LangChain deserialization → RCE
10
+ * - CVE-2025-61301 / CVE-2025-61303 YAML library DoS family
11
+ * ("Laughter in the Wild" study across 14 libraries / 10 languages)
12
+ * - CVE-2022-1471 SnakeYAML constructor RCE
13
+ * - CVE-2020-1747 / CVE-2020-14343 PyYAML FullLoader RCE chain
14
+ * - CVE-2017-18342 PyYAML python/object/apply RCE
15
+ *
16
+ * var rv = b.guardYaml.validate(input, { profile: "strict" });
17
+ * var safe = b.guardYaml.parse(input, { profile: "strict" });
18
+ * var g = b.guardYaml.gate({ profile: "strict" });
19
+ *
20
+ * Threat catalog covered (all source-level — operator's downstream
21
+ * parser may be pyyaml/snakeyaml/js-yaml; the guard refuses hostile
22
+ * sources before any parser sees them):
23
+ *
24
+ * 1. Tag-injection RCE — language-specific deserialization tags
25
+ * with prefixes !!python/ / !!java. / !!ruby/ / !!perl/ / !!js/
26
+ * / !!cs/ / !!net/ / !!system. and the !!apply / !!new family.
27
+ * Refused regardless of profile under strict.
28
+ *
29
+ * 2. Anchor / alias recursion (billion laughs) — &anchor declares,
30
+ * *alias references. Recursive aliasing amplifies a small input
31
+ * into GiB on parse. Caps via maxAnchors + maxAliasDepth + total
32
+ * node count.
33
+ *
34
+ * 3. Multi-document streams — operators expecting a single doc
35
+ * silently get the first one and ignore the rest, which can mask
36
+ * hostile content.
37
+ *
38
+ * 4. Norway problem — YAML 1.1 (still default in pyyaml + libyaml
39
+ * in 2026) treats unquoted no/yes/y/n/on/off as booleans. Country
40
+ * code "NO" → false.
41
+ *
42
+ * 5. Leading-zero octals — 0777 parses as octal 511 in YAML 1.1.
43
+ *
44
+ * 6. Duplicate keys — YAML 1.2 SHOULD-unique; parsers silently
45
+ * last-wins, same threat shape as JSON duplicate-key smuggling.
46
+ *
47
+ * 7. Local + custom user tags — even when not language-specific,
48
+ * surface that suggests a non-safe parser is downstream.
49
+ *
50
+ * 8. Merge-key chain depth (<<: *anchor) — anchor-chain DoS.
51
+ *
52
+ * 9. Bidi / null / control / zero-width chars in scalar values.
53
+ *
54
+ * 10. Anti-DoS caps — total document size, total node count, max
55
+ * anchors, max alias depth, max document count, max scalar
56
+ * length, max depth.
57
+ */
58
+
59
+ var codepointClass = require("./codepoint-class");
60
+ var lazyRequire = require("./lazy-require");
61
+ var gateContract = require("./gate-contract");
62
+ var C = require("./constants");
63
+ var numericBounds = require("./numeric-bounds");
64
+ var safeYamlLazy = lazyRequire(function () { return require("./parsers/safe-yaml"); });
65
+ var { GuardYamlError } = require("./framework-error");
66
+
67
+ var observability = lazyRequire(function () { return require("./observability"); });
68
+ void observability;
69
+
70
+ var _err = GuardYamlError.factory;
71
+
72
+ // ---- Source-level threat detectors ----
73
+
74
+ // Dangerous tag prefixes — language-specific deserialization triggers.
75
+ var DANGEROUS_TAG_PREFIXES = Object.freeze([
76
+ "!!python/", "!!java.", "!!ruby/", "!!perl/", "!!js/", "!!cs/",
77
+ "!!net/", "!!system.", "!!eval", "!!exec", "!!new", "!!apply",
78
+ ]);
79
+
80
+ // YAML 1.2 core tag allowlist — when allowing tags at all, only these
81
+ // are permitted under balanced.
82
+ var SAFE_CORE_TAGS = Object.freeze([
83
+ "!!str", "!!int", "!!float", "!!bool", "!!null",
84
+ "!!seq", "!!map", "!!set", "!!omap", "!!pairs",
85
+ "!!binary", "!!timestamp", "!!merge",
86
+ ]);
87
+
88
+ // Anchor declaration — &name. Alias reference — *name.
89
+ var ANCHOR_DECL_RE = /(^|\s|:|-)(&[A-Za-z_][A-Za-z0-9_-]*)/g;
90
+ var ALIAS_REF_RE = /(^|\s|:|-|\[|\{|,)(\*[A-Za-z_][A-Za-z0-9_-]*)/g;
91
+
92
+ // Norway problem — unquoted YAML 1.1 boolean-shaped tokens at scalar
93
+ // position. true/false ARE valid YAML 1.2 booleans so we don't flag
94
+ // them; only the no/yes/y/n/on/off quirks fire.
95
+ var NORWAY_BOOL_QUIRK_RE = /:\s*(no|yes|y|n|on|off)\b/gi;
96
+
97
+ // Leading-zero octals — 0777 etc. at scalar position.
98
+ var LEADING_ZERO_OCTAL_RE = /:\s*0\d+\b/g;
99
+
100
+ // Merge keys — <<: *anchor chain.
101
+ var MERGE_KEY_RE = /<<\s*:\s*\*/;
102
+
103
+ // ---- Profile presets ----
104
+
105
+ var PROFILES = Object.freeze({
106
+ "strict": {
107
+ tagPolicy: "reject",
108
+ aliasPolicy: "reject",
109
+ multiDocPolicy: "reject",
110
+ norwayPolicy: "reject",
111
+ leadingZeroPolicy: "reject",
112
+ duplicateKeyPolicy: "reject",
113
+ mergeKeyPolicy: "reject",
114
+ bidiPolicy: "reject",
115
+ controlPolicy: "reject",
116
+ nullBytePolicy: "reject",
117
+ zeroWidthPolicy: "reject",
118
+ safeCoreTagsAllowed: false,
119
+ maxBytes: C.BYTES.mib(2),
120
+ maxDepth: 8, // allow:raw-byte-literal — recursion depth, not byte size
121
+ maxAnchors: 16, // allow:raw-byte-literal — anchor count cap, not byte size
122
+ maxAliasDepth: 1, // allow:raw-byte-literal — alias chain cap, not byte size
123
+ maxDocuments: 1, // allow:raw-byte-literal — doc count cap, not byte size
124
+ maxNodes: 1024, // allow:raw-byte-literal — node count cap, not byte size
125
+ maxScalarLength: C.BYTES.kib(8),
126
+ },
127
+ "balanced": {
128
+ tagPolicy: "audit",
129
+ aliasPolicy: "audit",
130
+ multiDocPolicy: "audit",
131
+ norwayPolicy: "audit",
132
+ leadingZeroPolicy: "audit",
133
+ duplicateKeyPolicy: "audit",
134
+ mergeKeyPolicy: "audit",
135
+ bidiPolicy: "strip",
136
+ controlPolicy: "strip",
137
+ nullBytePolicy: "strip",
138
+ zeroWidthPolicy: "strip",
139
+ safeCoreTagsAllowed: true,
140
+ maxBytes: C.BYTES.mib(8),
141
+ maxDepth: 32, // allow:raw-byte-literal — recursion depth, not byte size
142
+ maxAnchors: 64, // allow:raw-byte-literal — anchor count cap, not byte size
143
+ maxAliasDepth: 3, // allow:raw-byte-literal — alias chain cap, not byte size
144
+ maxDocuments: 16, // allow:raw-byte-literal — doc count cap, not byte size
145
+ maxNodes: 16384, // allow:raw-byte-literal — node count cap, not byte size
146
+ maxScalarLength: C.BYTES.kib(64),
147
+ },
148
+ "permissive": {
149
+ tagPolicy: "audit",
150
+ aliasPolicy: "audit",
151
+ multiDocPolicy: "audit",
152
+ norwayPolicy: "audit",
153
+ leadingZeroPolicy: "audit",
154
+ duplicateKeyPolicy: "audit",
155
+ mergeKeyPolicy: "audit",
156
+ bidiPolicy: "audit",
157
+ controlPolicy: "strip",
158
+ nullBytePolicy: "reject",
159
+ zeroWidthPolicy: "strip",
160
+ safeCoreTagsAllowed: true,
161
+ maxBytes: C.BYTES.mib(64),
162
+ maxDepth: 64, // allow:raw-byte-literal — recursion depth, not byte size
163
+ maxAnchors: 1024, // allow:raw-byte-literal — anchor count cap, not byte size
164
+ maxAliasDepth: 8, // allow:raw-byte-literal — alias chain cap, not byte size
165
+ maxDocuments: 256, // allow:raw-byte-literal — doc count cap, not byte size
166
+ maxNodes: 65536, // allow:raw-byte-literal — node count cap, not byte size
167
+ maxScalarLength: C.BYTES.kib(256),
168
+ },
169
+ });
170
+
171
+ var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
172
+ mode: "enforce",
173
+ maxRuntimeMs: C.TIME.seconds(10),
174
+ }));
175
+
176
+ var COMPLIANCE_POSTURES = Object.freeze({
177
+ "hipaa": Object.assign({}, PROFILES["strict"], {
178
+ forensicSnippetBytes: C.BYTES.bytes(256),
179
+ }),
180
+ "pci-dss": Object.assign({}, PROFILES["strict"], {
181
+ forensicSnippetBytes: C.BYTES.bytes(256),
182
+ }),
183
+ "gdpr": Object.assign({}, PROFILES["balanced"], {
184
+ forensicSnippetBytes: C.BYTES.bytes(128),
185
+ }),
186
+ "soc2-cc7": Object.assign({}, PROFILES["strict"], {
187
+ forensicSnippetBytes: C.BYTES.bytes(512),
188
+ }),
189
+ });
190
+
191
+ // ---- Helpers ----
192
+
193
+ function _resolveOpts(opts) {
194
+ return gateContract.resolveProfileAndPosture(opts, {
195
+ profiles: PROFILES,
196
+ compliancePostures: COMPLIANCE_POSTURES,
197
+ defaults: DEFAULTS,
198
+ errorClass: GuardYamlError,
199
+ errCodePrefix: "yaml",
200
+ });
201
+ }
202
+
203
+ function _isDangerousTag(tag) {
204
+ for (var i = 0; i < DANGEROUS_TAG_PREFIXES.length; i += 1) {
205
+ if (tag.indexOf(DANGEROUS_TAG_PREFIXES[i]) === 0) return true;
206
+ }
207
+ return false;
208
+ }
209
+
210
+ function _isSafeCoreTag(tag) {
211
+ return SAFE_CORE_TAGS.indexOf(tag) !== -1;
212
+ }
213
+
214
+ // _scanTags — find every tag-prefix occurrence in the source.
215
+ function _scanTags(text) {
216
+ var matches = [];
217
+ var iter = text.matchAll(/(^|\s)(![A-Za-z][\w./:-]*|!![A-Za-z][\w./:-]*)/g);
218
+ var m;
219
+ for (m of iter) {
220
+ var tag = m[2];
221
+ var kind;
222
+ if (_isDangerousTag(tag)) kind = "dangerous";
223
+ else if (_isSafeCoreTag(tag)) kind = "safe-core";
224
+ else kind = "custom";
225
+ matches.push({ tag: tag, location: m.index, kind: kind });
226
+ }
227
+ return matches;
228
+ }
229
+
230
+ function _detectIssues(input, opts) {
231
+ var issues = [];
232
+ if (typeof input !== "string") {
233
+ return [{ kind: "bad-input", severity: "high",
234
+ snippet: "input is not a string" }];
235
+ }
236
+ if (input.length > opts.maxBytes) {
237
+ return [{ kind: "too-large", severity: "high", ruleId: "yaml.too-large",
238
+ snippet: "input " + input.length +
239
+ " bytes exceeds maxBytes " + opts.maxBytes }];
240
+ }
241
+
242
+ // 1. Tag-injection scan.
243
+ var tagHits = _scanTags(input);
244
+ for (var ti = 0; ti < tagHits.length; ti += 1) {
245
+ var t = tagHits[ti];
246
+ if (t.kind === "dangerous") {
247
+ issues.push({
248
+ kind: "dangerous-tag", severity: "critical",
249
+ ruleId: "yaml.dangerous-tag",
250
+ location: t.location,
251
+ snippet: "deserialization-tag injection " + JSON.stringify(t.tag) +
252
+ " (CVE-2026-24009 / CVE-2022-1471 class)",
253
+ });
254
+ } else if (t.kind === "custom") {
255
+ if (opts.tagPolicy === "reject" ||
256
+ (opts.tagPolicy === "audit" && !opts.safeCoreTagsAllowed)) {
257
+ issues.push({
258
+ kind: "custom-tag",
259
+ severity: opts.tagPolicy === "reject" ? "critical" : "high",
260
+ ruleId: "yaml.custom-tag",
261
+ location: t.location,
262
+ snippet: "custom tag " + JSON.stringify(t.tag) +
263
+ " (suggests non-safe parser downstream)",
264
+ });
265
+ }
266
+ } else if (t.kind === "safe-core") {
267
+ if (opts.tagPolicy === "reject" || !opts.safeCoreTagsAllowed) {
268
+ issues.push({
269
+ kind: "core-tag",
270
+ severity: opts.tagPolicy === "reject" ? "high" : "warn",
271
+ ruleId: "yaml.core-tag",
272
+ location: t.location,
273
+ snippet: "YAML 1.2 core tag " + JSON.stringify(t.tag),
274
+ });
275
+ }
276
+ }
277
+ }
278
+
279
+ // 2. Anchor / alias recursion scan.
280
+ var anchors = [];
281
+ var aIter = input.matchAll(ANCHOR_DECL_RE);
282
+ var aM;
283
+ for (aM of aIter) anchors.push(aM[2]);
284
+ var aliases = [];
285
+ var alIter = input.matchAll(ALIAS_REF_RE);
286
+ var alM;
287
+ for (alM of alIter) aliases.push(alM[2]);
288
+ if (anchors.length > opts.maxAnchors) {
289
+ issues.push({
290
+ kind: "anchor-cap", severity: "high",
291
+ ruleId: "yaml.anchor-cap",
292
+ snippet: "anchor declarations " + anchors.length +
293
+ " exceeds maxAnchors " + opts.maxAnchors,
294
+ });
295
+ }
296
+ if ((anchors.length > 0 || aliases.length > 0) && opts.aliasPolicy === "reject") {
297
+ issues.push({
298
+ kind: "alias-disabled", severity: "critical",
299
+ ruleId: "yaml.alias",
300
+ snippet: "anchors/aliases refused under strict (billion-laughs vector — " +
301
+ "CVE-2026-27807 MarkUs class)",
302
+ });
303
+ }
304
+ // alias-amplification ratio: aliases / anchors. Billion-laughs shape
305
+ // is ratio >= 8. Independent of maxAnchors absolute cap (which is
306
+ // about overall load); ratio is about exponential expansion shape.
307
+ var ampRatio = aliases.length / Math.max(anchors.length, 1);
308
+ if (anchors.length >= 1 && ampRatio >= 8) { // allow:raw-byte-literal — multiplier ratio, not byte size
309
+ issues.push({
310
+ kind: "alias-explosion", severity: "critical",
311
+ ruleId: "yaml.alias-explosion",
312
+ snippet: "alias-reference count " + aliases.length +
313
+ " amplifies " + ampRatio.toFixed(1) +
314
+ "x against " + anchors.length + " anchor(s) (billion-laughs shape)",
315
+ });
316
+ }
317
+
318
+ // 3. Multi-document.
319
+ var docs = (input.match(/(^|\n)---\s/g) || []).length;
320
+ if (docs > 0 && opts.multiDocPolicy !== "allow") {
321
+ if (opts.multiDocPolicy === "reject" ||
322
+ (docs + 1) > opts.maxDocuments) {
323
+ issues.push({
324
+ kind: "multi-document",
325
+ severity: opts.multiDocPolicy === "reject" ? "critical" : "high",
326
+ ruleId: "yaml.multi-document",
327
+ snippet: "multi-document stream (" + (docs + 1) +
328
+ " docs) — first-doc-wins silently masks the rest",
329
+ });
330
+ }
331
+ }
332
+
333
+ // 4. Norway-problem implicit booleans.
334
+ if (opts.norwayPolicy !== "allow") {
335
+ var norwayIter = input.matchAll(NORWAY_BOOL_QUIRK_RE);
336
+ var norwayM;
337
+ var seen = false;
338
+ for (norwayM of norwayIter) {
339
+ if (!seen) {
340
+ issues.push({
341
+ kind: "norway-implicit-bool",
342
+ severity: opts.norwayPolicy === "reject" ? "critical" : "warn",
343
+ ruleId: "yaml.norway",
344
+ location: norwayM.index,
345
+ snippet: "implicit YAML 1.1 boolean " + JSON.stringify(norwayM[1]) +
346
+ " (Norway problem — country code 'NO' parses as false; " +
347
+ "quote scalars to disambiguate)",
348
+ });
349
+ seen = true;
350
+ }
351
+ }
352
+ }
353
+
354
+ // 5. Leading-zero octals.
355
+ if (opts.leadingZeroPolicy !== "allow") {
356
+ if (LEADING_ZERO_OCTAL_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxBytes above
357
+ issues.push({
358
+ kind: "leading-zero-octal",
359
+ severity: opts.leadingZeroPolicy === "reject" ? "high" : "warn",
360
+ ruleId: "yaml.leading-zero",
361
+ snippet: "leading-zero numeric (parses as octal in YAML 1.1)",
362
+ });
363
+ }
364
+ }
365
+
366
+ // 6. Merge-key chain depth.
367
+ if (opts.mergeKeyPolicy !== "allow" && MERGE_KEY_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxBytes above
368
+ issues.push({
369
+ kind: "merge-key",
370
+ severity: opts.mergeKeyPolicy === "reject" ? "high" : "warn",
371
+ ruleId: "yaml.merge-key",
372
+ snippet: "merge-key with anchor reference (anchor-chain DoS vector)",
373
+ });
374
+ }
375
+
376
+ // 7. Codepoint-class threats.
377
+ issues.push.apply(issues, codepointClass.detectCharThreats(input, opts, "yaml"));
378
+
379
+ // 8. Duplicate keys via per-indent block-mapping scan. Done BEFORE
380
+ // parse because some parsers refuse-on-duplicate, and we want the
381
+ // duplicate-key issue surfaced regardless of parser strictness.
382
+ if (opts.duplicateKeyPolicy !== "allow") {
383
+ var dups = _detectDuplicateKeysYaml(input);
384
+ for (var di = 0; di < dups.length; di += 1) {
385
+ issues.push({
386
+ kind: "duplicate-key",
387
+ severity: opts.duplicateKeyPolicy === "reject" ? "critical" : "warn",
388
+ ruleId: "yaml.duplicate-key",
389
+ snippet: "duplicate key " + JSON.stringify(dups[di]) +
390
+ " (YAML 1.2 SHOULD-unique; parsers silently last-wins)",
391
+ });
392
+ }
393
+ }
394
+
395
+ // 9. Try parse via b.parsers.yaml.
396
+ try {
397
+ safeYamlLazy().parse(input, {
398
+ maxBytes: opts.maxBytes,
399
+ maxDepth: opts.maxDepth,
400
+ maxKeys: opts.maxNodes,
401
+ });
402
+ } catch (e) {
403
+ issues.push({
404
+ kind: "parse-failed", severity: "critical", ruleId: "yaml.parse",
405
+ snippet: "YAML parse failed: " + (e && e.message),
406
+ });
407
+ }
408
+
409
+ return issues;
410
+ }
411
+
412
+ function _detectDuplicateKeysYaml(text) {
413
+ var dups = Object.create(null);
414
+ var lines = text.split(/\r?\n/);
415
+ var indentScopes = Object.create(null);
416
+ for (var i = 0; i < lines.length; i += 1) {
417
+ var line = lines[i];
418
+ if (line.length === 0 || /^\s*#/.test(line)) continue;
419
+ var indentMatch = line.match(/^(\s*)([^\s].*?):(\s|$)/);
420
+ if (!indentMatch) continue;
421
+ var indent = indentMatch[1].length;
422
+ var key = indentMatch[2].trim();
423
+ if (key.charAt(0) === "-" || key.charAt(0) === "[" || key.charAt(0) === "{") continue;
424
+ if (!indentScopes[indent]) indentScopes[indent] = Object.create(null);
425
+ if (indentScopes[indent][key]) dups[key] = true;
426
+ else indentScopes[indent][key] = true;
427
+ Object.keys(indentScopes).forEach(function (k) {
428
+ if (Number(k) > indent) delete indentScopes[k];
429
+ });
430
+ }
431
+ return Object.keys(dups);
432
+ }
433
+
434
+ // ---- Public surface ----
435
+
436
+ function validate(input, opts) {
437
+ opts = _resolveOpts(opts);
438
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
439
+ ["maxBytes", "maxDepth", "maxAnchors", "maxAliasDepth",
440
+ "maxDocuments", "maxNodes", "maxScalarLength"],
441
+ "guardYaml.validate", GuardYamlError, "yaml.bad-opt");
442
+ if (typeof input !== "string") {
443
+ return {
444
+ ok: false,
445
+ issues: [{ kind: "bad-input", severity: "high",
446
+ snippet: "input is not a string" }],
447
+ };
448
+ }
449
+ return gateContract.aggregateIssues(_detectIssues(input, opts));
450
+ }
451
+
452
+ function parse(input, opts) {
453
+ opts = _resolveOpts(opts);
454
+ if (typeof input !== "string") {
455
+ throw _err("yaml.bad-input", "parse requires string input");
456
+ }
457
+ var issues = _detectIssues(input, opts);
458
+ for (var i = 0; i < issues.length; i += 1) {
459
+ if (issues[i].severity === "critical") {
460
+ throw _err(issues[i].ruleId || "yaml.refused",
461
+ "guardYaml.parse: " + issues[i].snippet);
462
+ }
463
+ }
464
+ return safeYamlLazy().parse(input, {
465
+ maxBytes: opts.maxBytes,
466
+ maxDepth: opts.maxDepth,
467
+ maxKeys: opts.maxNodes,
468
+ });
469
+ }
470
+
471
+ function gate(opts) {
472
+ opts = _resolveOpts(opts);
473
+ return gateContract.buildGuardGate(
474
+ opts.name || "guardYaml:" + (opts.profile || "default"),
475
+ opts,
476
+ async function (ctx) {
477
+ var text = gateContract.extractBytesAsText(ctx);
478
+ if (!text) return { ok: true, action: "serve" };
479
+ var rv = validate(text, opts);
480
+ if (rv.issues.length === 0) return { ok: true, action: "serve" };
481
+ var hasCritical = rv.issues.some(function (i) {
482
+ return i.severity === "critical" || i.severity === "high";
483
+ });
484
+ if (!hasCritical) return { ok: true, action: "audit-only", issues: rv.issues };
485
+ return { ok: false, action: "refuse", issues: rv.issues };
486
+ });
487
+ }
488
+
489
+ var buildProfile = gateContract.makeProfileBuilder(PROFILES);
490
+
491
+ function compliancePosture(name) {
492
+ return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES, _err, "yaml");
493
+ }
494
+
495
+ var _yamlRulePacks = gateContract.makeRulePackLoader(GuardYamlError, "yaml");
496
+ var loadRulePack = _yamlRulePacks.load;
497
+
498
+ module.exports = {
499
+ // ---- guard-* family registry exports ----
500
+ NAME: "yaml",
501
+ KIND: "content",
502
+ MIME_TYPES: Object.freeze([
503
+ "application/yaml", "application/x-yaml", "text/yaml", "text/x-yaml",
504
+ ]),
505
+ EXTENSIONS: Object.freeze([".yml", ".yaml"]),
506
+ INTEGRATION_FIXTURES: Object.freeze({
507
+ kind: "content",
508
+ contentType: "application/yaml",
509
+ extension: ".yaml",
510
+ benignBytes: Buffer.from('name: alice\nage: 30\n', "utf8"),
511
+ // Hostile: deserialization-tag injection (CVE-2026-24009 PyYAML
512
+ // class). Parser-runtime would attempt to instantiate the named
513
+ // language-specific class.
514
+ hostileBytes: Buffer.from("!!python/object/new:cls\nargs: [\"x\"]\n", "utf8"),
515
+ }),
516
+ // ---- primitive surface ----
517
+ validate: validate,
518
+ parse: parse,
519
+ gate: gate,
520
+ buildProfile: buildProfile,
521
+ compliancePosture: compliancePosture,
522
+ loadRulePack: loadRulePack,
523
+ PROFILES: PROFILES,
524
+ DEFAULTS: DEFAULTS,
525
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
526
+ DANGEROUS_TAG_PREFIXES: DANGEROUS_TAG_PREFIXES,
527
+ SAFE_CORE_TAGS: SAFE_CORE_TAGS,
528
+ GuardYamlError: GuardYamlError,
529
+ };
package/lib/mail-dkim.js CHANGED
@@ -241,7 +241,17 @@ function create(opts) {
241
241
  "headersToSign[" + i + "] must be a non-empty string");
242
242
  }
243
243
  }
244
- validateOpts.optionalFiniteNonNegative(opts.bodyLength, "bodyLength", DkimError, "dkim/bad-body-length");
244
+ // The DKIM `l=` body-length tag is intentionally NOT supported.
245
+ // M³AAWG, Gmail, and Microsoft 365 guidance is "never use l=" — it
246
+ // enables append-after-signature attacks where an attacker appends
247
+ // arbitrary content past the signed length and the DKIM signature
248
+ // still validates against the original prefix. Throw at create-time
249
+ // so the misconfiguration surfaces at boot, not at first send().
250
+ if (opts.bodyLength !== undefined) {
251
+ throw new DkimError("dkim/l-tag-forbidden",
252
+ "DKIM `l=` body-length tag is forbidden — append-after-signature " +
253
+ "attack vector. Remove opts.bodyLength.");
254
+ }
245
255
 
246
256
  var auditOn = opts.audit !== false;
247
257
  // Try to parse the private key once at create time so misconfigured
@@ -282,11 +292,9 @@ function create(opts) {
282
292
  var split = _splitHeadersBody(rfc822);
283
293
  var parsedHeaders = _parseHeaders(split.headers);
284
294
 
285
- // Body hash
295
+ // Body hash. The `l=` body-length tag is forbidden at create-time
296
+ // (above), so the body is always hashed in full.
286
297
  var body = split.body;
287
- if (opts.bodyLength !== undefined) {
288
- body = body.slice(0, opts.bodyLength);
289
- }
290
298
  var bh = _bodyHashB64(body, algorithm, canonBody);
291
299
 
292
300
  // Build the unsigned DKIM-Signature header (b= empty).
@@ -300,7 +308,6 @@ function create(opts) {
300
308
  "h=" + headersToSign.join(":"),
301
309
  "bh=" + bh,
302
310
  ];
303
- if (opts.bodyLength !== undefined) sigTags.push("l=" + opts.bodyLength);
304
311
  sigTags.push("b=");
305
312
  var unsignedSigValue = sigTags.join("; ");
306
313
 
package/lib/mail.js CHANGED
@@ -68,6 +68,7 @@ var lazyRequire = require("./lazy-require");
68
68
  var safeBuffer = require("./safe-buffer");
69
69
  var audit = lazyRequire(function () { return require("./audit"); });
70
70
  var httpClient = lazyRequire(function () { return require("./http-client"); });
71
+ var guardEmail = lazyRequire(function () { return require("./guard-email"); });
71
72
  var mailDkim = require("./mail-dkim");
72
73
  var net = lazyRequire(function () { return require("net"); });
73
74
  var tls = lazyRequire(function () { return require("tls"); });
@@ -533,6 +534,24 @@ function _smtpSend(message, cfg) {
533
534
  }
534
535
  }
535
536
 
537
+ // Outbound SMTP-smuggling defense — refuse before opening the
538
+ // socket if the produced RFC 822 wire contains the bare-CR / bare-
539
+ // LF + smuggled-verb shape (CVE-2023-51764 / 51765 / 51766 class).
540
+ // Operator-supplied subject / body / headers can sneak the pattern
541
+ // through _buildRfc822 if the input wasn't already gated.
542
+ var rv = guardEmail().validateMessage(dataMessage, { profile: "strict" });
543
+ if (!rv.ok) {
544
+ var critical = rv.issues.filter(function (i) {
545
+ return i.severity === "critical";
546
+ });
547
+ if (critical.length > 0) {
548
+ reject(new MailError("mail/outbound-smuggling-refused",
549
+ "outbound RFC 822 wire failed guardEmail: " +
550
+ critical.map(function (i) { return i.kind; }).join(","), true));
551
+ return;
552
+ }
553
+ }
554
+
536
555
  function fail(reason) {
537
556
  if (settled) return;
538
557
  settled = true;