@blamejs/core 0.18.51 → 0.18.54
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 +185 -0
- package/NOTICE +1 -1
- package/README.md +3 -3
- package/lib/ai-adverse-decision.js +18 -2
- package/lib/codepoint-class.js +72 -0
- package/lib/cookies.js +7 -10
- package/lib/credential-hash.js +8 -1
- package/lib/crypto.js +7 -5
- package/lib/guard-auth.js +34 -11
- package/lib/guard-filename.js +39 -16
- package/lib/guard-managesieve-command.js +49 -9
- package/lib/guard-regex.js +3 -5
- package/lib/guard-yaml.js +212 -31
- package/lib/mail-agent.js +23 -9
- package/lib/mail-arc-sign.js +40 -7
- package/lib/mail-auth.js +75 -20
- package/lib/mail-crypto-pgp.js +1 -1
- package/lib/mail-dkim.js +80 -11
- package/lib/mail-helo.js +10 -0
- package/lib/mail-rbl.js +10 -3
- package/lib/mail-send-deliver.js +151 -32
- package/lib/mail-server-imap.js +121 -55
- package/lib/mail-server-jmap.js +31 -4
- package/lib/mail-server-managesieve.js +168 -25
- package/lib/mail-server-mx.js +76 -4
- package/lib/mail-server-net.js +126 -0
- package/lib/mail-server-pop3.js +73 -22
- package/lib/mail-server-submission.js +21 -2
- package/lib/mail-store.js +33 -11
- package/lib/mail.js +355 -17
- package/lib/middleware/bearer-auth.js +6 -1
- package/lib/middleware/fetch-metadata.js +5 -1
- package/lib/middleware/headers.js +7 -10
- package/lib/network-dns-resolver.js +71 -8
- package/lib/network-dns.js +26 -0
- package/lib/network-smtp-policy.js +42 -10
- package/lib/parsers/safe-yaml.js +24 -3
- package/lib/redact.js +13 -3
- package/lib/retention.js +22 -2
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +278 -40
- package/lib/yaml-lex.js +587 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/guard-filename.js
CHANGED
|
@@ -253,15 +253,13 @@ function _foldSuperscriptDigits(s) {
|
|
|
253
253
|
// A `..` segment: the whole name, or bounded by path separators on both sides.
|
|
254
254
|
// Bounded, not merely present — `..foo` and `a..b` are ordinary names.
|
|
255
255
|
function _hasTraversalSegment(name) {
|
|
256
|
-
|
|
257
|
-
if (name.charAt(i) !== "." || name.charAt(i + 1) !== ".") continue;
|
|
256
|
+
return codepointClass.hasPairWhere(name, ".", ".", function (i) {
|
|
258
257
|
var beforeOk = i === 0 ||
|
|
259
258
|
PATH_SEPARATORS.indexOf(name.charAt(i - 1)) !== -1;
|
|
260
259
|
var afterOk = i + 2 === name.length ||
|
|
261
260
|
PATH_SEPARATORS.indexOf(name.charAt(i + 2)) !== -1;
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
return false;
|
|
261
|
+
return beforeOk && afterOk;
|
|
262
|
+
});
|
|
265
263
|
}
|
|
266
264
|
|
|
267
265
|
function _hasAnyFolded(name, needles) {
|
|
@@ -280,6 +278,15 @@ function _hasUncPrefix(name) {
|
|
|
280
278
|
|
|
281
279
|
// An NTFS alternate-data-stream suffix: a colon followed by a run with no
|
|
282
280
|
// further colon and no separator in it, at the very end of the name.
|
|
281
|
+
// One wording for every path that refuses a stream-suffixed name — sanitize in
|
|
282
|
+
// either mode, validate, gate, verifyExtractionPath — so a caller meets the same
|
|
283
|
+
// answer whichever door they arrive at, and the way out is named where they are
|
|
284
|
+
// standing rather than somewhere else in the documentation.
|
|
285
|
+
var ADS_SNIPPET = "NTFS alternate data stream syntax (name:stream); set " +
|
|
286
|
+
"adsPolicy \"allow\" when the target filesystem is not NTFS and a colon is " +
|
|
287
|
+
"an ordinary filename character there";
|
|
288
|
+
var ADS_MESSAGE = "filename contains " + ADS_SNIPPET;
|
|
289
|
+
|
|
283
290
|
function _hasAdsSuffix(name) {
|
|
284
291
|
var colon = name.lastIndexOf(":");
|
|
285
292
|
if (colon === -1 || colon === name.length - 1) return false;
|
|
@@ -461,15 +468,17 @@ function _detectIssues(input, opts) {
|
|
|
461
468
|
|
|
462
469
|
// 6. NTFS alternate data streams — `name:stream`. Unconditional: a write to
|
|
463
470
|
// `name:stream` lands on a hidden stream of the base file rather than the
|
|
464
|
-
// file the caller named
|
|
471
|
+
// file the caller named — on Windows. On the Linux targets `adsPolicy`
|
|
472
|
+
// exists for, a colon is an ordinary filename character, so the operator
|
|
473
|
+
// holds the switch and the finding is suppressed when they set it.
|
|
465
474
|
{
|
|
466
|
-
if (_hasAdsSuffix(name) && name.charAt(0) !== "/") {
|
|
475
|
+
if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name) && name.charAt(0) !== "/") {
|
|
467
476
|
// Only flag when there's a `:` followed by stream-name characters
|
|
468
477
|
// and we're NOT at the start (relative path indicator).
|
|
469
478
|
issues.push({
|
|
470
479
|
kind: "ntfs-ads", severity: "critical",
|
|
471
480
|
ruleId: "filename.ntfs-ads",
|
|
472
|
-
snippet:
|
|
481
|
+
snippet: ADS_SNIPPET,
|
|
473
482
|
});
|
|
474
483
|
}
|
|
475
484
|
}
|
|
@@ -660,9 +669,22 @@ function _sanitize(input, opts) {
|
|
|
660
669
|
name = "_" + name;
|
|
661
670
|
}
|
|
662
671
|
|
|
663
|
-
// ADS detection.
|
|
664
|
-
|
|
665
|
-
|
|
672
|
+
// ADS detection. On Windows a `name:stream` write lands on a hidden stream of
|
|
673
|
+
// the base file rather than on a file anyone can see, so this is refused by
|
|
674
|
+
// default in every profile.
|
|
675
|
+
//
|
|
676
|
+
// It is an opt-out rather than an absolute, because the check is lexical and
|
|
677
|
+
// a colon is an ordinary filename character on the Linux targets these
|
|
678
|
+
// policies exist for: `12:30 notes.txt` has the same shape as an attack and
|
|
679
|
+
// is a timestamped note (#623). Only the operator knows which filesystem the
|
|
680
|
+
// name will be written to, which is why they get the switch.
|
|
681
|
+
//
|
|
682
|
+
// An earlier attempt refused regardless and put the scope in the message,
|
|
683
|
+
// which reads as a considered boundary right up until a caller has set every
|
|
684
|
+
// documented opt-out and still cannot store the file. An option that is
|
|
685
|
+
// accepted and documented as opting out must opt out.
|
|
686
|
+
if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name)) {
|
|
687
|
+
throw _err("filename.ntfs-ads", ADS_MESSAGE);
|
|
666
688
|
}
|
|
667
689
|
|
|
668
690
|
// Length cap.
|
|
@@ -713,9 +735,10 @@ function _sanitize(input, opts) {
|
|
|
713
735
|
* // than refusing is safe
|
|
714
736
|
* reservedCharPolicy: "reject"|"strip"|"allow",
|
|
715
737
|
* reservedNamePolicy: "reject"|"audit"|"allow",
|
|
716
|
-
* adsPolicy: "reject"|"allow", //
|
|
717
|
-
* // is
|
|
718
|
-
* //
|
|
738
|
+
* adsPolicy: "reject"|"allow", // "allow" when the target
|
|
739
|
+
* // filesystem is not NTFS
|
|
740
|
+
* // and a colon is ordinary
|
|
741
|
+
* // there (Linux, macOS)
|
|
719
742
|
* leadingTrailingPolicy: "reject"|"strip"|"allow",
|
|
720
743
|
* shellExecExtPolicy: "reject"|"audit"|"allow",
|
|
721
744
|
* pathSeparatorsPolicy: "reject"|"audit"|"allow",
|
|
@@ -795,8 +818,8 @@ function _sanitizeStripMode(input, opts) {
|
|
|
795
818
|
if (_hasUncPrefix(name)) {
|
|
796
819
|
throw _err("filename.unc", "UNC path syntax");
|
|
797
820
|
}
|
|
798
|
-
if (_hasAdsSuffix(name) && name.charAt(0) !== "/") {
|
|
799
|
-
throw _err("filename.ntfs-ads",
|
|
821
|
+
if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name) && name.charAt(0) !== "/") {
|
|
822
|
+
throw _err("filename.ntfs-ads", ADS_MESSAGE);
|
|
800
823
|
}
|
|
801
824
|
if (Buffer.byteLength(name, "utf8") > opts.maxBytes) {
|
|
802
825
|
throw _err("filename.length", "filename exceeds maxBytes " + opts.maxBytes);
|
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
* under strict (RFC 4954 §4 class), validates per-verb shape.
|
|
103
103
|
*/
|
|
104
104
|
|
|
105
|
+
var C = require("./constants");
|
|
105
106
|
var { defineClass } = require("./framework-error");
|
|
106
107
|
var gateContract = require("./gate-contract");
|
|
107
108
|
var codepointClass = require("./codepoint-class");
|
|
@@ -112,6 +113,12 @@ var GuardManageSieveCommandError = defineClass("GuardManageSieveCommandError",
|
|
|
112
113
|
|
|
113
114
|
var DEFAULT_PROFILE = "strict";
|
|
114
115
|
|
|
116
|
+
// A SASL token — the AUTHENTICATE initial response and every later response in
|
|
117
|
+
// a multi-step exchange. Bounded far below the script cap because it is a
|
|
118
|
+
// base64 blob of credentials, not a script body, and because the client
|
|
119
|
+
// declaring its size is by definition not yet authenticated.
|
|
120
|
+
var MAX_SASL_TOKEN_BYTES = C.BYTES.kib(4);
|
|
121
|
+
|
|
115
122
|
var PROFILES = Object.freeze({
|
|
116
123
|
strict: {
|
|
117
124
|
maxLineBytes: 8192, // 8 KiB per-line cap (strict)
|
|
@@ -339,13 +346,15 @@ function _validateAuthenticate(rest, caps, profileName, opts) {
|
|
|
339
346
|
throw new GuardManageSieveCommandError("guard-managesieve-command/literal-plus-refused",
|
|
340
347
|
"guardManageSieveCommand.validate: LITERAL+ refused under profile '" + profileName + "'");
|
|
341
348
|
}
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
349
|
+
// A SASL token, not a script body, so it is bounded well below the
|
|
350
|
+
// script cap. The same number bounds the client's LATER responses in a
|
|
351
|
+
// multi-step exchange (b.mail.server.managesieve reads it from
|
|
352
|
+
// MAX_SASL_TOKEN_BYTES): one exchange, one bound, whichever round the
|
|
353
|
+
// token arrives on and whichever representation it uses.
|
|
354
|
+
if (n > MAX_SASL_TOKEN_BYTES) {
|
|
346
355
|
throw new GuardManageSieveCommandError("guard-managesieve-command/literal-too-large",
|
|
347
356
|
"guardManageSieveCommand.validate: AUTHENTICATE initial-response " +
|
|
348
|
-
n + " bytes exceeds
|
|
357
|
+
n + " bytes exceeds " + MAX_SASL_TOKEN_BYTES + "-byte cap");
|
|
349
358
|
}
|
|
350
359
|
literalBytes = n;
|
|
351
360
|
literalPlus = isPlus;
|
|
@@ -478,10 +487,34 @@ function _validateRenamescript(rest, caps) {
|
|
|
478
487
|
return { verb: "RENAMESCRIPT", args: [first.value, second.value] };
|
|
479
488
|
}
|
|
480
489
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
490
|
+
/**
|
|
491
|
+
* @primitive b.guardManageSieveCommand.parseQuotedString
|
|
492
|
+
* @signature b.guardManageSieveCommand.parseQuotedString(s)
|
|
493
|
+
* @since 0.18.54
|
|
494
|
+
* @status stable
|
|
495
|
+
* @related b.guardManageSieveCommand.validate, b.mail.server.managesieve.create
|
|
496
|
+
*
|
|
497
|
+
* Read a leading RFC 5804 §1.2 quoted string off `s` and return
|
|
498
|
+
* `{ value, rest }`, where `value` is the unescaped content and `rest` is
|
|
499
|
+
* what follows with leading whitespace removed. Returns `null` when `s` does
|
|
500
|
+
* not begin with a double quote.
|
|
501
|
+
*
|
|
502
|
+
* The production honours the `\"` and `\\` escapes, and refuses NUL, CR and
|
|
503
|
+
* LF inside the quotes: those end a line-oriented protocol record, so a
|
|
504
|
+
* string carrying one would split the command it appears in.
|
|
505
|
+
*
|
|
506
|
+
* Exposed because a `string` also arrives outside a command, as the client's
|
|
507
|
+
* reply to a SASL challenge. That line never reaches `validate`, and a second
|
|
508
|
+
* hand-rolled unquoting there would be free to lose the escape handling and
|
|
509
|
+
* the control-byte refusal this one has. `b.mail.server.managesieve` reads
|
|
510
|
+
* its SASL responses through this.
|
|
511
|
+
*
|
|
512
|
+
* @example
|
|
513
|
+
* b.guardManageSieveCommand.parseQuotedString('"PLAIN" {12+}');
|
|
514
|
+
* // → { value: "PLAIN", rest: "{12+}" }
|
|
515
|
+
* b.guardManageSieveCommand.parseQuotedString("PLAIN");
|
|
516
|
+
* // → null
|
|
517
|
+
*/
|
|
485
518
|
function _parseQuotedString(s) {
|
|
486
519
|
if (s.length === 0 || s.charCodeAt(0) !== 0x22) return null; // DQUOTE
|
|
487
520
|
var out = "";
|
|
@@ -572,5 +605,12 @@ module.exports = gateContract.defineParser({
|
|
|
572
605
|
extra: {
|
|
573
606
|
KNOWN_VERBS: KNOWN_VERBS,
|
|
574
607
|
ZERO_ARG_VERBS: ZERO_ARG_VERBS,
|
|
608
|
+
MAX_SASL_TOKEN_BYTES: MAX_SASL_TOKEN_BYTES,
|
|
609
|
+
// The RFC 5804 §1.2 "string" production. The listener needs it for the
|
|
610
|
+
// client's reply to a SASL challenge, which arrives as a bare string on its
|
|
611
|
+
// own line rather than as a command argument — so it never reaches
|
|
612
|
+
// validate(), and hand-rolling a second dquote-strip there would drop this
|
|
613
|
+
// one's escape handling and its refusal of NUL / CR / LF inside the quotes.
|
|
614
|
+
parseQuotedString: _parseQuotedString,
|
|
575
615
|
},
|
|
576
616
|
});
|
package/lib/guard-regex.js
CHANGED
|
@@ -454,8 +454,7 @@ function _scanBraces(src, at) {
|
|
|
454
454
|
// running the construct it screens over operator-supplied text, which is the
|
|
455
455
|
// shape this module exists to keep away from.
|
|
456
456
|
function _turnsFoldingOn(src) {
|
|
457
|
-
|
|
458
|
-
if (src.charAt(i) !== "(" || src.charAt(i + 1) !== "?") continue;
|
|
457
|
+
return codepointClass.hasPairWhere(src, "(", "?", function (i) {
|
|
459
458
|
var at = i + 2;
|
|
460
459
|
var enablesFold = false;
|
|
461
460
|
while (at < src.length && _isFlagLetter(src.charAt(at))) {
|
|
@@ -466,9 +465,8 @@ function _turnsFoldingOn(src) {
|
|
|
466
465
|
at += 1;
|
|
467
466
|
while (at < src.length && _isFlagLetter(src.charAt(at))) at += 1;
|
|
468
467
|
}
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
return false;
|
|
468
|
+
return enablesFold && src.charAt(at) === ":";
|
|
469
|
+
});
|
|
472
470
|
}
|
|
473
471
|
|
|
474
472
|
function _parsePattern(src, flags, budget) {
|
package/lib/guard-yaml.js
CHANGED
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
*/
|
|
69
69
|
|
|
70
70
|
var codepointClass = require("./codepoint-class");
|
|
71
|
+
var yamlLex = require("./yaml-lex");
|
|
71
72
|
var lazyRequire = require("./lazy-require");
|
|
72
73
|
var gateContract = require("./gate-contract");
|
|
73
74
|
var C = require("./constants");
|
|
@@ -95,11 +96,11 @@ var SAFE_CORE_TAGS = Object.freeze([
|
|
|
95
96
|
"!!binary", "!!timestamp", "!!merge",
|
|
96
97
|
]);
|
|
97
98
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
// There is no table of characters an anchor or alias is allowed to follow any
|
|
100
|
+
// more. Two of them existed, one per sigil, and each was a guess at YAML's
|
|
101
|
+
// grammar written as a character class: they admitted a sigil in the middle of
|
|
102
|
+
// a plain scalar and excluded the compact flow form `{"a":&anchor v}` that YAML
|
|
103
|
+
// permits. Position is decided once, by the shared lexer's mask.
|
|
103
104
|
|
|
104
105
|
// The YAML 1.1 boolean-shaped tokens that make an unquoted scalar change type.
|
|
105
106
|
// `true` and `false` are valid YAML 1.2 booleans and are not flagged; these
|
|
@@ -119,14 +120,17 @@ function _isSpace(cc) {
|
|
|
119
120
|
|
|
120
121
|
// Every `&name` / `*name` in the document, given the sigil and the characters
|
|
121
122
|
// that may precede it. Returns the names, in order.
|
|
122
|
-
|
|
123
|
+
// `text` is the MASK, in which a sigil survives only where it opens a node, so
|
|
124
|
+
// there is no test on the preceding character here at all. That judgement used
|
|
125
|
+
// to be a hand-written list of characters a sigil was allowed to follow, kept
|
|
126
|
+
// separately for anchors and for aliases, and it was wrong in both directions:
|
|
127
|
+
// it admitted a `&` in the middle of a plain scalar, and it had no way to allow
|
|
128
|
+
// `{"a":&anchor v}`, where YAML's JSON compatibility lets a quoted key take its
|
|
129
|
+
// colon with no space after it. One scanner decides position now.
|
|
130
|
+
function _collectSigilNames(text, sigil) {
|
|
123
131
|
var out = [];
|
|
124
132
|
for (var i = 0; i < text.length; i += 1) {
|
|
125
133
|
if (text.charAt(i) !== sigil) continue;
|
|
126
|
-
if (i > 0) {
|
|
127
|
-
var lead = text.charCodeAt(i - 1);
|
|
128
|
-
if (!_isSpace(lead) && leadChars.indexOf(text.charAt(i - 1)) === -1) continue;
|
|
129
|
-
}
|
|
130
134
|
if (!_isNameStart(text.charCodeAt(i + 1))) continue;
|
|
131
135
|
var end = i + 2;
|
|
132
136
|
while (end < text.length && _isNameChar(text.charCodeAt(end))) end += 1;
|
|
@@ -175,17 +179,33 @@ function _hasLeadingZeroOctal(text) {
|
|
|
175
179
|
|
|
176
180
|
// A merge key with an anchor reference — `<<` then optional whitespace, `:`,
|
|
177
181
|
// optional whitespace, `*`.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
182
|
+
// A merge key is `<<: *anchor` — a mapping key, so a structural question, and
|
|
183
|
+
// asked of the lexer rather than of the source.
|
|
184
|
+
//
|
|
185
|
+
// The test is whether a NODE can begin at the `<<`, not whether what follows is
|
|
186
|
+
// well-formed: `<<:*d` with no space after the colon is not a mapping entry and
|
|
187
|
+
// is reported anyway, because that is the shape being smuggled past a parser
|
|
188
|
+
// variant that reads it as a merge. A node cannot begin inside a comment, a
|
|
189
|
+
// quoted body, a block scalar's body, or a plain scalar's continuation line, so
|
|
190
|
+
// one question covers all of them.
|
|
191
|
+
//
|
|
192
|
+
// Enumerating those regions instead was tried and is the wrong shape. Without
|
|
193
|
+
// any of this, `<<: *base` written inside a block scalar — a line of someone's
|
|
194
|
+
// shell script — was reported as a merge key; excluding block bodies and quoted
|
|
195
|
+
// bodies then still reported one written in a comment, on a continuation line,
|
|
196
|
+
// and after a document marker. Same root as the duplicate-key screen (#642): a
|
|
197
|
+
// structural rule reading raw source, and a fix that lists regions is the same
|
|
198
|
+
// mistake with a longer list.
|
|
199
|
+
function _hasMergeKeyAlias(text, nodeStarts) {
|
|
200
|
+
return codepointClass.hasPairWhere(text, "<", "<", function (i) {
|
|
201
|
+
if (nodeStarts && !nodeStarts[i]) return false;
|
|
181
202
|
var j = i + 2;
|
|
182
203
|
while (j < text.length && _isSpace(text.charCodeAt(j))) j += 1;
|
|
183
|
-
if (text.charAt(j) !== ":")
|
|
204
|
+
if (text.charAt(j) !== ":") return false;
|
|
184
205
|
j += 1;
|
|
185
206
|
while (j < text.length && _isSpace(text.charCodeAt(j))) j += 1;
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
return false;
|
|
207
|
+
return text.charAt(j) === "*";
|
|
208
|
+
});
|
|
189
209
|
}
|
|
190
210
|
|
|
191
211
|
// ---- Profile presets ----
|
|
@@ -325,9 +345,11 @@ function _scanTags(text) {
|
|
|
325
345
|
var tags = [];
|
|
326
346
|
for (var i = 0; i < text.length; i += 1) {
|
|
327
347
|
if (text.charAt(i) !== "!") continue;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
348
|
+
// No "preceded by whitespace" test. That WAS the original defect, and once
|
|
349
|
+
// the mask decides position it is not merely redundant but harmful: in
|
|
350
|
+
// `{"a":!!python/object x}` the character before the tag is the colon of a
|
|
351
|
+
// JSON-style key, so the test skipped it and a deserialization tag reached
|
|
352
|
+
// both screens unreported. Position is the mask's question now.
|
|
331
353
|
var nameAt = text.charAt(i + 1) === "!" ? i + 2 : i + 1;
|
|
332
354
|
if (!codepointClass.isAsciiLetter(text.charCodeAt(nameAt))) continue;
|
|
333
355
|
var end = nameAt + 1;
|
|
@@ -354,8 +376,29 @@ function _detectIssues(input, opts) {
|
|
|
354
376
|
if (pre.done) return pre.issues;
|
|
355
377
|
var issues = pre.issues;
|
|
356
378
|
|
|
379
|
+
// Sigil scans run against the MASK, not the source. A `!`, `&` or `*` means
|
|
380
|
+
// what it looks like only at a node start, and the previous rule — "after
|
|
381
|
+
// whitespace" — cannot tell a node start from the middle of a scalar. It
|
|
382
|
+
// reported a tag for the bang in a comment, in a quoted string, in a block
|
|
383
|
+
// scalar's shell script, and in ordinary prose: `x: 1 # note !bang` was
|
|
384
|
+
// refused. The mask is index-aligned with the source and the same length, so
|
|
385
|
+
// every location and line number reported below is still the source's.
|
|
386
|
+
//
|
|
387
|
+
// Every STRUCTURAL screen uses it: the three sigil scans, the duplicate-key
|
|
388
|
+
// scan, and the merge key. The value-shaped detectors (the Norway problem,
|
|
389
|
+
// leading zeros) are asking about scalar CONTENT, which is exactly what the
|
|
390
|
+
// mask removes, so those keep reading the source.
|
|
391
|
+
//
|
|
392
|
+
// Merge keys were on the wrong side of that line, listed with the
|
|
393
|
+
// value-shaped ones: `<<:` is a mapping key, and reading it from the source
|
|
394
|
+
// reported one written inside a block scalar. The test is not what the rule
|
|
395
|
+
// is looking for but what kind of question it asks — "is this a mapping
|
|
396
|
+
// entry?" is structure however scalar-shaped the token looks.
|
|
397
|
+
var lexed = yamlLex.lexLines(input);
|
|
398
|
+
var masked = lexed.masked;
|
|
399
|
+
|
|
357
400
|
// 1. Tag-injection scan.
|
|
358
|
-
var tagHits = _scanTags(
|
|
401
|
+
var tagHits = _scanTags(masked);
|
|
359
402
|
for (var ti = 0; ti < tagHits.length; ti += 1) {
|
|
360
403
|
var t = tagHits[ti];
|
|
361
404
|
if (t.kind === "dangerous") {
|
|
@@ -392,8 +435,8 @@ function _detectIssues(input, opts) {
|
|
|
392
435
|
}
|
|
393
436
|
|
|
394
437
|
// 2. Anchor / alias recursion scan.
|
|
395
|
-
var anchors = _collectSigilNames(
|
|
396
|
-
var aliases = _collectSigilNames(
|
|
438
|
+
var anchors = _collectSigilNames(masked, "&");
|
|
439
|
+
var aliases = _collectSigilNames(masked, "*");
|
|
397
440
|
if (anchors.length > opts.maxAnchors) {
|
|
398
441
|
issues.push({
|
|
399
442
|
kind: "anchor-cap", severity: "high",
|
|
@@ -468,7 +511,8 @@ function _detectIssues(input, opts) {
|
|
|
468
511
|
}
|
|
469
512
|
|
|
470
513
|
// 6. Merge-key chain depth.
|
|
471
|
-
if (opts.mergeKeyPolicy !== "allow" &&
|
|
514
|
+
if (opts.mergeKeyPolicy !== "allow" &&
|
|
515
|
+
_hasMergeKeyAlias(input, lexed.nodeStarts)) {
|
|
472
516
|
issues.push({
|
|
473
517
|
kind: "merge-key",
|
|
474
518
|
severity: opts.mergeKeyPolicy === "reject" ? "high" : "warn",
|
|
@@ -517,12 +561,25 @@ function _detectIssues(input, opts) {
|
|
|
517
561
|
// FIRST colon that is followed by whitespace or ends the line, which is where
|
|
518
562
|
// a value begins — a colon inside the key (a timestamp, a URL) does not end
|
|
519
563
|
// it unless whitespace follows.
|
|
520
|
-
|
|
564
|
+
// `masked` is the same line with every non-structural region blanked by
|
|
565
|
+
// yaml-lex. It decides WHICH colons are mapping separators; the raw line
|
|
566
|
+
// supplies the key text, because the mask blanks that too.
|
|
567
|
+
//
|
|
568
|
+
// Without it, a colon inside a block scalar's body or a quoted value read as a
|
|
569
|
+
// mapping entry, and two such lines inside one scalar were reported as a
|
|
570
|
+
// duplicate key (#642). That is the same root as #631/#632 — a screen deciding
|
|
571
|
+
// structure by reading the source — which survived here because only the sigil
|
|
572
|
+
// scans were moved onto the mask.
|
|
573
|
+
function _mappingEntryAt(line, masked) {
|
|
521
574
|
var indent = 0;
|
|
522
575
|
while (indent < line.length && _isSpace(line.charCodeAt(indent))) indent += 1;
|
|
523
576
|
if (indent === line.length) return null;
|
|
524
577
|
for (var i = indent; i < line.length; i += 1) {
|
|
525
578
|
if (line.charAt(i) !== ":") continue;
|
|
579
|
+
// Structural only: the mask keeps a mapping separator and blanks a colon
|
|
580
|
+
// that is part of a scalar. Absent a mask, every colon counts, which is the
|
|
581
|
+
// pre-#642 behaviour and is what the non-masked callers still want.
|
|
582
|
+
if (masked !== undefined && masked.charAt(i) !== ":") continue;
|
|
526
583
|
var after = line.charCodeAt(i + 1);
|
|
527
584
|
if (i + 1 < line.length && !_isSpace(after)) continue;
|
|
528
585
|
if (i === indent) return null; // no key before the colon
|
|
@@ -531,6 +588,24 @@ function _mappingEntryAt(line) {
|
|
|
531
588
|
return null;
|
|
532
589
|
}
|
|
533
590
|
|
|
591
|
+
// The column the line's content starts in.
|
|
592
|
+
function _indentOfLine(line) {
|
|
593
|
+
var i = 0;
|
|
594
|
+
while (i < line.length && _isSpace(line.charCodeAt(i))) i += 1;
|
|
595
|
+
return i;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// The indent of a sequence dash opening this line, or -1 when the line does not
|
|
599
|
+
// open a sequence item. A dash counts only when it stands alone as a token: `-`
|
|
600
|
+
// at end of line, or followed by a space. `-quux` and `-1` are scalars.
|
|
601
|
+
function _sequenceDashIndent(line) {
|
|
602
|
+
var i = 0;
|
|
603
|
+
while (i < line.length && _isSpace(line.charCodeAt(i))) i += 1;
|
|
604
|
+
if (i >= line.length || line.charAt(i) !== "-") return -1;
|
|
605
|
+
if (i + 1 < line.length && !_isSpace(line.charCodeAt(i + 1))) return -1;
|
|
606
|
+
return i;
|
|
607
|
+
}
|
|
608
|
+
|
|
534
609
|
// Is the line blank, or a comment?
|
|
535
610
|
function _isCommentLine(line) {
|
|
536
611
|
var i = 0;
|
|
@@ -543,20 +618,126 @@ var _splitLines = codepointClass.splitLines;
|
|
|
543
618
|
function _detectDuplicateKeysYaml(text) {
|
|
544
619
|
var dups = Object.create(null);
|
|
545
620
|
var lines = _splitLines(text);
|
|
621
|
+
// Whether a line carries a mapping entry is a STRUCTURAL question, so it is
|
|
622
|
+
// asked of the lexer's mask rather than of the source. Split the same way, so
|
|
623
|
+
// line i of one is line i of the other.
|
|
624
|
+
var maskedLines = _splitLines(yamlLex.maskNonStructural(text));
|
|
546
625
|
var indentScopes = Object.create(null);
|
|
626
|
+
// The sequence items currently open, outermost first, each with the column
|
|
627
|
+
// its first key sat in. Every key of an item at its top level is filed under
|
|
628
|
+
// one scope whatever column it is written in, because it is one mapping.
|
|
629
|
+
//
|
|
630
|
+
// A STACK, because sequences nest. Holding the innermost item in a pair of
|
|
631
|
+
// variables let a nested sequence overwrite its parent's scope and never give
|
|
632
|
+
// it back, so a key repeated in the OUTER item after the nested one closed
|
|
633
|
+
// was filed somewhere else and went unreported:
|
|
634
|
+
//
|
|
635
|
+
// - a: 1
|
|
636
|
+
// inner:
|
|
637
|
+
// - x: 1
|
|
638
|
+
// a: 2 <- a duplicate of the first `a`, and it was missed
|
|
639
|
+
//
|
|
640
|
+
// The same shape as the defect this detector reports, one level up: a single
|
|
641
|
+
// slot standing in for something there can be several of.
|
|
642
|
+
var itemStack = [];
|
|
547
643
|
for (var i = 0; i < lines.length; i += 1) {
|
|
548
644
|
var line = lines[i];
|
|
549
645
|
if (line.length === 0 || _isCommentLine(line)) continue;
|
|
550
|
-
|
|
646
|
+
// A sequence item OPENS A NEW MAPPING, so it ends the previous item's and
|
|
647
|
+
// everything nested inside it. That is decided from the DASH, before
|
|
648
|
+
// anything else, because a line may be a sequence item and carry no mapping
|
|
649
|
+
// entry of its own:
|
|
650
|
+
//
|
|
651
|
+
// steps:
|
|
652
|
+
// -
|
|
653
|
+
// p: 1
|
|
654
|
+
// -
|
|
655
|
+
// p: 2
|
|
656
|
+
//
|
|
657
|
+
// A dash alone has no `key: value` on it, so a reset that waited for one
|
|
658
|
+
// never ran and the second item's `p` was still read as a duplicate of the
|
|
659
|
+
// first's. The boundary is the dash; whether the item writes its first key
|
|
660
|
+
// beside it or underneath it is a matter of layout.
|
|
661
|
+
var dashAt = _sequenceDashIndent(line);
|
|
662
|
+
// An item is over once the document comes back out to its dash's column or
|
|
663
|
+
// further left. That is true of the next item's own dash as much as of a
|
|
664
|
+
// key belonging to the enclosing mapping, so it is measured here, before
|
|
665
|
+
// this line is classified at all.
|
|
666
|
+
var lineIndent = dashAt >= 0 ? dashAt : _indentOfLine(line);
|
|
667
|
+
while (itemStack.length &&
|
|
668
|
+
itemStack[itemStack.length - 1].dash >= lineIndent) itemStack.pop();
|
|
669
|
+
if (dashAt >= 0) {
|
|
670
|
+
Object.keys(indentScopes).forEach(function (k) {
|
|
671
|
+
if (Number(k) > dashAt) delete indentScopes[k];
|
|
672
|
+
});
|
|
673
|
+
// keyIndent is set by this item's first key, wherever it is written.
|
|
674
|
+
itemStack.push({ dash: dashAt, keyIndent: -1 });
|
|
675
|
+
}
|
|
676
|
+
// Passed straight through, with no `|| ""` fallback: an empty mask line
|
|
677
|
+
// blanks every colon, so a missing one would exempt the line from the screen
|
|
678
|
+
// altogether. `undefined` means "no mask" instead, which counts every colon
|
|
679
|
+
// — over-reporting rather than under-reporting if the two ever desynchronise.
|
|
680
|
+
var entry = _mappingEntryAt(line, maskedLines[i]);
|
|
551
681
|
if (!entry) continue;
|
|
552
682
|
var indent = entry.indent;
|
|
553
683
|
var key = entry.key.trim();
|
|
554
|
-
if (key.charAt(0) === "
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
684
|
+
if (key.charAt(0) === "[" || key.charAt(0) === "{") continue;
|
|
685
|
+
// The key written INLINE with the dash belongs to the item's mapping, and
|
|
686
|
+
// sits at the indent AFTER the dash and its space — so that is the scope it
|
|
687
|
+
// is registered in, alongside the keys written underneath it. It used to be
|
|
688
|
+
// skipped entirely, which meant repeating it went unreported.
|
|
689
|
+
var dash = key.charAt(0) === "-" &&
|
|
690
|
+
(key.length === 1 || _isSpace(key.charCodeAt(1)));
|
|
691
|
+
var scopeAt = indent;
|
|
692
|
+
if (dash) {
|
|
693
|
+
var after = 1;
|
|
694
|
+
while (after < key.length && _isSpace(key.charCodeAt(after))) after += 1;
|
|
695
|
+
key = key.slice(after).trim();
|
|
696
|
+
// `- ` alone, or `- - x`: no inline key of this item's own to register.
|
|
697
|
+
if (!key || key.charAt(0) === "-" || key.charAt(0) === "[" ||
|
|
698
|
+
key.charAt(0) === "{") continue;
|
|
699
|
+
scopeAt = indent + after;
|
|
700
|
+
}
|
|
701
|
+
// The item's mapping is ONE mapping however its keys are laid out, so the
|
|
702
|
+
// scope it is tracked under must not depend on spacing. `- a: 1` puts its
|
|
703
|
+
// inline key at column 4 while the key written under it sits at column 2,
|
|
704
|
+
// and keying on the raw column filed them separately — so a repeat across
|
|
705
|
+
// those two lines went unreported, which is exactly the smuggling shape
|
|
706
|
+
// this detector exists for (one parser reads two keys, another reads one).
|
|
707
|
+
//
|
|
708
|
+
// The item's top level is every key deeper than the dash and no deeper than
|
|
709
|
+
// the first key it saw. Anything past that is genuinely nested and keeps
|
|
710
|
+
// its own column, so `- a: 1` / ` b:` / ` a: 2` is still not a
|
|
711
|
+
// duplicate.
|
|
712
|
+
// Half a column past the dash: a number, so the pruning comparisons below
|
|
713
|
+
// and at the dash keep working unchanged, and one that sorts between the
|
|
714
|
+
// dash and anything nested inside the item.
|
|
715
|
+
var item = itemStack.length ? itemStack[itemStack.length - 1] : null;
|
|
716
|
+
if (item && scopeAt > item.dash) {
|
|
717
|
+
// The item's top level is every key no deeper than the SHALLOWEST key it
|
|
718
|
+
// has shown, and the bound moves down as shallower ones appear. Two
|
|
719
|
+
// failures pinned this from opposite sides:
|
|
720
|
+
//
|
|
721
|
+
// - a: 1 the inline key sits at column 4 because of the extra
|
|
722
|
+
// b: spacing, but the item's mapping is written at 2. If
|
|
723
|
+
// a: 2 4 is taken as the bound, the NESTED `a` at 4 counts
|
|
724
|
+
// as top level and reads as a duplicate.
|
|
725
|
+
//
|
|
726
|
+
// - a: here the inline key IS the bound, at 2. If it sets
|
|
727
|
+
// x: 1 nothing, the first nested key at 4 becomes the bound
|
|
728
|
+
// a: 2 and the nested `a` reads as a duplicate instead.
|
|
729
|
+
//
|
|
730
|
+
// So the inline key establishes the bound and a later, shallower key
|
|
731
|
+
// lowers it. Extra spacing after the indicator is presentation; the
|
|
732
|
+
// shallowest key is the structure.
|
|
733
|
+
if (item.keyIndent === -1 || scopeAt < item.keyIndent) item.keyIndent = scopeAt;
|
|
734
|
+
if (scopeAt <= item.keyIndent) scopeAt = item.dash + 0.5;
|
|
735
|
+
}
|
|
736
|
+
if (!indentScopes[scopeAt]) indentScopes[scopeAt] = Object.create(null);
|
|
737
|
+
if (indentScopes[scopeAt][key]) dups[key] = true;
|
|
738
|
+
else indentScopes[scopeAt][key] = true;
|
|
558
739
|
Object.keys(indentScopes).forEach(function (k) {
|
|
559
|
-
if (Number(k) >
|
|
740
|
+
if (Number(k) > scopeAt) delete indentScopes[k];
|
|
560
741
|
});
|
|
561
742
|
}
|
|
562
743
|
return Object.keys(dups);
|
package/lib/mail-agent.js
CHANGED
|
@@ -539,19 +539,33 @@ async function _expunge(ctx, args) {
|
|
|
539
539
|
"agent.expunge: { folder, objectIds, [candidateTtlMs] } required");
|
|
540
540
|
}
|
|
541
541
|
|
|
542
|
-
// Look up the regulator-mandated retention floor for the operator's
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
// complianceFloor
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
542
|
+
// Look up the regulator-mandated retention floor for the operator's active
|
|
543
|
+
// posture. For expunge semantics the floor IS the minimum TTL — messages
|
|
544
|
+
// younger than it MUST NOT be hard-deleted, even on operator request.
|
|
545
|
+
//
|
|
546
|
+
// Through b.retention.complianceFloor, not the floor table it wraps. The
|
|
547
|
+
// direct read fell back to zero for anything the table did not contain, so a
|
|
548
|
+
// misspelled posture, a capitalised one ("HIPAA"), or one the table simply
|
|
549
|
+
// does not carry all permitted an unbounded hard delete with no error — a
|
|
550
|
+
// typo refused where retention windows are computed and accepted at the one
|
|
551
|
+
// call that destroys mail permanently. It also read through the prototype:
|
|
552
|
+
// posture "constructor" returned a function, which is truthy, so the `|| 0`
|
|
553
|
+
// fallback kept it and every age comparison against it was nonsense.
|
|
554
|
+
//
|
|
555
|
+
// No posture at all is a legitimate configuration and keeps the zero floor.
|
|
556
|
+
// A posture that was supplied and is not understood now throws.
|
|
550
557
|
var retentionModule = require("./retention"); // allow:inline-require — lazy-load until first expunge call
|
|
551
558
|
var posture = (ctx && ctx.posture) || (args && args.posture) || null;
|
|
552
559
|
var floorMs = 0;
|
|
553
560
|
if (typeof posture === "string" && posture.length > 0) {
|
|
554
|
-
|
|
561
|
+
try {
|
|
562
|
+
floorMs = retentionModule.complianceFloor(posture);
|
|
563
|
+
} catch (e) {
|
|
564
|
+
throw new MailAgentError("mail-agent/unknown-posture",
|
|
565
|
+
"expunge: posture '" + posture + "' is not a posture the framework knows, " +
|
|
566
|
+
"so no retention floor can be established and the hard delete is refused " +
|
|
567
|
+
"rather than run unbounded: " + ((e && e.message) || String(e)));
|
|
568
|
+
}
|
|
555
569
|
}
|
|
556
570
|
|
|
557
571
|
// Read message metadata BEFORE invoking hardExpunge so the per-id
|