@tsdmarc/opensearch 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +201 -0
- package/NOTICE +61 -0
- package/README.md +177 -2
- package/dist/index.d.ts +233 -0
- package/dist/index.js +1931 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1931 @@
|
|
|
1
|
+
import { InvalidFailureReport, PyDateTime, camelToSnake, humanTimestampToDatetime, logger, parseDate, pyRepr, pyStr, rawJsonIntegers, redactUrlCredentials, sanitizeForLog } from "tsdmarc";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { Client, Transport, errors } from "@opensearch-project/opensearch";
|
|
4
|
+
// ../search-common/src/python.ts
|
|
5
|
+
const ASCII_INT = /^[+-]?\d+(?:_\d+)*$/u;
|
|
6
|
+
/** Python's `KeyError`: a missing key in a report dict. */
|
|
7
|
+
var KeyError = class extends Error {
|
|
8
|
+
name = "KeyError";
|
|
9
|
+
key;
|
|
10
|
+
constructor(key) {
|
|
11
|
+
super(pyRepr(key));
|
|
12
|
+
this.key = key;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* `target[key] = value` as a Python dict does it: an own enumerable
|
|
17
|
+
* property even for keys from report data like `__proto__`, which plain
|
|
18
|
+
* assignment would turn into a prototype change (and drop).
|
|
19
|
+
*/
|
|
20
|
+
function setOwn(target, key, value) {
|
|
21
|
+
Object.defineProperty(target, key, {
|
|
22
|
+
configurable: true,
|
|
23
|
+
enumerable: true,
|
|
24
|
+
value,
|
|
25
|
+
writable: true
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Python's `ValueError`. */
|
|
29
|
+
var ValueError = class extends Error {
|
|
30
|
+
name = "ValueError";
|
|
31
|
+
};
|
|
32
|
+
function isRecord$1(value) {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* `obj[key]` with Python's KeyError for a missing key. `key` is the camelCase
|
|
37
|
+
* key of the report object; the error names the snake_case key, as parsedmarc
|
|
38
|
+
* reports it.
|
|
39
|
+
*/
|
|
40
|
+
function getKey(obj, key) {
|
|
41
|
+
if (!isRecord$1(obj) || !(key in obj)) throw new KeyError(camelToSnake(key));
|
|
42
|
+
return obj[key];
|
|
43
|
+
}
|
|
44
|
+
/** Python's truthiness (`bool(value)`). */
|
|
45
|
+
function pyBool(value) {
|
|
46
|
+
if (value === null || value === void 0 || value === false) return false;
|
|
47
|
+
if (typeof value === "number") return value !== 0 && !Number.isNaN(value);
|
|
48
|
+
if (typeof value === "bigint") return value !== 0n;
|
|
49
|
+
if (typeof value === "string" || Array.isArray(value)) return value.length > 0;
|
|
50
|
+
if (isRecord$1(value)) return Object.keys(value).length > 0;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
/** A safe integer as a number, anything larger as an exact bigint. */
|
|
54
|
+
function exactInt(value) {
|
|
55
|
+
return value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : value;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Python's `int(value)` for the values reports carry (ASCII digits). Exact,
|
|
59
|
+
* like Python's unbounded ints: beyond 2^53 the result is a `bigint`.
|
|
60
|
+
*/
|
|
61
|
+
function pyInt(value) {
|
|
62
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
63
|
+
if (typeof value === "bigint") return exactInt(value);
|
|
64
|
+
if (typeof value === "number") {
|
|
65
|
+
if (!Number.isFinite(value)) throw new ValueError(`cannot convert float ${Number.isNaN(value) ? "NaN" : "infinity"} to integer`);
|
|
66
|
+
return exactInt(BigInt(Math.trunc(value)));
|
|
67
|
+
}
|
|
68
|
+
if (typeof value === "string") {
|
|
69
|
+
const text = value.trim();
|
|
70
|
+
if (ASCII_INT.test(text)) return exactInt(BigInt(text.replaceAll("_", "")));
|
|
71
|
+
throw new ValueError(`invalid literal for int() with base 10: ${pyRepr(value)}`);
|
|
72
|
+
}
|
|
73
|
+
throw new ValueError(`int() argument must be a string, a bytes-like object or a real number, not '${typeof value}'`);
|
|
74
|
+
}
|
|
75
|
+
/** `str(error)`: the message of an error, or the value itself. */
|
|
76
|
+
function errorMessage(error) {
|
|
77
|
+
return error instanceof Error ? error.message : String(error);
|
|
78
|
+
}
|
|
79
|
+
// ../search-common/src/ip.ts
|
|
80
|
+
const HEXTET_COUNT = 8;
|
|
81
|
+
const MAX_IPV6_LENGTH = 45;
|
|
82
|
+
const HEX_DIGITS = /^[0-9a-f]*$/iu;
|
|
83
|
+
const DECIMAL_DIGITS = /^\d+$/u;
|
|
84
|
+
const IPV4_MAPPED_PREFIX = 65535;
|
|
85
|
+
/** Parse a dotted-quad IPv4 address (strict, like Python), or `null`. */
|
|
86
|
+
function parseIpv4(text) {
|
|
87
|
+
if (text === "" || text.includes("/")) return null;
|
|
88
|
+
const octets = text.split(".");
|
|
89
|
+
if (octets.length !== 4) return null;
|
|
90
|
+
const values = [];
|
|
91
|
+
for (const octet of octets) {
|
|
92
|
+
if (!DECIMAL_DIGITS.test(octet) || octet.length > 3) return null;
|
|
93
|
+
if (octet !== "0" && octet.startsWith("0")) return null;
|
|
94
|
+
const value = Number(octet);
|
|
95
|
+
if (value > 255) return null;
|
|
96
|
+
values.push(value);
|
|
97
|
+
}
|
|
98
|
+
return values;
|
|
99
|
+
}
|
|
100
|
+
function parseHextet(text) {
|
|
101
|
+
if (text === "" || !HEX_DIGITS.test(text) || text.length > 4) return null;
|
|
102
|
+
return Number.parseInt(text, 16);
|
|
103
|
+
}
|
|
104
|
+
/** Python's `_BaseV6._ip_int_from_string`, as 8 hextets, or `null`. */
|
|
105
|
+
function parseIpv6Hextets(text) {
|
|
106
|
+
if (text === "" || text.length > MAX_IPV6_LENGTH) return null;
|
|
107
|
+
const maxParts = 9;
|
|
108
|
+
const parts = text.split(":");
|
|
109
|
+
if (parts.length > 10) return null;
|
|
110
|
+
if (parts.length < 3) return null;
|
|
111
|
+
const last = parts.at(-1) ?? "";
|
|
112
|
+
if (last.includes(".")) {
|
|
113
|
+
const ipv4 = parseIpv4(last);
|
|
114
|
+
if (ipv4 === null) return null;
|
|
115
|
+
parts.pop();
|
|
116
|
+
const [a = 0, b = 0, c = 0, d = 0] = ipv4;
|
|
117
|
+
parts.push((a << 8 | b).toString(16), (c << 8 | d).toString(16));
|
|
118
|
+
}
|
|
119
|
+
if (parts.length > maxParts) return null;
|
|
120
|
+
let skipIndex = null;
|
|
121
|
+
for (let index = 1; index < parts.length - 1; index++) if (parts[index] === "") {
|
|
122
|
+
if (skipIndex !== null) return null;
|
|
123
|
+
skipIndex = index;
|
|
124
|
+
}
|
|
125
|
+
let partsHi;
|
|
126
|
+
let partsLo;
|
|
127
|
+
let partsSkipped;
|
|
128
|
+
if (skipIndex === null) {
|
|
129
|
+
if (parts.length !== HEXTET_COUNT || parts[0] === "" || last === "") return null;
|
|
130
|
+
partsHi = parts.length;
|
|
131
|
+
partsLo = 0;
|
|
132
|
+
partsSkipped = 0;
|
|
133
|
+
} else {
|
|
134
|
+
partsHi = skipIndex;
|
|
135
|
+
partsLo = parts.length - skipIndex - 1;
|
|
136
|
+
if (parts[0] === "") {
|
|
137
|
+
partsHi -= 1;
|
|
138
|
+
if (partsHi !== 0) return null;
|
|
139
|
+
}
|
|
140
|
+
if (parts.at(-1) === "") {
|
|
141
|
+
partsLo -= 1;
|
|
142
|
+
if (partsLo !== 0) return null;
|
|
143
|
+
}
|
|
144
|
+
partsSkipped = HEXTET_COUNT - (partsHi + partsLo);
|
|
145
|
+
if (partsSkipped < 1) return null;
|
|
146
|
+
}
|
|
147
|
+
const hextets = [];
|
|
148
|
+
for (let index = 0; index < partsHi; index++) {
|
|
149
|
+
const value = parseHextet(parts[index] ?? "");
|
|
150
|
+
if (value === null) return null;
|
|
151
|
+
hextets.push(value);
|
|
152
|
+
}
|
|
153
|
+
for (let index = 0; index < partsSkipped; index++) hextets.push(0);
|
|
154
|
+
for (let index = parts.length - partsLo; index < parts.length; index++) {
|
|
155
|
+
const value = parseHextet(parts[index] ?? "");
|
|
156
|
+
if (value === null) return null;
|
|
157
|
+
hextets.push(value);
|
|
158
|
+
}
|
|
159
|
+
return hextets;
|
|
160
|
+
}
|
|
161
|
+
/** Python's `_compress_hextets`: replace the longest run of zeros with `::`. */
|
|
162
|
+
function compressHextets(hextets) {
|
|
163
|
+
let bestStart = -1;
|
|
164
|
+
let bestLength = 0;
|
|
165
|
+
let start = -1;
|
|
166
|
+
let length = 0;
|
|
167
|
+
hextets.forEach((hextet, index) => {
|
|
168
|
+
if (hextet === 0) {
|
|
169
|
+
length += 1;
|
|
170
|
+
if (start === -1) start = index;
|
|
171
|
+
if (length > bestLength) {
|
|
172
|
+
bestLength = length;
|
|
173
|
+
bestStart = start;
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
length = 0;
|
|
177
|
+
start = -1;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
const parts = hextets.map((hextet) => hextet.toString(16));
|
|
181
|
+
if (bestLength > 1) {
|
|
182
|
+
const end = bestStart + bestLength;
|
|
183
|
+
const head = parts.slice(0, bestStart);
|
|
184
|
+
const tail = parts.slice(end);
|
|
185
|
+
return `${head.join(":")}::${tail.join(":")}`;
|
|
186
|
+
}
|
|
187
|
+
return parts.join(":");
|
|
188
|
+
}
|
|
189
|
+
function formatIpv6(text) {
|
|
190
|
+
if (text.includes("/")) return null;
|
|
191
|
+
const percent = text.indexOf("%");
|
|
192
|
+
let address = text;
|
|
193
|
+
let scopeId = null;
|
|
194
|
+
if (percent !== -1) {
|
|
195
|
+
scopeId = text.slice(percent + 1);
|
|
196
|
+
address = text.slice(0, percent);
|
|
197
|
+
if (scopeId === "" || scopeId.includes("%")) return null;
|
|
198
|
+
}
|
|
199
|
+
const hextets = parseIpv6Hextets(address);
|
|
200
|
+
if (hextets === null) return null;
|
|
201
|
+
const isIpv4Mapped = hextets.slice(0, 5).every((hextet) => hextet === 0) && hextets[5] === IPV4_MAPPED_PREFIX;
|
|
202
|
+
const [high = 0, low = 0] = hextets.slice(6);
|
|
203
|
+
const formatted = isIpv4Mapped ? `::ffff:${high >> 8}.${high & 255}.${low >> 8}.${low & 255}` : compressHextets(hextets);
|
|
204
|
+
return scopeId ? `${formatted}%${scopeId}` : formatted;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Validate an IP address and return Python's canonical string for it.
|
|
208
|
+
*
|
|
209
|
+
* @throws {ValueError} `'<value>' does not appear to be an IPv4 or IPv6 address`
|
|
210
|
+
*/
|
|
211
|
+
function normalizeIpAddress(value) {
|
|
212
|
+
const ipv4 = parseIpv4(value);
|
|
213
|
+
if (ipv4 !== null) return ipv4.join(".");
|
|
214
|
+
const ipv6 = formatIpv6(value);
|
|
215
|
+
if (ipv6 !== null) return ipv6;
|
|
216
|
+
throw new ValueError(`${pyRepr(value)} does not appear to be an IPv4 or IPv6 address`);
|
|
217
|
+
}
|
|
218
|
+
// ../search-common/src/dsl.ts
|
|
219
|
+
const MS_PER_SECOND$1 = 1e3;
|
|
220
|
+
function isObjectField(field) {
|
|
221
|
+
return field.type === "object" || field.type === "nested";
|
|
222
|
+
}
|
|
223
|
+
function isPlainObject(value) {
|
|
224
|
+
if (value === null || typeof value !== "object") return false;
|
|
225
|
+
const prototype = Object.getPrototypeOf(value);
|
|
226
|
+
return prototype === Object.prototype || prototype === null;
|
|
227
|
+
}
|
|
228
|
+
/** `value in ([], {}, None)`: what `to_dict(skip_empty=True)` leaves out. */
|
|
229
|
+
function isEmptyValue(value) {
|
|
230
|
+
if (value === null || value === void 0) return true;
|
|
231
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
232
|
+
return isPlainObject(value) && Object.keys(value).length === 0;
|
|
233
|
+
}
|
|
234
|
+
/** The DSL's `Date._deserialize`, then the JSON serializer's `isoformat()`. */
|
|
235
|
+
function serializeDate(value) {
|
|
236
|
+
if (value instanceof PyDateTime) return value.isoformat();
|
|
237
|
+
if (typeof value === "string") return parseDate(value).isoformat();
|
|
238
|
+
if (typeof value === "number" && Number.isInteger(value)) return PyDateTime.fromTimestamp(value / MS_PER_SECOND$1, true).withOffset(null).isoformat();
|
|
239
|
+
throw new ValueError(`Could not parse date from the value (${pyRepr(value)})`);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* A report value as a text field holds it. The parser leaves an element with
|
|
243
|
+
* children as an object; left alone it would map the field as an object in
|
|
244
|
+
* the index (rejecting every later text value) and turn into parameters of
|
|
245
|
+
* the duplicate search.
|
|
246
|
+
*/
|
|
247
|
+
function textValue(value) {
|
|
248
|
+
return isPlainObject(value) ? pyStr(value) : value;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* `value`, or `null` with a warning when `clean` rejects it. For values a
|
|
252
|
+
* sender controls and the parser accepts: the DSL's ValueError would fail the
|
|
253
|
+
* save of the whole report on every retry, for one field nothing depends on.
|
|
254
|
+
*/
|
|
255
|
+
function validOrNull(value, field, clean) {
|
|
256
|
+
if (value === null || value === void 0) return value;
|
|
257
|
+
try {
|
|
258
|
+
clean(value);
|
|
259
|
+
return value;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (!(error instanceof ValueError)) throw error;
|
|
262
|
+
logger.warning(`Dropping ${field} from a report: ${sanitizeForLog(errorMessage(error))}`);
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** `value` if an `Ip()` field can hold it, otherwise `null` (see above). */
|
|
267
|
+
function ipOrNull(value, field) {
|
|
268
|
+
return validOrNull(value, field, (item) => normalizeIpAddress(String(item)));
|
|
269
|
+
}
|
|
270
|
+
/** `value` if an `Integer()` field can hold it, otherwise `null` (see above). */
|
|
271
|
+
function integerOrNull(value, field) {
|
|
272
|
+
return validOrNull(value, field, pyInt);
|
|
273
|
+
}
|
|
274
|
+
function serializeScalar(field, value) {
|
|
275
|
+
switch (field.type) {
|
|
276
|
+
case "integer": return pyInt(value);
|
|
277
|
+
case "boolean": return value === "false" ? false : pyBool(value);
|
|
278
|
+
case "date": return serializeDate(value);
|
|
279
|
+
case "ip": return normalizeIpAddress(String(value));
|
|
280
|
+
default: return textValue(value);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function serializeOne(field, value) {
|
|
284
|
+
if (isObjectField(field)) return isPlainObject(value) ? serializeDocument(field.properties ?? {}, value) : value;
|
|
285
|
+
return serializeScalar(field, value);
|
|
286
|
+
}
|
|
287
|
+
function serializeField(field, value) {
|
|
288
|
+
if (Array.isArray(value)) return value.map((item) => item === null || item === void 0 ? null : serializeOne(field, item));
|
|
289
|
+
return serializeOne(field, value);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Clean and serialize a document like `Document.save()` does: declared
|
|
293
|
+
* fields are converted to their type, undeclared fields are kept as they
|
|
294
|
+
* are, and empty values are dropped. Throws like the DSL does for values a
|
|
295
|
+
* field cannot hold (an invalid IP address, a non-numeric integer), except
|
|
296
|
+
* objects in text fields (see {@link textValue}).
|
|
297
|
+
*/
|
|
298
|
+
function serializeDocument(schema, data) {
|
|
299
|
+
const out = {};
|
|
300
|
+
for (const [key, value] of Object.entries(data)) {
|
|
301
|
+
const field = Object.hasOwn(schema, key) ? schema[key] : void 0;
|
|
302
|
+
const serialized = field === void 0 || value === null || value === void 0 ? value : serializeField(field, value);
|
|
303
|
+
if (!isEmptyValue(serialized)) setOwn(out, key, serialized);
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
function fieldMapping(field) {
|
|
308
|
+
if (isObjectField(field)) {
|
|
309
|
+
if (field.properties === void 0) return { type: field.type };
|
|
310
|
+
return {
|
|
311
|
+
...schemaMapping(field.properties),
|
|
312
|
+
type: field.type
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const mapping = { type: field.type };
|
|
316
|
+
if (field.fields !== void 0) mapping.fields = structuredClone(field.fields);
|
|
317
|
+
return mapping;
|
|
318
|
+
}
|
|
319
|
+
/** The mapping a DSL class declares (`Document._doc_type.mapping.to_dict()`). */
|
|
320
|
+
function schemaMapping(schema) {
|
|
321
|
+
const properties = {};
|
|
322
|
+
for (const [name, field] of Object.entries(schema)) properties[name] = fieldMapping(field);
|
|
323
|
+
return { properties };
|
|
324
|
+
}
|
|
325
|
+
function indexProperties(schema) {
|
|
326
|
+
const properties = {};
|
|
327
|
+
for (const [name, field] of Object.entries(schema)) if (field.type === "date") properties[name] = { type: "date" };
|
|
328
|
+
else if (isObjectField(field) && field.indexMapping !== void 0) properties[name] = structuredClone(field.indexMapping);
|
|
329
|
+
else if (field.type === "object" && field.properties !== void 0) {
|
|
330
|
+
const inner = indexProperties(field.properties);
|
|
331
|
+
if (Object.keys(inner).length > 0) properties[name] = { properties: inner };
|
|
332
|
+
}
|
|
333
|
+
return properties;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The field parsedmarc 11.2 adds to every saved document: the date that
|
|
337
|
+
* picks the dated index (or that a data stream sorts by). Not declared on
|
|
338
|
+
* the DSL classes, so it is serialized as given (an ISO 8601 string).
|
|
339
|
+
*/
|
|
340
|
+
const TIMESTAMP_FIELD = "@timestamp";
|
|
341
|
+
/**
|
|
342
|
+
* What the index template of a data stream maps without other mappings
|
|
343
|
+
* (parsedmarc's `create_data_streams()`): only {@link TIMESTAMP_FIELD}.
|
|
344
|
+
*/
|
|
345
|
+
const TIMESTAMP_MAPPING = { properties: { [TIMESTAMP_FIELD]: { type: "date" } } };
|
|
346
|
+
/**
|
|
347
|
+
* The mappings of an index the save functions create: the declared date
|
|
348
|
+
* fields, and date detection off. Otherwise the first document of an index
|
|
349
|
+
* decides whether a text field is a date (`<report_id>2026-11-01</report_id>`),
|
|
350
|
+
* and the index name comes from a date the sender picks too, so one report can
|
|
351
|
+
* break the indexes of days to come. Plus the `indexMapping` of object fields
|
|
352
|
+
* that carry one. Nothing else is declared: the dashboards need the dynamic
|
|
353
|
+
* `object` mapping of the rest (see `createIndexes()`). With date detection
|
|
354
|
+
* off, `@timestamp` is declared too (see {@link TIMESTAMP_FIELD}).
|
|
355
|
+
*/
|
|
356
|
+
function indexMapping(schema) {
|
|
357
|
+
return {
|
|
358
|
+
date_detection: false,
|
|
359
|
+
properties: {
|
|
360
|
+
...TIMESTAMP_MAPPING.properties,
|
|
361
|
+
...indexProperties(schema)
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The mappings of a data stream's index template: `mappings` (such as the
|
|
367
|
+
* ones the save functions create dated indexes with) with
|
|
368
|
+
* {@link TIMESTAMP_FIELD} as a date, which a data stream requires, or only
|
|
369
|
+
* that field.
|
|
370
|
+
*/
|
|
371
|
+
function dataStreamMapping(mappings) {
|
|
372
|
+
if (mappings === null) return structuredClone(TIMESTAMP_MAPPING);
|
|
373
|
+
return {
|
|
374
|
+
...structuredClone(mappings),
|
|
375
|
+
properties: {
|
|
376
|
+
...structuredClone(mappings.properties),
|
|
377
|
+
...structuredClone(TIMESTAMP_MAPPING.properties)
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* `q1 & q2 & ...`: the DSL combines queries into one `bool.must` list; a
|
|
383
|
+
* single query stays as it is.
|
|
384
|
+
*/
|
|
385
|
+
function andQueries(queries) {
|
|
386
|
+
const [first] = queries;
|
|
387
|
+
if (queries.length === 1 && first !== void 0) return first;
|
|
388
|
+
return { bool: { must: [...queries] } };
|
|
389
|
+
}
|
|
390
|
+
/** Append to a list field, creating it on first use (an `AttrList`). */
|
|
391
|
+
function appendTo(doc, key, value) {
|
|
392
|
+
const current = doc[key];
|
|
393
|
+
if (Array.isArray(current)) current.push(value);
|
|
394
|
+
else doc[key] = [value];
|
|
395
|
+
}
|
|
396
|
+
// ../search-common/src/documents.ts
|
|
397
|
+
const TEXT = { type: "text" };
|
|
398
|
+
const KEYWORD = { type: "keyword" };
|
|
399
|
+
const INTEGER = { type: "integer" };
|
|
400
|
+
const BOOLEAN = { type: "boolean" };
|
|
401
|
+
const DATE = { type: "date" };
|
|
402
|
+
const IP = { type: "ip" };
|
|
403
|
+
/** `Text(multi=True, fields={"keyword": Keyword(ignore_above=256)})`. */
|
|
404
|
+
const COMBINED_TEXT = {
|
|
405
|
+
type: "text",
|
|
406
|
+
fields: { keyword: {
|
|
407
|
+
type: "keyword",
|
|
408
|
+
ignore_above: 256
|
|
409
|
+
} }
|
|
410
|
+
};
|
|
411
|
+
/** `_AggregateReportDoc` (index `dmarc_aggregate`). */
|
|
412
|
+
const AGGREGATE_REPORT_DOC = {
|
|
413
|
+
xml_schema: TEXT,
|
|
414
|
+
xml_namespace: KEYWORD,
|
|
415
|
+
org_name: TEXT,
|
|
416
|
+
org_email: TEXT,
|
|
417
|
+
org_extra_contact_info: TEXT,
|
|
418
|
+
report_id: TEXT,
|
|
419
|
+
date_range: DATE,
|
|
420
|
+
date_begin: DATE,
|
|
421
|
+
date_end: DATE,
|
|
422
|
+
normalized_timespan: BOOLEAN,
|
|
423
|
+
errors: TEXT,
|
|
424
|
+
published_policy: {
|
|
425
|
+
type: "object",
|
|
426
|
+
properties: {
|
|
427
|
+
domain: TEXT,
|
|
428
|
+
adkim: TEXT,
|
|
429
|
+
aspf: TEXT,
|
|
430
|
+
p: TEXT,
|
|
431
|
+
sp: TEXT,
|
|
432
|
+
pct: INTEGER,
|
|
433
|
+
fo: TEXT,
|
|
434
|
+
np: KEYWORD,
|
|
435
|
+
testing: KEYWORD,
|
|
436
|
+
discovery_method: KEYWORD
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
source_ip_address: IP,
|
|
440
|
+
source_country: TEXT,
|
|
441
|
+
source_reverse_dns: TEXT,
|
|
442
|
+
source_base_domain: TEXT,
|
|
443
|
+
source_type: TEXT,
|
|
444
|
+
source_name: TEXT,
|
|
445
|
+
source_asn: INTEGER,
|
|
446
|
+
source_as_name: TEXT,
|
|
447
|
+
source_as_domain: TEXT,
|
|
448
|
+
disposition: TEXT,
|
|
449
|
+
dkim_aligned: BOOLEAN,
|
|
450
|
+
spf_aligned: BOOLEAN,
|
|
451
|
+
passed_dmarc: BOOLEAN,
|
|
452
|
+
policy_overrides: {
|
|
453
|
+
type: "nested",
|
|
454
|
+
properties: {
|
|
455
|
+
type: TEXT,
|
|
456
|
+
comment: TEXT
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
header_from: TEXT,
|
|
460
|
+
envelope_from: TEXT,
|
|
461
|
+
envelope_to: TEXT,
|
|
462
|
+
dkim_results: {
|
|
463
|
+
type: "nested",
|
|
464
|
+
properties: {
|
|
465
|
+
domain: TEXT,
|
|
466
|
+
selector: TEXT,
|
|
467
|
+
result: TEXT,
|
|
468
|
+
human_result: TEXT
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
spf_results: {
|
|
472
|
+
type: "nested",
|
|
473
|
+
properties: {
|
|
474
|
+
domain: TEXT,
|
|
475
|
+
scope: TEXT,
|
|
476
|
+
result: TEXT,
|
|
477
|
+
human_result: TEXT
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
dkim_results_combined: COMBINED_TEXT,
|
|
481
|
+
spf_results_combined: COMBINED_TEXT,
|
|
482
|
+
np: KEYWORD,
|
|
483
|
+
testing: KEYWORD,
|
|
484
|
+
discovery_method: KEYWORD,
|
|
485
|
+
generator: TEXT
|
|
486
|
+
};
|
|
487
|
+
const EMAIL_ADDRESS_DOC = {
|
|
488
|
+
display_name: TEXT,
|
|
489
|
+
address: TEXT
|
|
490
|
+
};
|
|
491
|
+
/** `_EmailAttachmentDoc`. */
|
|
492
|
+
const EMAIL_ATTACHMENT_DOC = {
|
|
493
|
+
filename: TEXT,
|
|
494
|
+
content_type: TEXT,
|
|
495
|
+
sha256: TEXT
|
|
496
|
+
};
|
|
497
|
+
/** `_FailureReportDoc` (index `dmarc_failure`). */
|
|
498
|
+
const FAILURE_REPORT_DOC = {
|
|
499
|
+
feedback_type: TEXT,
|
|
500
|
+
user_agent: TEXT,
|
|
501
|
+
version: TEXT,
|
|
502
|
+
original_mail_from: TEXT,
|
|
503
|
+
arrival_date: DATE,
|
|
504
|
+
domain: TEXT,
|
|
505
|
+
original_envelope_id: TEXT,
|
|
506
|
+
authentication_results: TEXT,
|
|
507
|
+
delivery_results: TEXT,
|
|
508
|
+
source_ip_address: IP,
|
|
509
|
+
source_country: TEXT,
|
|
510
|
+
source_reverse_dns: TEXT,
|
|
511
|
+
source_asn: INTEGER,
|
|
512
|
+
source_as_name: TEXT,
|
|
513
|
+
source_as_domain: TEXT,
|
|
514
|
+
source_authentication_mechanisms: TEXT,
|
|
515
|
+
source_auth_failures: TEXT,
|
|
516
|
+
dkim_domain: TEXT,
|
|
517
|
+
original_rcpt_to: TEXT,
|
|
518
|
+
sample: {
|
|
519
|
+
type: "object",
|
|
520
|
+
properties: {
|
|
521
|
+
raw: TEXT,
|
|
522
|
+
headers: {
|
|
523
|
+
type: "object",
|
|
524
|
+
indexMapping: {
|
|
525
|
+
dynamic: false,
|
|
526
|
+
properties: Object.fromEntries([
|
|
527
|
+
"authentication-results",
|
|
528
|
+
"auto-submitted",
|
|
529
|
+
"cc",
|
|
530
|
+
"content-transfer-encoding",
|
|
531
|
+
"content-type",
|
|
532
|
+
"date",
|
|
533
|
+
"delivered-to",
|
|
534
|
+
"dkim-signature",
|
|
535
|
+
"from",
|
|
536
|
+
"in-reply-to",
|
|
537
|
+
"list-id",
|
|
538
|
+
"message-id",
|
|
539
|
+
"mime-version",
|
|
540
|
+
"received",
|
|
541
|
+
"received-spf",
|
|
542
|
+
"references",
|
|
543
|
+
"reply-to",
|
|
544
|
+
"return-path",
|
|
545
|
+
"sender",
|
|
546
|
+
"subject",
|
|
547
|
+
"thread-index",
|
|
548
|
+
"thread-topic",
|
|
549
|
+
"to",
|
|
550
|
+
"user-agent",
|
|
551
|
+
"x-auto-response-suppress",
|
|
552
|
+
"x-exclaimer-md-config",
|
|
553
|
+
"x-linkedin-fe",
|
|
554
|
+
"x-mailer",
|
|
555
|
+
"x-ms-exchange-generated-message-source",
|
|
556
|
+
"x-ms-exchange-inbox-rules-loop",
|
|
557
|
+
"x-ms-exchange-parent-message-id",
|
|
558
|
+
"x-ms-exchange-transport-fromentityheader",
|
|
559
|
+
"x-ms-has-attach",
|
|
560
|
+
"x-ms-tnef-correlator",
|
|
561
|
+
"x-onpremexternalip",
|
|
562
|
+
"x-originating-ip"
|
|
563
|
+
].map((name) => [name, {
|
|
564
|
+
type: "text",
|
|
565
|
+
fields: { keyword: {
|
|
566
|
+
type: "keyword",
|
|
567
|
+
ignore_above: 256
|
|
568
|
+
} }
|
|
569
|
+
}]))
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
headers_only: BOOLEAN,
|
|
573
|
+
to: {
|
|
574
|
+
type: "nested",
|
|
575
|
+
properties: EMAIL_ADDRESS_DOC
|
|
576
|
+
},
|
|
577
|
+
subject: TEXT,
|
|
578
|
+
filename_safe_subject: TEXT,
|
|
579
|
+
_from: {
|
|
580
|
+
type: "object",
|
|
581
|
+
properties: EMAIL_ADDRESS_DOC
|
|
582
|
+
},
|
|
583
|
+
date: DATE,
|
|
584
|
+
reply_to: {
|
|
585
|
+
type: "nested",
|
|
586
|
+
properties: EMAIL_ADDRESS_DOC
|
|
587
|
+
},
|
|
588
|
+
cc: {
|
|
589
|
+
type: "nested",
|
|
590
|
+
properties: EMAIL_ADDRESS_DOC
|
|
591
|
+
},
|
|
592
|
+
bcc: {
|
|
593
|
+
type: "nested",
|
|
594
|
+
properties: EMAIL_ADDRESS_DOC
|
|
595
|
+
},
|
|
596
|
+
body: TEXT,
|
|
597
|
+
attachments: {
|
|
598
|
+
type: "nested",
|
|
599
|
+
properties: EMAIL_ATTACHMENT_DOC
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
/** `_SMTPTLSReportDoc` (index `smtp_tls`). */
|
|
605
|
+
const SMTP_TLS_REPORT_DOC = {
|
|
606
|
+
organization_name: TEXT,
|
|
607
|
+
date_range: DATE,
|
|
608
|
+
date_begin: DATE,
|
|
609
|
+
date_end: DATE,
|
|
610
|
+
contact_info: TEXT,
|
|
611
|
+
report_id: TEXT,
|
|
612
|
+
policies: {
|
|
613
|
+
type: "nested",
|
|
614
|
+
properties: {
|
|
615
|
+
policy_domain: TEXT,
|
|
616
|
+
policy_type: TEXT,
|
|
617
|
+
policy_strings: TEXT,
|
|
618
|
+
mx_host_patterns: TEXT,
|
|
619
|
+
successful_session_count: INTEGER,
|
|
620
|
+
failed_session_count: INTEGER,
|
|
621
|
+
failure_details: {
|
|
622
|
+
type: "nested",
|
|
623
|
+
properties: {
|
|
624
|
+
result_type: TEXT,
|
|
625
|
+
sending_mta_ip: IP,
|
|
626
|
+
receiving_mx_helo: TEXT,
|
|
627
|
+
receiving_mx_hostname: TEXT,
|
|
628
|
+
receiving_ip: IP,
|
|
629
|
+
failed_session_count: INTEGER,
|
|
630
|
+
additional_information_uri: TEXT,
|
|
631
|
+
failure_reason_code: TEXT
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
policies_combined: COMBINED_TEXT,
|
|
637
|
+
failure_details_combined: COMBINED_TEXT
|
|
638
|
+
};
|
|
639
|
+
/**
|
|
640
|
+
* The mappings parsedmarc's document classes declare. `createIndexes()`
|
|
641
|
+
* does not install them (neither does parsedmarc): the shipped dashboards
|
|
642
|
+
* rely on dynamic mapping. They are exported for reference and for users
|
|
643
|
+
* who manage index templates themselves.
|
|
644
|
+
*/
|
|
645
|
+
const DOCUMENT_MAPPINGS = {
|
|
646
|
+
aggregate: schemaMapping(AGGREGATE_REPORT_DOC),
|
|
647
|
+
failure: schemaMapping(FAILURE_REPORT_DOC),
|
|
648
|
+
smtpTls: schemaMapping(SMTP_TLS_REPORT_DOC)
|
|
649
|
+
};
|
|
650
|
+
/** What the save functions create indexes with (see `indexMapping()`). */
|
|
651
|
+
const INDEX_MAPPINGS = {
|
|
652
|
+
aggregate: indexMapping(AGGREGATE_REPORT_DOC),
|
|
653
|
+
failure: indexMapping(FAILURE_REPORT_DOC),
|
|
654
|
+
smtpTls: indexMapping(SMTP_TLS_REPORT_DOC)
|
|
655
|
+
};
|
|
656
|
+
// ../search-common/src/resume.ts
|
|
657
|
+
/**
|
|
658
|
+
* The fields that tell the documents of one report apart. Enrichment
|
|
659
|
+
* (reverse DNS, GeoIP, source name and type) is left out: its answers
|
|
660
|
+
* change between attempts.
|
|
661
|
+
*/
|
|
662
|
+
const AGGREGATE_RECORD_FIELDS = [
|
|
663
|
+
"date_begin",
|
|
664
|
+
"date_end",
|
|
665
|
+
"source_ip_address",
|
|
666
|
+
"message_count",
|
|
667
|
+
"disposition",
|
|
668
|
+
"dkim_aligned",
|
|
669
|
+
"spf_aligned",
|
|
670
|
+
"header_from",
|
|
671
|
+
"envelope_from",
|
|
672
|
+
"envelope_to",
|
|
673
|
+
"dkim_results_combined",
|
|
674
|
+
"spf_results_combined"
|
|
675
|
+
];
|
|
676
|
+
/**
|
|
677
|
+
* Most records a report can have to be resumed: the stored documents are
|
|
678
|
+
* read in one search page, which the default `index.max_result_window`
|
|
679
|
+
* limits to 10,000 hits (one more than the report is asked for).
|
|
680
|
+
*/
|
|
681
|
+
const MAX_RESUMABLE_RECORDS = 9999;
|
|
682
|
+
function normalized(value) {
|
|
683
|
+
if (value === null || value === void 0) return null;
|
|
684
|
+
if (value instanceof PyDateTime) return value.isoformat();
|
|
685
|
+
if (Array.isArray(value)) return value.map(normalized);
|
|
686
|
+
if (typeof value === "number" || typeof value === "bigint") return String(value);
|
|
687
|
+
if (typeof value === "object") return { object: String(value) };
|
|
688
|
+
return value;
|
|
689
|
+
}
|
|
690
|
+
/** A serialized record document as the duplicate search returns it. */
|
|
691
|
+
function fingerprint(document) {
|
|
692
|
+
const source = typeof document === "object" && document !== null ? document : {};
|
|
693
|
+
return JSON.stringify(AGGREGATE_RECORD_FIELDS.map((field) => normalized(Object.hasOwn(source, field) ? source[field] : null)));
|
|
694
|
+
}
|
|
695
|
+
/** Hits in a search response without `size`: Elasticsearch's default. */
|
|
696
|
+
const DEFAULT_SEARCH_SIZE = 10;
|
|
697
|
+
/**
|
|
698
|
+
* The positions of the report's records that are already stored, when the
|
|
699
|
+
* documents the duplicate search found are part of this report and some are
|
|
700
|
+
* missing. `null` means the report counts as saved (parsedmarc's
|
|
701
|
+
* `AlreadySaved`): every record is there, the documents found are not all
|
|
702
|
+
* this report's (another report with the same identity), or there are too
|
|
703
|
+
* many to compare.
|
|
704
|
+
*
|
|
705
|
+
* @param documents The report's documents, serialized as they are sent, in
|
|
706
|
+
* record order; `null` when they can't be serialized.
|
|
707
|
+
* @param found The `_source` of the hits of the duplicate search (one page
|
|
708
|
+
* of the default size).
|
|
709
|
+
* @param fetchStored Reads the `_source` (at least
|
|
710
|
+
* {@link AGGREGATE_RECORD_FIELDS}) of up to `size` documents the duplicate
|
|
711
|
+
* search matches, when `found` is a full page.
|
|
712
|
+
*/
|
|
713
|
+
async function findStoredRecords(documents, found, fetchStored) {
|
|
714
|
+
if (documents === null || found.length === 0 || found.length >= documents.length || documents.length > MAX_RESUMABLE_RECORDS) return null;
|
|
715
|
+
const stored = found.length < DEFAULT_SEARCH_SIZE ? found : await fetchStored(documents.length + 1);
|
|
716
|
+
if (stored.length === 0 || stored.length >= documents.length) return null;
|
|
717
|
+
const positions = /* @__PURE__ */ new Map();
|
|
718
|
+
for (const [position, document] of documents.entries()) {
|
|
719
|
+
const key = fingerprint(document);
|
|
720
|
+
const list = positions.get(key);
|
|
721
|
+
if (list === void 0) positions.set(key, [position]);
|
|
722
|
+
else list.push(position);
|
|
723
|
+
}
|
|
724
|
+
const taken = /* @__PURE__ */ new Set();
|
|
725
|
+
for (const source of stored) {
|
|
726
|
+
const position = positions.get(fingerprint(source))?.shift();
|
|
727
|
+
if (position === void 0) return null;
|
|
728
|
+
taken.add(position);
|
|
729
|
+
}
|
|
730
|
+
return taken;
|
|
731
|
+
}
|
|
732
|
+
// src/reindex.ts
|
|
733
|
+
const DEFAULT_SCROLL = "5m";
|
|
734
|
+
const SCAN_SIZE = 1e3;
|
|
735
|
+
const BULK_CHUNK_SIZE = 500;
|
|
736
|
+
const BULK_MAX_CHUNK_BYTES = 104857600;
|
|
737
|
+
const HTTP_NOT_FOUND = 404;
|
|
738
|
+
const ACTION_META_KEYS = [
|
|
739
|
+
"_id",
|
|
740
|
+
"_index",
|
|
741
|
+
"_routing"
|
|
742
|
+
];
|
|
743
|
+
/** Raised when a scroll request fails on some shards (helpers.ScanError). */
|
|
744
|
+
var ScanError = class extends Error {
|
|
745
|
+
name = "ScanError";
|
|
746
|
+
scrollId;
|
|
747
|
+
constructor(scrollId, message) {
|
|
748
|
+
super(message);
|
|
749
|
+
this.scrollId = scrollId;
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
/** Raised when some documents fail to index (helpers.BulkIndexError). */
|
|
753
|
+
var BulkIndexError = class extends Error {
|
|
754
|
+
name = "BulkIndexError";
|
|
755
|
+
errors;
|
|
756
|
+
constructor(message, errors) {
|
|
757
|
+
super(message);
|
|
758
|
+
this.errors = errors;
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
/**
|
|
762
|
+
* Unlike helpers.scan(), also checked on a page without hits: when the last
|
|
763
|
+
* shard with documents fails, the scroll ends in an empty page that reports
|
|
764
|
+
* the failure, and stopping there would lose those documents in silence.
|
|
765
|
+
*/
|
|
766
|
+
function assertAllShards(response, scrollId) {
|
|
767
|
+
const shards = response._shards;
|
|
768
|
+
if (shards) {
|
|
769
|
+
const successful = shards.successful ?? 0;
|
|
770
|
+
const skipped = shards.skipped ?? 0;
|
|
771
|
+
const total = shards.total ?? 0;
|
|
772
|
+
if (successful + skipped < total) throw new ScanError(scrollId, `Scroll request has only succeeded on ${successful} (+${skipped} skipped) shards out of ${total}.`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
async function* scan(client, index, scroll) {
|
|
776
|
+
let response = (await client.search({
|
|
777
|
+
index,
|
|
778
|
+
scroll,
|
|
779
|
+
size: SCAN_SIZE,
|
|
780
|
+
body: { sort: "_doc" }
|
|
781
|
+
})).body;
|
|
782
|
+
let scrollId = response._scroll_id;
|
|
783
|
+
try {
|
|
784
|
+
while (scrollId) {
|
|
785
|
+
const hits = response.hits?.hits ?? [];
|
|
786
|
+
yield* hits;
|
|
787
|
+
assertAllShards(response, scrollId);
|
|
788
|
+
if (hits.length === 0) break;
|
|
789
|
+
response = (await client.scroll({ body: {
|
|
790
|
+
scroll_id: scrollId,
|
|
791
|
+
scroll
|
|
792
|
+
} })).body;
|
|
793
|
+
scrollId = response._scroll_id;
|
|
794
|
+
}
|
|
795
|
+
} finally {
|
|
796
|
+
if (scrollId) await client.clearScroll({ body: { scroll_id: [scrollId] } }, { ignore: [HTTP_NOT_FOUND] });
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/** expand_action(): one hit → the bulk action line and the document. */
|
|
800
|
+
function expandHit(hit) {
|
|
801
|
+
const meta = {};
|
|
802
|
+
for (const key of ACTION_META_KEYS) if (key in hit) meta[key] = hit[key];
|
|
803
|
+
return [{ index: meta }, rawJsonIntegers("_source" in hit ? hit._source : hit)];
|
|
804
|
+
}
|
|
805
|
+
async function sendChunk(client, body) {
|
|
806
|
+
const response = (await client.bulk({ body })).body;
|
|
807
|
+
if (!response.items) throw new BulkIndexError("The bulk response has no items.", []);
|
|
808
|
+
const errors = [];
|
|
809
|
+
for (const item of response.items) for (const [opType, result] of Object.entries(item)) {
|
|
810
|
+
const status = result?.status ?? 500;
|
|
811
|
+
if (status < 200 || status >= 300) errors.push({ [opType]: result });
|
|
812
|
+
}
|
|
813
|
+
if (errors.length > 0) throw new BulkIndexError(`${errors.length} document(s) failed to index.`, errors);
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Copy every document of `sourceIndex` into `targetIndex`
|
|
817
|
+
* (`opensearchpy.helpers.reindex(client, source_index, target_index)`).
|
|
818
|
+
*/
|
|
819
|
+
async function reindex(client, sourceIndex, targetIndex) {
|
|
820
|
+
let body = [];
|
|
821
|
+
let count = 0;
|
|
822
|
+
let bytes = 0;
|
|
823
|
+
for await (const hit of scan(client, sourceIndex, DEFAULT_SCROLL)) {
|
|
824
|
+
const [action, document] = expandHit({
|
|
825
|
+
...hit,
|
|
826
|
+
_index: targetIndex
|
|
827
|
+
});
|
|
828
|
+
const size = Buffer.byteLength(JSON.stringify(action)) + Buffer.byteLength(JSON.stringify(document)) + 2;
|
|
829
|
+
if (count > 0 && (count === BULK_CHUNK_SIZE || bytes + size > BULK_MAX_CHUNK_BYTES)) {
|
|
830
|
+
await sendChunk(client, body);
|
|
831
|
+
body = [];
|
|
832
|
+
count = 0;
|
|
833
|
+
bytes = 0;
|
|
834
|
+
}
|
|
835
|
+
body.push(action, document);
|
|
836
|
+
count += 1;
|
|
837
|
+
bytes += size;
|
|
838
|
+
}
|
|
839
|
+
if (count > 0) await sendChunk(client, body);
|
|
840
|
+
}
|
|
841
|
+
// src/transport.ts
|
|
842
|
+
/** opensearch-py's `Transport.DEFAULT_RETRY_ON_STATUS`. */
|
|
843
|
+
const RETRY_ON_STATUS = /* @__PURE__ */ new Set([
|
|
844
|
+
502,
|
|
845
|
+
503,
|
|
846
|
+
504
|
|
847
|
+
]);
|
|
848
|
+
function isStream(value) {
|
|
849
|
+
return typeof value === "object" && value !== null && typeof value.pipe === "function";
|
|
850
|
+
}
|
|
851
|
+
/** Whether opensearch-py would retry after `error`. */
|
|
852
|
+
function isRetryable(error) {
|
|
853
|
+
if (error instanceof errors.TimeoutError) return false;
|
|
854
|
+
if (error instanceof errors.ConnectionError) return true;
|
|
855
|
+
return error instanceof errors.ResponseError && RETRY_ON_STATUS.has(error.statusCode);
|
|
856
|
+
}
|
|
857
|
+
/** Hand the outcome of `promise` to a node-style callback. */
|
|
858
|
+
async function deliver(promise, callback) {
|
|
859
|
+
let result;
|
|
860
|
+
try {
|
|
861
|
+
result = await promise;
|
|
862
|
+
} catch (error) {
|
|
863
|
+
callback(error, error.meta);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
callback(null, result);
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* A subclass of `Base` (the client's Transport, or the SigV4 signer's) that
|
|
870
|
+
* never retries a timed-out request.
|
|
871
|
+
*/
|
|
872
|
+
function withPythonRetries(Base) {
|
|
873
|
+
class PythonRetryTransport extends Base {
|
|
874
|
+
request(params, options = {}, callback) {
|
|
875
|
+
let requestOptions = {};
|
|
876
|
+
let done = callback;
|
|
877
|
+
if (typeof options === "function") done = options;
|
|
878
|
+
else requestOptions = options;
|
|
879
|
+
const maxRetries = isStream(params.body) || isStream(params.bulkBody) ? 0 : requestOptions.maxRetries ?? this.maxRetries;
|
|
880
|
+
let current = null;
|
|
881
|
+
let aborted = false;
|
|
882
|
+
const attempt = () => {
|
|
883
|
+
const pending = super.request(params, {
|
|
884
|
+
...requestOptions,
|
|
885
|
+
maxRetries: 0
|
|
886
|
+
});
|
|
887
|
+
current = pending;
|
|
888
|
+
return pending;
|
|
889
|
+
};
|
|
890
|
+
const run = async () => {
|
|
891
|
+
for (let retries = 0;; retries += 1) try {
|
|
892
|
+
return await attempt();
|
|
893
|
+
} catch (error) {
|
|
894
|
+
if (aborted || retries >= maxRetries || !isRetryable(error)) throw error;
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
const promise = run();
|
|
898
|
+
const abort = () => {
|
|
899
|
+
aborted = true;
|
|
900
|
+
current?.abort();
|
|
901
|
+
};
|
|
902
|
+
if (done) deliver(promise, done).catch((error) => {
|
|
903
|
+
queueMicrotask(() => {
|
|
904
|
+
throw error;
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
return Object.assign(promise, { abort });
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return PythonRetryTransport;
|
|
911
|
+
}
|
|
912
|
+
// src/opensearch.ts
|
|
913
|
+
/** Raised when an OpenSearch error occurs. */
|
|
914
|
+
var OpenSearchError = class extends Error {
|
|
915
|
+
name = "OpenSearchError";
|
|
916
|
+
};
|
|
917
|
+
/** Raised when a report to be saved matches an existing report. */
|
|
918
|
+
var AlreadySaved = class extends Error {
|
|
919
|
+
name = "AlreadySaved";
|
|
920
|
+
};
|
|
921
|
+
const MS_PER_SECOND = 1e3;
|
|
922
|
+
const DEFAULT_MAX_INDEXES_PER_REPORT = 31;
|
|
923
|
+
const DEFAULT_TIMEOUT_SECONDS = 60;
|
|
924
|
+
const DEFAULT_PORT = 9200;
|
|
925
|
+
const HTTPS_PORT = 443;
|
|
926
|
+
let defaultConnection = null;
|
|
927
|
+
const DATA_STREAM_TEMPLATE_PRIORITY = 501;
|
|
928
|
+
const dataStreamStates = /* @__PURE__ */ new WeakMap();
|
|
929
|
+
const COMBINED_BACKFILL_QUERY = { bool: {
|
|
930
|
+
minimum_should_match: 1,
|
|
931
|
+
should: [{ bool: {
|
|
932
|
+
must: [{ bool: {
|
|
933
|
+
minimum_should_match: 1,
|
|
934
|
+
should: [{ exists: { field: "dkim_results.domain" } }, { exists: { field: "dkim_results.result" } }]
|
|
935
|
+
} }],
|
|
936
|
+
must_not: [{ exists: { field: "dkim_results_combined" } }]
|
|
937
|
+
} }, { bool: {
|
|
938
|
+
must: [{ bool: {
|
|
939
|
+
minimum_should_match: 1,
|
|
940
|
+
should: [{ exists: { field: "spf_results.domain" } }, { exists: { field: "spf_results.result" } }]
|
|
941
|
+
} }],
|
|
942
|
+
must_not: [{ exists: { field: "spf_results_combined" } }]
|
|
943
|
+
} }]
|
|
944
|
+
} };
|
|
945
|
+
const COMBINED_BACKFILL_SCRIPT = [
|
|
946
|
+
"List dk = new ArrayList(); ",
|
|
947
|
+
"def dr = ctx._source.dkim_results; ",
|
|
948
|
+
"if (dr != null) { ",
|
|
949
|
+
"if (!(dr instanceof List)) { dr = [dr]; } ",
|
|
950
|
+
"for (e in dr) { ",
|
|
951
|
+
"if (e == null) { continue; } ",
|
|
952
|
+
"def sel = e.selector != null ? e.selector : \"none\"; ",
|
|
953
|
+
"def dom = e.domain != null ? e.domain : \"none\"; ",
|
|
954
|
+
"def res = e.result != null ? e.result : \"none\"; ",
|
|
955
|
+
"dk.add(sel + \" / \" + dom + \" / \" + res); ",
|
|
956
|
+
"} } ",
|
|
957
|
+
"ctx._source.dkim_results_combined = dk; ",
|
|
958
|
+
"List sp = new ArrayList(); ",
|
|
959
|
+
"def sr = ctx._source.spf_results; ",
|
|
960
|
+
"if (sr != null) { ",
|
|
961
|
+
"if (!(sr instanceof List)) { sr = [sr]; } ",
|
|
962
|
+
"for (e in sr) { ",
|
|
963
|
+
"if (e == null) { continue; } ",
|
|
964
|
+
"def sc = e.scope != null ? e.scope : \"mfrom\"; ",
|
|
965
|
+
"def dom = e.domain != null ? e.domain : \"none\"; ",
|
|
966
|
+
"def res = e.result != null ? e.result : (e.results != null ? e.results : \"none\"); ",
|
|
967
|
+
"sp.add(sc + \" / \" + dom + \" / \" + res); ",
|
|
968
|
+
"} } ",
|
|
969
|
+
"ctx._source.spf_results_combined = sp;"
|
|
970
|
+
].join("");
|
|
971
|
+
const SMTP_TLS_COMBINED_BACKFILL_QUERY = { bool: {
|
|
972
|
+
minimum_should_match: 1,
|
|
973
|
+
should: [{ bool: {
|
|
974
|
+
must: [{ bool: {
|
|
975
|
+
minimum_should_match: 1,
|
|
976
|
+
should: [{ exists: { field: "policies.policy_domain" } }, { exists: { field: "policies.policy_type" } }]
|
|
977
|
+
} }],
|
|
978
|
+
must_not: [{ exists: { field: "policies_combined" } }]
|
|
979
|
+
} }, { bool: {
|
|
980
|
+
must: [{ bool: {
|
|
981
|
+
minimum_should_match: 1,
|
|
982
|
+
should: [{ exists: { field: "policies.failure_details.result_type" } }, { exists: { field: "policies.failure_details.sending_mta_ip" } }]
|
|
983
|
+
} }],
|
|
984
|
+
must_not: [{ exists: { field: "failure_details_combined" } }]
|
|
985
|
+
} }]
|
|
986
|
+
} };
|
|
987
|
+
const SMTP_TLS_COMBINED_BACKFILL_SCRIPT = [
|
|
988
|
+
"List pols = new ArrayList(); ",
|
|
989
|
+
"List dets = new ArrayList(); ",
|
|
990
|
+
"def ps = ctx._source.policies; ",
|
|
991
|
+
"if (ps != null) { ",
|
|
992
|
+
"if (!(ps instanceof List)) { ps = [ps]; } ",
|
|
993
|
+
"for (p in ps) { ",
|
|
994
|
+
"if (p == null) { continue; } ",
|
|
995
|
+
"def dom = p.policy_domain != null ? p.policy_domain : \"none\"; ",
|
|
996
|
+
"def typ = p.policy_type != null ? p.policy_type : \"none\"; ",
|
|
997
|
+
"pols.add(dom + \" / \" + typ); ",
|
|
998
|
+
"def fds = p.failure_details; ",
|
|
999
|
+
"if (fds != null) { ",
|
|
1000
|
+
"if (!(fds instanceof List)) { fds = [fds]; } ",
|
|
1001
|
+
"for (f in fds) { ",
|
|
1002
|
+
"if (f == null) { continue; } ",
|
|
1003
|
+
"def rt = f.result_type != null ? f.result_type : \"none\"; ",
|
|
1004
|
+
"def smi = f.sending_mta_ip != null ? f.sending_mta_ip : \"none\"; ",
|
|
1005
|
+
"def ri = f.receiving_ip != null ? f.receiving_ip : \"none\"; ",
|
|
1006
|
+
"def rmh = f.receiving_mx_hostname != null ? f.receiving_mx_hostname : \"none\"; ",
|
|
1007
|
+
"dets.add(dom + \" / \" + typ + \" / \" + rt + \" / \" + smi + \" / \" + ri + \" / \" + rmh); ",
|
|
1008
|
+
"} } } } ",
|
|
1009
|
+
"ctx._source.policies_combined = pols; ",
|
|
1010
|
+
"ctx._source.failure_details_combined = dets;"
|
|
1011
|
+
].join("");
|
|
1012
|
+
/** The backfill queries and scripts migrateIndexes() submits. */
|
|
1013
|
+
const BACKFILL = {
|
|
1014
|
+
aggregateQuery: COMBINED_BACKFILL_QUERY,
|
|
1015
|
+
aggregateScript: COMBINED_BACKFILL_SCRIPT,
|
|
1016
|
+
smtpTlsQuery: SMTP_TLS_COMBINED_BACKFILL_QUERY,
|
|
1017
|
+
smtpTlsScript: SMTP_TLS_COMBINED_BACKFILL_SCRIPT
|
|
1018
|
+
};
|
|
1019
|
+
const NETLOC_END = /[/?#]/u;
|
|
1020
|
+
const TRAILING_PORT = /:(\d+)$/u;
|
|
1021
|
+
/**
|
|
1022
|
+
* opensearch-py's host normalization: a host without a scheme is read as
|
|
1023
|
+
* `//host`; `https://` hosts default to port 443 and TLS, others to port
|
|
1024
|
+
* 9200; the client-level `use_ssl` turns on TLS for every host.
|
|
1025
|
+
*/
|
|
1026
|
+
function nodeUrl(host, useSsl) {
|
|
1027
|
+
const withScheme = host.includes("://") ? host : `//${host}`;
|
|
1028
|
+
const parsed = new URL(withScheme, "http://placeholder");
|
|
1029
|
+
const schemeIsHttps = withScheme.toLowerCase().startsWith("https://");
|
|
1030
|
+
const netloc = withScheme.split("//", 2)[1]?.split(NETLOC_END, 1)[0] ?? "";
|
|
1031
|
+
const hostPort = netloc.slice(netloc.lastIndexOf("@") + 1);
|
|
1032
|
+
const portPart = hostPort.startsWith("[") ? hostPort.slice(hostPort.indexOf("]") + 1) : hostPort;
|
|
1033
|
+
const port = TRAILING_PORT.exec(portPart)?.[1] ?? String(schemeIsHttps ? HTTPS_PORT : DEFAULT_PORT);
|
|
1034
|
+
const scheme = schemeIsHttps || useSsl ? "https" : "http";
|
|
1035
|
+
const userinfo = parsed.username || parsed.password ? `${parsed.username}:${parsed.password}@` : "";
|
|
1036
|
+
const path = parsed.pathname === "/" ? "" : parsed.pathname;
|
|
1037
|
+
return `${scheme}://${userinfo}${parsed.hostname}:${port}${path}`;
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* urllib3's timeout rules, as opensearch-py applies them: `None` means no
|
|
1041
|
+
* timeout (0 for opensearch-js), and a value that is not positive raises.
|
|
1042
|
+
*/
|
|
1043
|
+
function requestTimeoutMs(timeout) {
|
|
1044
|
+
if (timeout === null) return 0;
|
|
1045
|
+
if (!(timeout > 0)) throw new ValueError(`Attempted to set connect timeout to ${timeout}, but the timeout cannot be set to a value less than or equal to 0.`);
|
|
1046
|
+
return timeout * MS_PER_SECOND;
|
|
1047
|
+
}
|
|
1048
|
+
async function sigV4Options(awsRegion, awsService) {
|
|
1049
|
+
if (!awsRegion) throw new OpenSearchError("OpenSearch AWS SigV4 auth requires 'aws_region' to be set");
|
|
1050
|
+
const [{ fromNodeProviderChain }, { AwsSigv4Signer }] = await Promise.all([import("@aws-sdk/credential-providers"), import("@opensearch-project/opensearch/aws")]);
|
|
1051
|
+
const getCredentials = fromNodeProviderChain();
|
|
1052
|
+
try {
|
|
1053
|
+
await getCredentials();
|
|
1054
|
+
} catch {
|
|
1055
|
+
throw new OpenSearchError("Unable to load AWS credentials for OpenSearch SigV4 authentication");
|
|
1056
|
+
}
|
|
1057
|
+
return AwsSigv4Signer({
|
|
1058
|
+
region: awsRegion,
|
|
1059
|
+
service: awsService,
|
|
1060
|
+
getCredentials
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Creates an OpenSearch client the way {@link setHosts} does, without
|
|
1065
|
+
* registering it as the default connection. Pass it as `client` to the save
|
|
1066
|
+
* functions, createIndexes() and migrateIndexes() to write to several
|
|
1067
|
+
* clusters from one process. Close it with `client.close()`.
|
|
1068
|
+
*/
|
|
1069
|
+
async function createClient(hosts, options = {}) {
|
|
1070
|
+
const { useSsl = false, sslCertPath = null, skipCertificateVerification = false, username = null, password = null, timeout = DEFAULT_TIMEOUT_SECONDS, authType = "basic", awsRegion = null, awsService = "es" } = options;
|
|
1071
|
+
const hostList = Array.isArray(hosts) ? hosts : [hosts];
|
|
1072
|
+
logger.debug(`Connecting to OpenSearch: hosts=${pyRepr(redactUrlCredentials(hostList))}, use_ssl=${pyRepr(useSsl)}`);
|
|
1073
|
+
const clientOptions = {
|
|
1074
|
+
nodes: hostList.map((host) => nodeUrl(host, Boolean(useSsl))),
|
|
1075
|
+
enableLongNumeralSupport: true,
|
|
1076
|
+
disablePrototypePoisoningProtection: true
|
|
1077
|
+
};
|
|
1078
|
+
if (useSsl) clientOptions.ssl = {
|
|
1079
|
+
rejectUnauthorized: !skipCertificateVerification,
|
|
1080
|
+
...sslCertPath ? { ca: await readFile(sslCertPath) } : {}
|
|
1081
|
+
};
|
|
1082
|
+
const normalizedAuthType = (authType || "basic").trim().toLowerCase();
|
|
1083
|
+
if (normalizedAuthType === "awssigv4") Object.assign(clientOptions, await sigV4Options(awsRegion, awsService));
|
|
1084
|
+
else if (normalizedAuthType === "basic") {
|
|
1085
|
+
if (username && password) clientOptions.auth = {
|
|
1086
|
+
username,
|
|
1087
|
+
password
|
|
1088
|
+
};
|
|
1089
|
+
} else throw new OpenSearchError(`Unsupported OpenSearch auth_type '${authType}'. Expected 'basic' or 'awssigv4'.`);
|
|
1090
|
+
clientOptions.requestTimeout = requestTimeoutMs(timeout);
|
|
1091
|
+
clientOptions.Transport = withPythonRetries(clientOptions.Transport ?? Transport);
|
|
1092
|
+
return new Client(clientOptions);
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Sets the OpenSearch hosts to use, and registers the client as the
|
|
1096
|
+
* default connection the other functions use.
|
|
1097
|
+
*
|
|
1098
|
+
* A client registered by an earlier call is replaced but not closed, as in
|
|
1099
|
+
* parsedmarc (the DSL's connection registry only drops it): its owner may
|
|
1100
|
+
* still be using it or put it back (the CLI's configuration reload). Close
|
|
1101
|
+
* clients you no longer need with `client.close()`; on shutdown,
|
|
1102
|
+
* `await getDefaultConnection()?.close()` then `setDefaultConnection(null)`.
|
|
1103
|
+
*
|
|
1104
|
+
* @returns The registered client. Callers that need to close this exact
|
|
1105
|
+
* client later should hold on to it.
|
|
1106
|
+
*/
|
|
1107
|
+
async function setHosts(hosts, options = {}) {
|
|
1108
|
+
defaultConnection = await createClient(hosts, options);
|
|
1109
|
+
return defaultConnection;
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* The client registered as the `default` connection, or `null`. With
|
|
1113
|
+
* {@link setDefaultConnection}, lets a caller snapshot and restore the
|
|
1114
|
+
* module state setHosts() writes (the CLI's configuration reload).
|
|
1115
|
+
*/
|
|
1116
|
+
function getDefaultConnection() {
|
|
1117
|
+
return defaultConnection;
|
|
1118
|
+
}
|
|
1119
|
+
/** Register `client` as the `default` connection (`null` removes it). */
|
|
1120
|
+
function setDefaultConnection(client) {
|
|
1121
|
+
defaultConnection = client;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* `client`, or the client registered by setHosts() (DSL
|
|
1125
|
+
* `connections.get_connection()`).
|
|
1126
|
+
*/
|
|
1127
|
+
function getConnection(client) {
|
|
1128
|
+
if (client) return client;
|
|
1129
|
+
if (defaultConnection === null) throw new KeyError("There is no connection with alias 'default'.");
|
|
1130
|
+
return defaultConnection;
|
|
1131
|
+
}
|
|
1132
|
+
async function indexExists(client, name) {
|
|
1133
|
+
return (await client.indices.exists({ index: name })).body;
|
|
1134
|
+
}
|
|
1135
|
+
/** `Index(name).settings(...).create()`: the body is `{}` without settings. */
|
|
1136
|
+
async function createIndex(client, name, settings = {}, mappings = null) {
|
|
1137
|
+
await client.indices.create({
|
|
1138
|
+
index: name,
|
|
1139
|
+
body: {
|
|
1140
|
+
...Object.keys(settings).length > 0 ? { settings } : {},
|
|
1141
|
+
...mappings ? { mappings } : {}
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Whether `error` is the cluster refusing to create an index that another
|
|
1147
|
+
* writer created after the exists check (concurrent saves to a new index).
|
|
1148
|
+
*/
|
|
1149
|
+
function isIndexAlreadyCreated(error) {
|
|
1150
|
+
if (!(error instanceof errors.ResponseError)) return false;
|
|
1151
|
+
return error.meta.body?.error?.type === "resource_already_exists_exception";
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Create OpenSearch indexes that don't exist yet.
|
|
1155
|
+
*
|
|
1156
|
+
* @param settings Index settings (default: 1 shard, 0 replicas).
|
|
1157
|
+
* @param mappings Mappings for new indexes (default: none, as in parsedmarc).
|
|
1158
|
+
* @param options The cluster to use (default: the default connection).
|
|
1159
|
+
*/
|
|
1160
|
+
async function createIndexes(names, settings = null, mappings = null, options = {}) {
|
|
1161
|
+
for (const name of names) try {
|
|
1162
|
+
const client = getConnection(options.client);
|
|
1163
|
+
if (!await indexExists(client, name)) {
|
|
1164
|
+
logger.debug(`Creating OpenSearch index: ${name}`);
|
|
1165
|
+
try {
|
|
1166
|
+
await createIndex(client, name, settings ?? {
|
|
1167
|
+
number_of_shards: 1,
|
|
1168
|
+
number_of_replicas: 0
|
|
1169
|
+
}, mappings);
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
if (!isIndexAlreadyCreated(error)) throw error;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
throw new OpenSearchError(`OpenSearch error: ${errorMessage(error)}`);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
function dataStreamState(client) {
|
|
1179
|
+
let state = dataStreamStates.get(client);
|
|
1180
|
+
if (state === void 0) {
|
|
1181
|
+
state = {
|
|
1182
|
+
ready: /* @__PURE__ */ new Map(),
|
|
1183
|
+
pending: /* @__PURE__ */ new Map()
|
|
1184
|
+
};
|
|
1185
|
+
dataStreamStates.set(client, state);
|
|
1186
|
+
}
|
|
1187
|
+
return state;
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Whether `error` is the cluster refusing to look up a data stream by the
|
|
1191
|
+
* name of an alias (`illegal_argument_exception`, HTTP 400). The index
|
|
1192
|
+
* exists check then reports the name as taken, with a clearer message than
|
|
1193
|
+
* this error.
|
|
1194
|
+
*/
|
|
1195
|
+
function isAliasName(error) {
|
|
1196
|
+
if (!(error instanceof errors.ResponseError) || error.statusCode !== 400) return false;
|
|
1197
|
+
return error.meta.body?.error?.type === "illegal_argument_exception";
|
|
1198
|
+
}
|
|
1199
|
+
/** Whether `error` is an HTTP 404 (opensearch-py's `NotFoundError`). */
|
|
1200
|
+
function isNotFound(error) {
|
|
1201
|
+
return error instanceof errors.ResponseError && error.statusCode === 404;
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Whether a data stream holds `name`. OpenSearch 3 answers both a missing
|
|
1205
|
+
* name and a name held by a concrete index with a 404. Elasticsearch answers
|
|
1206
|
+
* the latter with 200 and an empty list, so, as in the Elasticsearch
|
|
1207
|
+
* package, look for the stream in the response rather than trust the status
|
|
1208
|
+
* alone.
|
|
1209
|
+
*/
|
|
1210
|
+
async function dataStreamExists(client, name) {
|
|
1211
|
+
try {
|
|
1212
|
+
return ((await client.indices.getDataStream({ name })).body.data_streams ?? []).some((stream) => stream.name === name);
|
|
1213
|
+
} catch (error) {
|
|
1214
|
+
if (isNotFound(error) || isAliasName(error)) return false;
|
|
1215
|
+
throw error;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
async function setUpDataStream(client, name, body) {
|
|
1219
|
+
let collision = false;
|
|
1220
|
+
try {
|
|
1221
|
+
let streamExists = await dataStreamExists(client, name);
|
|
1222
|
+
if (!streamExists && await indexExists(client, name)) {
|
|
1223
|
+
streamExists = await dataStreamExists(client, name);
|
|
1224
|
+
collision = !streamExists;
|
|
1225
|
+
}
|
|
1226
|
+
if (!collision) {
|
|
1227
|
+
await client.indices.putIndexTemplate({
|
|
1228
|
+
name,
|
|
1229
|
+
body
|
|
1230
|
+
});
|
|
1231
|
+
if (!streamExists) {
|
|
1232
|
+
logger.debug(`Creating OpenSearch data stream: ${name}`);
|
|
1233
|
+
try {
|
|
1234
|
+
await client.indices.createDataStream({ name });
|
|
1235
|
+
} catch (error) {
|
|
1236
|
+
if (!isIndexAlreadyCreated(error)) throw error;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
throw new OpenSearchError(`OpenSearch error: ${errorMessage(error)}`);
|
|
1242
|
+
}
|
|
1243
|
+
if (collision) throw new OpenSearchError(`OpenSearch error: cannot create the data stream ${name}: an index or alias with that name already exists`);
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Create OpenSearch data streams and their index templates.
|
|
1247
|
+
*
|
|
1248
|
+
* For each name, puts an index template named after the stream whose
|
|
1249
|
+
* `index_patterns` is exactly that name (no wildcard, so it never applies to
|
|
1250
|
+
* the date-suffixed indexes written without data streams), and then creates
|
|
1251
|
+
* the data stream if it does not exist yet. The template is put again by
|
|
1252
|
+
* every process that writes to the stream, so edits made to it directly do
|
|
1253
|
+
* not last; an ISM policy attaches itself to the stream's backing indices
|
|
1254
|
+
* through its own `ism_template` instead. Each name is set up once per
|
|
1255
|
+
* client and template.
|
|
1256
|
+
*
|
|
1257
|
+
* @param settings Index settings for the stream's backing indices (default:
|
|
1258
|
+
* 1 shard, 0 replicas, as in {@link createIndexes}).
|
|
1259
|
+
* @param mappings Mappings for the backing indices, plus `@timestamp` as a
|
|
1260
|
+
* date (default: only `@timestamp`, as in parsedmarc). The save functions
|
|
1261
|
+
* pass the mappings they create dated indexes with.
|
|
1262
|
+
* @param options The cluster to use (default: the default connection).
|
|
1263
|
+
* @throws {OpenSearchError} when a concrete index or alias already holds one
|
|
1264
|
+
* of the names, or the cluster returns an error. A name that fails is not
|
|
1265
|
+
* remembered, so the next save retries it.
|
|
1266
|
+
*/
|
|
1267
|
+
async function createDataStreams(names, settings = null, mappings = null, options = {}) {
|
|
1268
|
+
const effectiveSettings = settings ? { ...settings } : {
|
|
1269
|
+
number_of_shards: 1,
|
|
1270
|
+
number_of_replicas: 0
|
|
1271
|
+
};
|
|
1272
|
+
for (const name of names) {
|
|
1273
|
+
let client;
|
|
1274
|
+
try {
|
|
1275
|
+
client = getConnection(options.client);
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
throw new OpenSearchError(`OpenSearch error: ${errorMessage(error)}`);
|
|
1278
|
+
}
|
|
1279
|
+
const body = {
|
|
1280
|
+
index_patterns: [name],
|
|
1281
|
+
data_stream: {},
|
|
1282
|
+
priority: DATA_STREAM_TEMPLATE_PRIORITY,
|
|
1283
|
+
template: {
|
|
1284
|
+
settings: effectiveSettings,
|
|
1285
|
+
mappings: dataStreamMapping(mappings)
|
|
1286
|
+
},
|
|
1287
|
+
_meta: { description: "Managed by tsdmarc, which overwrites it. Attach ISM policies with an ism_template." }
|
|
1288
|
+
};
|
|
1289
|
+
const key = JSON.stringify({
|
|
1290
|
+
name,
|
|
1291
|
+
body
|
|
1292
|
+
});
|
|
1293
|
+
const state = dataStreamState(client);
|
|
1294
|
+
if (state.ready.get(name) === key) continue;
|
|
1295
|
+
let pending = state.pending.get(key);
|
|
1296
|
+
if (pending === void 0) {
|
|
1297
|
+
pending = setUpDataStream(client, name, body).finally(() => {
|
|
1298
|
+
state.pending.delete(key);
|
|
1299
|
+
});
|
|
1300
|
+
state.pending.set(key, pending);
|
|
1301
|
+
}
|
|
1302
|
+
await pending;
|
|
1303
|
+
state.ready.set(name, key);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
const LEGACY_FO_FIELD = "published_policy.fo";
|
|
1307
|
+
const [LEGACY_FO_OBJECT = "", LEGACY_FO_LEAF = ""] = LEGACY_FO_FIELD.split(".");
|
|
1308
|
+
function isRecord(value) {
|
|
1309
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* The mapped type of `published_policy.fo` in an index, or `null` when the
|
|
1313
|
+
* index does not map it. Reads both the typeless response (OpenSearch has
|
|
1314
|
+
* no mapping types) and the Elasticsearch 6-era shape keyed by type (`doc`).
|
|
1315
|
+
*/
|
|
1316
|
+
async function legacyFoFieldType(client, index) {
|
|
1317
|
+
const response = (await client.indices.getFieldMapping({
|
|
1318
|
+
index,
|
|
1319
|
+
fields: [LEGACY_FO_FIELD]
|
|
1320
|
+
})).body;
|
|
1321
|
+
const [firstIndex = ""] = Object.keys(response);
|
|
1322
|
+
let mappings = response[firstIndex]?.mappings ?? {};
|
|
1323
|
+
if (!(LEGACY_FO_FIELD in mappings)) for (const typeName of ["doc", "_doc"]) {
|
|
1324
|
+
const typed = mappings[typeName];
|
|
1325
|
+
if (isRecord(typed)) {
|
|
1326
|
+
mappings = typed;
|
|
1327
|
+
break;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
const fieldMapping = mappings[LEGACY_FO_FIELD];
|
|
1331
|
+
if (!isRecord(fieldMapping) || Object.keys(fieldMapping).length === 0) return null;
|
|
1332
|
+
const mapping = isRecord(fieldMapping.mapping) ? fieldMapping.mapping : {};
|
|
1333
|
+
const leaf = isRecord(mapping[LEGACY_FO_LEAF]) ? mapping[LEGACY_FO_LEAF] : {};
|
|
1334
|
+
return typeof leaf.type === "string" ? leaf.type : null;
|
|
1335
|
+
}
|
|
1336
|
+
async function migrateLegacyFoIndex(client, legacyIndexName, version) {
|
|
1337
|
+
if (!await indexExists(client, legacyIndexName)) return;
|
|
1338
|
+
if (await legacyFoFieldType(client, legacyIndexName) !== "long") return;
|
|
1339
|
+
const newIndexName = `${legacyIndexName}-v${version}`;
|
|
1340
|
+
const body = { properties: { [LEGACY_FO_OBJECT]: { properties: { [LEGACY_FO_LEAF]: {
|
|
1341
|
+
type: "text",
|
|
1342
|
+
fields: { keyword: {
|
|
1343
|
+
type: "keyword",
|
|
1344
|
+
ignore_above: 256
|
|
1345
|
+
} }
|
|
1346
|
+
} } } } };
|
|
1347
|
+
logger.info(`Migrating ${legacyIndexName} to ${newIndexName}: ${LEGACY_FO_FIELD} is mapped as long, as parsedmarc releases before 5.0.0 declared it`);
|
|
1348
|
+
if (await indexExists(client, newIndexName)) {
|
|
1349
|
+
logger.warning(`Discarding ${newIndexName} left behind by an earlier interrupted migration of ${legacyIndexName}`);
|
|
1350
|
+
await client.indices.delete({ index: newIndexName });
|
|
1351
|
+
}
|
|
1352
|
+
await createIndex(client, newIndexName);
|
|
1353
|
+
await client.indices.putMapping({
|
|
1354
|
+
index: newIndexName,
|
|
1355
|
+
body
|
|
1356
|
+
});
|
|
1357
|
+
await reindex(client, legacyIndexName, newIndexName);
|
|
1358
|
+
await verifyCopy(client, legacyIndexName, newIndexName);
|
|
1359
|
+
await client.indices.delete({ index: legacyIndexName });
|
|
1360
|
+
}
|
|
1361
|
+
/** Documents in `index`, failing if some shard did not answer. */
|
|
1362
|
+
async function countDocuments(client, index) {
|
|
1363
|
+
const { body: response } = await client.count({ index });
|
|
1364
|
+
if ((response._shards?.failed ?? 0) > 0) throw new OpenSearchError(`Could not count the documents of ${index}`);
|
|
1365
|
+
return response.count;
|
|
1366
|
+
}
|
|
1367
|
+
/** The original is about to be deleted: the copy must be complete. */
|
|
1368
|
+
async function verifyCopy(client, sourceIndex, targetIndex) {
|
|
1369
|
+
await client.indices.refresh({ index: targetIndex });
|
|
1370
|
+
const sourceCount = await countDocuments(client, sourceIndex);
|
|
1371
|
+
const targetCount = await countDocuments(client, targetIndex);
|
|
1372
|
+
if (targetCount !== sourceCount) throw new OpenSearchError(`${targetIndex} holds ${targetCount} documents, ${sourceIndex} holds ${sourceCount}`);
|
|
1373
|
+
}
|
|
1374
|
+
async function submitBackfills(client, target) {
|
|
1375
|
+
for (const name of target.names) {
|
|
1376
|
+
const pattern = `${name}*`;
|
|
1377
|
+
try {
|
|
1378
|
+
const { count } = (await client.count({
|
|
1379
|
+
index: pattern,
|
|
1380
|
+
body: { query: target.query },
|
|
1381
|
+
ignore_unavailable: true,
|
|
1382
|
+
allow_no_indices: true
|
|
1383
|
+
})).body;
|
|
1384
|
+
if (!count) continue;
|
|
1385
|
+
const taskId = (await client.updateByQuery({
|
|
1386
|
+
index: pattern,
|
|
1387
|
+
body: {
|
|
1388
|
+
query: target.query,
|
|
1389
|
+
script: {
|
|
1390
|
+
source: target.script,
|
|
1391
|
+
lang: "painless"
|
|
1392
|
+
}
|
|
1393
|
+
},
|
|
1394
|
+
conflicts: "proceed",
|
|
1395
|
+
wait_for_completion: false,
|
|
1396
|
+
ignore_unavailable: true,
|
|
1397
|
+
allow_no_indices: true
|
|
1398
|
+
})).body?.task ?? null;
|
|
1399
|
+
logger.info(`Backfilling ${target.fields} on ${count} existing documents in ${pattern} (task ${pyStr(taskId)})`);
|
|
1400
|
+
} catch (error) {
|
|
1401
|
+
logger.warning(`Failed to check/submit the ${target.fields} backfill for ${pattern}: ${errorMessage(error)}. This will be retried at the next startup; the manual _update_by_query command in the documentation remains available in the meantime.`);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Runs index migrations and backfills.
|
|
1407
|
+
*
|
|
1408
|
+
* First, the legacy `published_policy.fo` migration for each name in
|
|
1409
|
+
* `legacyFoIndexes`: an index that maps the field as `long` (parsedmarc
|
|
1410
|
+
* before 5.0.0) is rebuilt as a `-v2` index with the text/keyword shape, its
|
|
1411
|
+
* documents are reindexed into it, and the original is deleted.
|
|
1412
|
+
*
|
|
1413
|
+
* Second, it backfills `dkim_results_combined`/`spf_results_combined` on
|
|
1414
|
+
* aggregate documents and `policies_combined`/`failure_details_combined` on
|
|
1415
|
+
* SMTP TLS documents saved before those fields existed, as non-blocking
|
|
1416
|
+
* `update_by_query` tasks against each `name*` pattern, guarded by a cheap
|
|
1417
|
+
* `count`. Errors are logged as warnings, never thrown: the work is retried
|
|
1418
|
+
* at the next startup.
|
|
1419
|
+
*/
|
|
1420
|
+
async function migrateIndexes(options = {}) {
|
|
1421
|
+
const { aggregateIndexes, smtpTlsIndexes, legacyFoIndexes } = options;
|
|
1422
|
+
const hasAggregate = Boolean(aggregateIndexes?.length);
|
|
1423
|
+
const hasSmtpTls = Boolean(smtpTlsIndexes?.length);
|
|
1424
|
+
if (!hasAggregate && !hasSmtpTls && !legacyFoIndexes?.length) return;
|
|
1425
|
+
const version = 2;
|
|
1426
|
+
for (const legacyIndexName of legacyFoIndexes ?? []) try {
|
|
1427
|
+
await migrateLegacyFoIndex(getConnection(options.client), legacyIndexName, version);
|
|
1428
|
+
} catch (error) {
|
|
1429
|
+
logger.warning(`Failed the legacy published_policy.fo migration for ${legacyIndexName}: ${errorMessage(error)}. This will be retried at the next startup.`);
|
|
1430
|
+
}
|
|
1431
|
+
if (!hasAggregate && !hasSmtpTls) return;
|
|
1432
|
+
let client;
|
|
1433
|
+
try {
|
|
1434
|
+
client = getConnection(options.client);
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
logger.warning(`Skipping the dkim_results_combined/spf_results_combined/policies_combined/failure_details_combined backfill: could not get an OpenSearch connection: ${errorMessage(error)}. This will be retried at the next startup.`);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
await submitBackfills(client, {
|
|
1440
|
+
names: aggregateIndexes ?? [],
|
|
1441
|
+
query: COMBINED_BACKFILL_QUERY,
|
|
1442
|
+
script: COMBINED_BACKFILL_SCRIPT,
|
|
1443
|
+
fields: "dkim_results_combined/spf_results_combined"
|
|
1444
|
+
});
|
|
1445
|
+
await submitBackfills(client, {
|
|
1446
|
+
names: smtpTlsIndexes ?? [],
|
|
1447
|
+
query: SMTP_TLS_COMBINED_BACKFILL_QUERY,
|
|
1448
|
+
script: SMTP_TLS_COMBINED_BACKFILL_SCRIPT,
|
|
1449
|
+
fields: "policies_combined/failure_details_combined"
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
/** `Search(index=...).query(...).execute()`: the number of hits. */
|
|
1453
|
+
async function countExisting(options, index, query) {
|
|
1454
|
+
return (await searchExisting(options, index, query)).length;
|
|
1455
|
+
}
|
|
1456
|
+
/** The `_source` of the hits of `Search(index=...).query(...).execute()`. */
|
|
1457
|
+
async function searchExisting(options, index, query) {
|
|
1458
|
+
return (await getConnection(options.client).search({
|
|
1459
|
+
index,
|
|
1460
|
+
ignore_unavailable: true,
|
|
1461
|
+
body: { query }
|
|
1462
|
+
})).body.hits.hits.map((hit) => hit._source);
|
|
1463
|
+
}
|
|
1464
|
+
/** The `_source` of up to `size` documents a duplicate search matches. */
|
|
1465
|
+
async function storedSources(options, index, query, size) {
|
|
1466
|
+
return (await getConnection(options.client).search({
|
|
1467
|
+
index,
|
|
1468
|
+
ignore_unavailable: true,
|
|
1469
|
+
body: {
|
|
1470
|
+
query,
|
|
1471
|
+
size,
|
|
1472
|
+
_source: [...AGGREGATE_RECORD_FIELDS]
|
|
1473
|
+
}
|
|
1474
|
+
})).body.hits.hits.map((hit) => hit._source);
|
|
1475
|
+
}
|
|
1476
|
+
/** `Document.save()`: clean, serialize and index the document. */
|
|
1477
|
+
async function saveDocument(options, schema, index, doc) {
|
|
1478
|
+
const body = rawJsonIntegers(serializeDocument(schema, doc));
|
|
1479
|
+
await getConnection(options.client).index({
|
|
1480
|
+
index,
|
|
1481
|
+
body,
|
|
1482
|
+
op_type: "create"
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
/** The index name without its date: also the name of the data stream. */
|
|
1486
|
+
function undatedIndexName(base, options) {
|
|
1487
|
+
let index = base;
|
|
1488
|
+
if (options.indexSuffix) index = `${index}_${options.indexSuffix}`;
|
|
1489
|
+
if (options.indexPrefix) index = `${options.indexPrefix}${index}`;
|
|
1490
|
+
return index;
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* The indexes a duplicate search covers: the dated indexes of this prefix
|
|
1494
|
+
* and suffix, and the data stream (or a pre-5.0.0 undated index) of the
|
|
1495
|
+
* same name, so duplicates are caught across a switch between the two. The
|
|
1496
|
+
* `-` before the date stops the wildcard at the suffix, so `prod` does not
|
|
1497
|
+
* reach `production` and no suffix does not reach every suffix.
|
|
1498
|
+
*/
|
|
1499
|
+
function searchPattern(base, options) {
|
|
1500
|
+
const name = undatedIndexName(base, options);
|
|
1501
|
+
return `${name}-*,${name}`;
|
|
1502
|
+
}
|
|
1503
|
+
function indexSettings(options) {
|
|
1504
|
+
return {
|
|
1505
|
+
number_of_shards: options.numberOfShards ?? 1,
|
|
1506
|
+
number_of_replicas: options.numberOfReplicas ?? 0
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
function indexDate(date, monthly) {
|
|
1510
|
+
return date.strftime(monthly ? "%Y-%m" : "%Y-%m-%d");
|
|
1511
|
+
}
|
|
1512
|
+
function isAligned(result) {
|
|
1513
|
+
return result !== null && result !== void 0 && result.toLowerCase() === "pass";
|
|
1514
|
+
}
|
|
1515
|
+
/** The indexes of one report, at most `maxIndexesPerReport` of them. */
|
|
1516
|
+
var ReportIndexes = class {
|
|
1517
|
+
#limit;
|
|
1518
|
+
#names = /* @__PURE__ */ new Set();
|
|
1519
|
+
#warned = false;
|
|
1520
|
+
constructor(options) {
|
|
1521
|
+
this.#limit = options.maxIndexesPerReport ?? DEFAULT_MAX_INDEXES_PER_REPORT;
|
|
1522
|
+
}
|
|
1523
|
+
/** `name`, or the report's first index once the limit is reached. */
|
|
1524
|
+
pick(name) {
|
|
1525
|
+
if (this.#limit === 0 || this.#names.has(name) || this.#names.size < this.#limit) {
|
|
1526
|
+
this.#names.add(name);
|
|
1527
|
+
return name;
|
|
1528
|
+
}
|
|
1529
|
+
const [first = name] = this.#names;
|
|
1530
|
+
if (!this.#warned) {
|
|
1531
|
+
this.#warned = true;
|
|
1532
|
+
logger.warning(`An aggregate report spans more than ${this.#limit} indexes; saving the remaining records to ${first}`);
|
|
1533
|
+
}
|
|
1534
|
+
return first;
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
/**
|
|
1538
|
+
* Saves a parsed DMARC aggregate report to OpenSearch, one document per
|
|
1539
|
+
* record.
|
|
1540
|
+
*
|
|
1541
|
+
* @throws {AlreadySaved} when the report is already in OpenSearch.
|
|
1542
|
+
* @throws {OpenSearchError} when OpenSearch fails or rejects the document.
|
|
1543
|
+
*/
|
|
1544
|
+
async function saveAggregateReportToOpenSearch(aggregateReport, options = {}) {
|
|
1545
|
+
logger.info("Saving aggregate report to OpenSearch");
|
|
1546
|
+
const metadata = aggregateReport.reportMetadata;
|
|
1547
|
+
const policy = aggregateReport.policyPublished;
|
|
1548
|
+
const { orgName, reportId } = metadata;
|
|
1549
|
+
const domain = textValue(policy.domain);
|
|
1550
|
+
const beginDate = humanTimestampToDatetime(metadata.beginDate, { toUtc: true });
|
|
1551
|
+
const endDate = humanTimestampToDatetime(metadata.endDate, { toUtc: true });
|
|
1552
|
+
const searchIndex = searchPattern("dmarc_aggregate", options);
|
|
1553
|
+
const query = andQueries([
|
|
1554
|
+
{ match_phrase: { org_name: orgName } },
|
|
1555
|
+
{ match_phrase: { report_id: reportId } },
|
|
1556
|
+
{ match_phrase: { "published_policy.domain": domain } },
|
|
1557
|
+
{ range: { date_begin: { gte: beginDate.isoformat() } } },
|
|
1558
|
+
{ range: { date_end: { lte: endDate.isoformat() } } }
|
|
1559
|
+
]);
|
|
1560
|
+
const beginDateHuman = beginDate.strftime("%Y-%m-%d %H:%M:%SZ");
|
|
1561
|
+
const endDateHuman = endDate.strftime("%Y-%m-%d %H:%M:%SZ");
|
|
1562
|
+
let existing;
|
|
1563
|
+
try {
|
|
1564
|
+
existing = await searchExisting(options, searchIndex, query);
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
throw new OpenSearchError(`OpenSearch's search for existing report error: ${errorMessage(error)}`);
|
|
1567
|
+
}
|
|
1568
|
+
const alreadySaved = () => new AlreadySaved(`An aggregate report ID ${reportId} from ${orgName} about ${pyStr(domain)} with a date range of ${beginDateHuman} UTC to ${endDateHuman} UTC already exists in OpenSearch`);
|
|
1569
|
+
const records = aggregateReport.records;
|
|
1570
|
+
const recordDocument = (record, policyDocument) => {
|
|
1571
|
+
const recordBegin = humanTimestampToDatetime(record.intervalBegin, {
|
|
1572
|
+
toUtc: true,
|
|
1573
|
+
assumeUtc: true
|
|
1574
|
+
});
|
|
1575
|
+
const recordEnd = humanTimestampToDatetime(record.intervalEnd, {
|
|
1576
|
+
toUtc: true,
|
|
1577
|
+
assumeUtc: true
|
|
1578
|
+
});
|
|
1579
|
+
const { source, policyEvaluated, identifiers } = record;
|
|
1580
|
+
const aggDoc = {
|
|
1581
|
+
xml_schema: aggregateReport.xmlSchema,
|
|
1582
|
+
xml_namespace: aggregateReport.xmlNamespace ?? null,
|
|
1583
|
+
org_name: metadata.orgName,
|
|
1584
|
+
org_email: metadata.orgEmail,
|
|
1585
|
+
org_extra_contact_info: metadata.orgExtraContactInfo,
|
|
1586
|
+
report_id: metadata.reportId,
|
|
1587
|
+
date_range: [recordBegin, recordEnd],
|
|
1588
|
+
date_begin: recordBegin,
|
|
1589
|
+
date_end: recordEnd,
|
|
1590
|
+
normalized_timespan: record.normalizedTimespan ?? null,
|
|
1591
|
+
errors: metadata.errors,
|
|
1592
|
+
published_policy: policyDocument,
|
|
1593
|
+
source_ip_address: source.ipAddress,
|
|
1594
|
+
source_country: source.country,
|
|
1595
|
+
source_reverse_dns: source.reverseDns,
|
|
1596
|
+
source_base_domain: source.baseDomain,
|
|
1597
|
+
source_type: source.type,
|
|
1598
|
+
source_name: source.name,
|
|
1599
|
+
source_asn: source.asn,
|
|
1600
|
+
source_as_name: source.asName,
|
|
1601
|
+
source_as_domain: source.asDomain,
|
|
1602
|
+
message_count: record.count,
|
|
1603
|
+
disposition: policyEvaluated.disposition,
|
|
1604
|
+
dkim_aligned: isAligned(policyEvaluated.dkim),
|
|
1605
|
+
spf_aligned: isAligned(policyEvaluated.spf),
|
|
1606
|
+
header_from: identifiers.headerFrom,
|
|
1607
|
+
envelope_from: identifiers.envelopeFrom,
|
|
1608
|
+
envelope_to: identifiers.envelopeTo,
|
|
1609
|
+
np: policy.np ?? null,
|
|
1610
|
+
testing: policy.testing ?? null,
|
|
1611
|
+
discovery_method: policy.discoveryMethod ?? null,
|
|
1612
|
+
generator: metadata.generator ?? null,
|
|
1613
|
+
[TIMESTAMP_FIELD]: recordBegin.isoformat()
|
|
1614
|
+
};
|
|
1615
|
+
for (const override of policyEvaluated.policyOverrideReasons) appendTo(aggDoc, "policy_overrides", {
|
|
1616
|
+
type: override.type,
|
|
1617
|
+
comment: override.comment
|
|
1618
|
+
});
|
|
1619
|
+
for (const dkim of record.authResults.dkim) {
|
|
1620
|
+
appendTo(aggDoc, "dkim_results", {
|
|
1621
|
+
domain: dkim.domain,
|
|
1622
|
+
selector: dkim.selector,
|
|
1623
|
+
result: dkim.result,
|
|
1624
|
+
human_result: dkim.humanResult ?? null
|
|
1625
|
+
});
|
|
1626
|
+
appendTo(aggDoc, "dkim_results_combined", `${dkim.selector} / ${dkim.domain} / ${dkim.result}`);
|
|
1627
|
+
}
|
|
1628
|
+
for (const spf of record.authResults.spf) {
|
|
1629
|
+
appendTo(aggDoc, "spf_results", {
|
|
1630
|
+
domain: spf.domain,
|
|
1631
|
+
scope: spf.scope,
|
|
1632
|
+
result: spf.result,
|
|
1633
|
+
human_result: spf.humanResult ?? null
|
|
1634
|
+
});
|
|
1635
|
+
appendTo(aggDoc, "spf_results_combined", `${spf.scope} / ${spf.domain} / ${spf.result}`);
|
|
1636
|
+
}
|
|
1637
|
+
return {
|
|
1638
|
+
aggDoc,
|
|
1639
|
+
recordBegin
|
|
1640
|
+
};
|
|
1641
|
+
};
|
|
1642
|
+
let storedRecords = /* @__PURE__ */ new Set();
|
|
1643
|
+
if (existing.length > 0) {
|
|
1644
|
+
let documents = null;
|
|
1645
|
+
if (existing.length < records.length) try {
|
|
1646
|
+
documents = records.map((record) => serializeDocument(AGGREGATE_REPORT_DOC, recordDocument(record, null).aggDoc));
|
|
1647
|
+
} catch {
|
|
1648
|
+
documents = null;
|
|
1649
|
+
}
|
|
1650
|
+
let stored;
|
|
1651
|
+
try {
|
|
1652
|
+
stored = await findStoredRecords(documents, existing, async (size) => storedSources(options, searchIndex, query, size));
|
|
1653
|
+
} catch (error) {
|
|
1654
|
+
throw new OpenSearchError(`OpenSearch's search for existing report error: ${errorMessage(error)}`);
|
|
1655
|
+
}
|
|
1656
|
+
if (stored === null) throw alreadySaved();
|
|
1657
|
+
storedRecords = stored;
|
|
1658
|
+
logger.warning(`Resuming aggregate report ID ${sanitizeForLog(pyStr(reportId))} from ${sanitizeForLog(pyStr(orgName))}: ${stored.size} of ${records.length} records are already in OpenSearch`);
|
|
1659
|
+
}
|
|
1660
|
+
const publishedPolicy = {
|
|
1661
|
+
domain: policy.domain,
|
|
1662
|
+
adkim: policy.adkim,
|
|
1663
|
+
aspf: policy.aspf,
|
|
1664
|
+
p: policy.p,
|
|
1665
|
+
sp: policy.sp,
|
|
1666
|
+
pct: integerOrNull(policy.pct, "pct"),
|
|
1667
|
+
fo: policy.fo,
|
|
1668
|
+
np: policy.np ?? null,
|
|
1669
|
+
testing: policy.testing ?? null,
|
|
1670
|
+
discovery_method: policy.discoveryMethod ?? null
|
|
1671
|
+
};
|
|
1672
|
+
const indexes = new ReportIndexes(options);
|
|
1673
|
+
for (const [position, record] of records.entries()) {
|
|
1674
|
+
const { aggDoc, recordBegin } = recordDocument(record, publishedPolicy);
|
|
1675
|
+
let index = undatedIndexName("dmarc_aggregate", options);
|
|
1676
|
+
if (!options.useDataStreams) index = indexes.pick(`${index}-${indexDate(recordBegin, options.monthlyIndexes)}`);
|
|
1677
|
+
if (storedRecords.has(position)) continue;
|
|
1678
|
+
if (options.useDataStreams) await createDataStreams([index], indexSettings(options), INDEX_MAPPINGS.aggregate, options);
|
|
1679
|
+
else await createIndexes([index], indexSettings(options), INDEX_MAPPINGS.aggregate, options);
|
|
1680
|
+
try {
|
|
1681
|
+
aggDoc.passed_dmarc = aggDoc.spf_aligned || aggDoc.dkim_aligned;
|
|
1682
|
+
await saveDocument(options, AGGREGATE_REPORT_DOC, index, aggDoc);
|
|
1683
|
+
} catch (error) {
|
|
1684
|
+
throw new OpenSearchError(`OpenSearch error: ${errorMessage(error)}`);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Flatten a mailparser address header (`[[display_name, address], ...]`) to
|
|
1690
|
+
* one string from its first address: the bare address when the display
|
|
1691
|
+
* name is empty, otherwise `Name <address>`. `null` when there is no address
|
|
1692
|
+
* (`From:`, `To: undisclosed-recipients:;`), which parsedmarc fails on.
|
|
1693
|
+
*/
|
|
1694
|
+
function flattenAddressHeader(value) {
|
|
1695
|
+
if (pyLength(value) === 0) return null;
|
|
1696
|
+
const first = pyIndex(value, 0);
|
|
1697
|
+
if (pyIndex(first, 0) === "") return pyIndex(first, 1);
|
|
1698
|
+
return `${pyJoin(" <", first)}>`;
|
|
1699
|
+
}
|
|
1700
|
+
function pyLength(value) {
|
|
1701
|
+
return Array.isArray(value) || typeof value === "string" ? value.length : null;
|
|
1702
|
+
}
|
|
1703
|
+
/** `value[index]` for the lists and strings header values can be. */
|
|
1704
|
+
function pyIndex(value, index) {
|
|
1705
|
+
if (Array.isArray(value) || typeof value === "string") {
|
|
1706
|
+
if (index >= value.length) throw new Error("list index out of range");
|
|
1707
|
+
return value[index];
|
|
1708
|
+
}
|
|
1709
|
+
throw new TypeError(`'${typeof value}' object is not subscriptable`);
|
|
1710
|
+
}
|
|
1711
|
+
/** `separator.join(value)` for a list of strings or a string. */
|
|
1712
|
+
function pyJoin(separator, value) {
|
|
1713
|
+
if (typeof value === "string") return [...value].join(separator);
|
|
1714
|
+
if (Array.isArray(value)) return value.map(String).join(separator);
|
|
1715
|
+
throw new TypeError("can only join an iterable");
|
|
1716
|
+
}
|
|
1717
|
+
function asRecord(value) {
|
|
1718
|
+
return isRecord(value) ? value : {};
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Saves a parsed DMARC failure report to OpenSearch.
|
|
1722
|
+
*
|
|
1723
|
+
* @throws {AlreadySaved} when the failure sample is already in OpenSearch.
|
|
1724
|
+
* @throws {OpenSearchError} when OpenSearch fails or rejects the document.
|
|
1725
|
+
* @throws {InvalidFailureReport} when the report lacks a required field.
|
|
1726
|
+
*/
|
|
1727
|
+
async function saveFailureReportToOpenSearch(failureReport, options = {}) {
|
|
1728
|
+
logger.info("Saving failure report to OpenSearch");
|
|
1729
|
+
const parsedSample = getKey(failureReport, "parsedSample");
|
|
1730
|
+
const sampleDateValue = getKey(parsedSample, "date");
|
|
1731
|
+
let sampleDate = null;
|
|
1732
|
+
if (sampleDateValue !== null && sampleDateValue !== void 0) sampleDate = humanTimestampToDatetime(String(sampleDateValue));
|
|
1733
|
+
const originalHeaders = asRecord(getKey(parsedSample, "headers"));
|
|
1734
|
+
const headers = {};
|
|
1735
|
+
for (const [name, value] of Object.entries(originalHeaders)) setOwn(headers, name.toLowerCase().replaceAll(".", "_"), value);
|
|
1736
|
+
const arrivalDateUtc = String(getKey(failureReport, "arrivalDateUtc"));
|
|
1737
|
+
const arrivalDate = humanTimestampToDatetime(arrivalDateUtc, { assumeUtc: true });
|
|
1738
|
+
const arrivalDateEpochMilliseconds = Math.trunc(arrivalDate.timestamp() * MS_PER_SECOND);
|
|
1739
|
+
const searchIndex = [searchPattern("dmarc_failure", options), searchPattern("dmarc_forensic", options)].join(",");
|
|
1740
|
+
const queries = [{ match: { arrival_date: arrivalDateEpochMilliseconds } }];
|
|
1741
|
+
let from = null;
|
|
1742
|
+
let to = null;
|
|
1743
|
+
let subject = null;
|
|
1744
|
+
if (Object.hasOwn(headers, "from")) {
|
|
1745
|
+
headers.from = flattenAddressHeader(headers.from);
|
|
1746
|
+
if (headers.from !== null) {
|
|
1747
|
+
from = { "sample.headers.from": headers.from };
|
|
1748
|
+
queries.push({ match_phrase: from });
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
if (Object.hasOwn(headers, "to")) {
|
|
1752
|
+
headers.to = flattenAddressHeader(headers.to);
|
|
1753
|
+
if (headers.to !== null) {
|
|
1754
|
+
to = { "sample.headers.to": headers.to };
|
|
1755
|
+
queries.push({ match_phrase: to });
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
if (Object.hasOwn(headers, "reply-to")) headers["reply-to"] = flattenAddressHeader(headers["reply-to"]);
|
|
1759
|
+
if (Object.hasOwn(headers, "subject")) {
|
|
1760
|
+
subject = headers.subject;
|
|
1761
|
+
queries.push({ match_phrase: { "sample.headers.subject": subject } });
|
|
1762
|
+
}
|
|
1763
|
+
let existing;
|
|
1764
|
+
try {
|
|
1765
|
+
existing = await countExisting(options, searchIndex, andQueries(queries));
|
|
1766
|
+
} catch (error) {
|
|
1767
|
+
throw new OpenSearchError(`OpenSearch's search for existing report error: ${errorMessage(error)}`);
|
|
1768
|
+
}
|
|
1769
|
+
if (existing > 0) throw new AlreadySaved(`A failure sample to ${pyStr(to)} from ${pyStr(from)} with a subject of ${pyStr(subject)} and arrival date of ${arrivalDateUtc} already exists in OpenSearch`);
|
|
1770
|
+
const sample = {
|
|
1771
|
+
raw: getKey(failureReport, "sample"),
|
|
1772
|
+
headers,
|
|
1773
|
+
headers_only: getKey(failureReport, "sampleHeadersOnly"),
|
|
1774
|
+
date: sampleDate,
|
|
1775
|
+
subject: getKey(parsedSample, "subject"),
|
|
1776
|
+
filename_safe_subject: getKey(parsedSample, "filenameSafeSubject"),
|
|
1777
|
+
body: getKey(parsedSample, "body")
|
|
1778
|
+
};
|
|
1779
|
+
for (const [key, field] of [
|
|
1780
|
+
["to", "to"],
|
|
1781
|
+
["replyTo", "reply_to"],
|
|
1782
|
+
["cc", "cc"],
|
|
1783
|
+
["bcc", "bcc"]
|
|
1784
|
+
]) for (const address of getKey(parsedSample, key)) appendTo(sample, field, {
|
|
1785
|
+
display_name: getKey(address, "displayName"),
|
|
1786
|
+
address: getKey(address, "address")
|
|
1787
|
+
});
|
|
1788
|
+
for (const attachment of getKey(parsedSample, "attachments")) appendTo(sample, "attachments", {
|
|
1789
|
+
filename: getKey(attachment, "filename"),
|
|
1790
|
+
content_type: getKey(attachment, "mailContentType"),
|
|
1791
|
+
sha256: getKey(attachment, "sha256")
|
|
1792
|
+
});
|
|
1793
|
+
try {
|
|
1794
|
+
const source = getKey(failureReport, "source");
|
|
1795
|
+
const failureDoc = {
|
|
1796
|
+
feedback_type: getKey(failureReport, "feedbackType"),
|
|
1797
|
+
user_agent: getKey(failureReport, "userAgent"),
|
|
1798
|
+
version: getKey(failureReport, "version"),
|
|
1799
|
+
original_mail_from: getKey(failureReport, "originalMailFrom"),
|
|
1800
|
+
arrival_date: arrivalDateEpochMilliseconds,
|
|
1801
|
+
domain: getKey(failureReport, "reportedDomain"),
|
|
1802
|
+
original_envelope_id: getKey(failureReport, "originalEnvelopeId"),
|
|
1803
|
+
authentication_results: getKey(failureReport, "authenticationResults"),
|
|
1804
|
+
delivery_results: getKey(failureReport, "deliveryResult"),
|
|
1805
|
+
source_ip_address: getKey(source, "ipAddress"),
|
|
1806
|
+
source_country: getKey(source, "country"),
|
|
1807
|
+
source_reverse_dns: getKey(source, "reverseDns"),
|
|
1808
|
+
source_base_domain: getKey(source, "baseDomain"),
|
|
1809
|
+
source_asn: getKey(source, "asn"),
|
|
1810
|
+
source_as_name: getKey(source, "asName"),
|
|
1811
|
+
source_as_domain: getKey(source, "asDomain"),
|
|
1812
|
+
authentication_mechanisms: getKey(failureReport, "authenticationMechanisms"),
|
|
1813
|
+
auth_failure: getKey(failureReport, "authFailure"),
|
|
1814
|
+
dkim_domain: getKey(failureReport, "dkimDomain"),
|
|
1815
|
+
original_rcpt_to: getKey(failureReport, "originalRcptTo"),
|
|
1816
|
+
sample,
|
|
1817
|
+
[TIMESTAMP_FIELD]: arrivalDate.isoformat()
|
|
1818
|
+
};
|
|
1819
|
+
let index = undatedIndexName("dmarc_failure", options);
|
|
1820
|
+
if (options.useDataStreams) await createDataStreams([index], indexSettings(options), INDEX_MAPPINGS.failure, options);
|
|
1821
|
+
else {
|
|
1822
|
+
index = `${index}-${indexDate(arrivalDate, options.monthlyIndexes)}`;
|
|
1823
|
+
await createIndexes([index], indexSettings(options), INDEX_MAPPINGS.failure, options);
|
|
1824
|
+
}
|
|
1825
|
+
try {
|
|
1826
|
+
await saveDocument(options, FAILURE_REPORT_DOC, index, failureDoc);
|
|
1827
|
+
} catch (error) {
|
|
1828
|
+
throw new OpenSearchError(`OpenSearch error: ${errorMessage(error)}`);
|
|
1829
|
+
}
|
|
1830
|
+
} catch (error) {
|
|
1831
|
+
if (error instanceof KeyError) throw new InvalidFailureReport(`Failure report missing required field: ${error.message}`);
|
|
1832
|
+
throw error;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Saves a parsed SMTP TLS report to OpenSearch.
|
|
1837
|
+
*
|
|
1838
|
+
* @throws {AlreadySaved} when the report is already in OpenSearch.
|
|
1839
|
+
* @throws {OpenSearchError} when OpenSearch fails or rejects the document.
|
|
1840
|
+
*/
|
|
1841
|
+
async function saveSmtpTlsReportToOpenSearch(report, options = {}) {
|
|
1842
|
+
logger.info("Saving SMTP TLS report to OpenSearch");
|
|
1843
|
+
const orgName = report.organizationName;
|
|
1844
|
+
const { reportId } = report;
|
|
1845
|
+
const beginDate = humanTimestampToDatetime(report.beginDate, { toUtc: true });
|
|
1846
|
+
const endDate = humanTimestampToDatetime(report.endDate, { toUtc: true });
|
|
1847
|
+
const beginDateHuman = beginDate.strftime("%Y-%m-%d %H:%M:%SZ");
|
|
1848
|
+
const endDateHuman = endDate.strftime("%Y-%m-%d %H:%M:%SZ");
|
|
1849
|
+
const query = andQueries([
|
|
1850
|
+
{ match_phrase: { org_name: orgName } },
|
|
1851
|
+
{ match_phrase: { report_id: reportId } },
|
|
1852
|
+
{ match: { date_begin: beginDate.isoformat() } },
|
|
1853
|
+
{ match: { date_end: endDate.isoformat() } }
|
|
1854
|
+
]);
|
|
1855
|
+
const searchIndex = searchPattern("smtp_tls", options);
|
|
1856
|
+
let existing;
|
|
1857
|
+
try {
|
|
1858
|
+
existing = await countExisting(options, searchIndex, query);
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
throw new OpenSearchError(`OpenSearch's search for existing report error: ${errorMessage(error)}`);
|
|
1861
|
+
}
|
|
1862
|
+
if (existing > 0) throw new AlreadySaved(`An SMTP TLS report ID ${reportId} from ${orgName} with a date range of ${beginDateHuman} UTC to ${endDateHuman} UTC already exists in OpenSearch`);
|
|
1863
|
+
let index = undatedIndexName("smtp_tls", options);
|
|
1864
|
+
if (!options.useDataStreams) index = `${index}-${indexDate(beginDate, options.monthlyIndexes)}`;
|
|
1865
|
+
const smtpTlsDoc = {
|
|
1866
|
+
org_name: report.organizationName,
|
|
1867
|
+
date_range: [beginDate, endDate],
|
|
1868
|
+
date_begin: beginDate,
|
|
1869
|
+
date_end: endDate,
|
|
1870
|
+
contact_info: report.contactInfo,
|
|
1871
|
+
report_id: report.reportId,
|
|
1872
|
+
[TIMESTAMP_FIELD]: beginDate.isoformat()
|
|
1873
|
+
};
|
|
1874
|
+
for (const policy of report.policies) {
|
|
1875
|
+
const policyStrings = "policyStrings" in policy ? policy.policyStrings : null;
|
|
1876
|
+
const mxHostPatterns = "mxHostPatterns" in policy ? policy.mxHostPatterns : null;
|
|
1877
|
+
const policyDomainCombined = policy.policyDomain || "none";
|
|
1878
|
+
const policyTypeCombined = policy.policyType || "none";
|
|
1879
|
+
appendTo(smtpTlsDoc, "policies_combined", `${policyDomainCombined} / ${policyTypeCombined}`);
|
|
1880
|
+
const policyDoc = {
|
|
1881
|
+
policy_domain: policy.policyDomain,
|
|
1882
|
+
policy_type: policy.policyType,
|
|
1883
|
+
successful_session_count: policy.successfulSessionCount,
|
|
1884
|
+
failed_session_count: policy.failedSessionCount,
|
|
1885
|
+
policy_string: policyStrings ?? null,
|
|
1886
|
+
mx_host_patterns: mxHostPatterns ?? null
|
|
1887
|
+
};
|
|
1888
|
+
if ("failureDetails" in policy) for (const detail of policy.failureDetails ?? []) {
|
|
1889
|
+
const longForm = detail;
|
|
1890
|
+
let additionalInformationUri = null;
|
|
1891
|
+
if ("additionalInfoUri" in detail) additionalInformationUri = detail.additionalInfoUri;
|
|
1892
|
+
else if ("additionalInformationUri" in detail) additionalInformationUri = longForm.additionalInformationUri;
|
|
1893
|
+
const pick = (key) => key in detail ? detail[key] : null;
|
|
1894
|
+
const sendingMtaIp = pick("sendingMtaIp");
|
|
1895
|
+
const receivingIp = pick("receivingIp");
|
|
1896
|
+
const receivingMxHostname = pick("receivingMxHostname");
|
|
1897
|
+
appendTo(policyDoc, "failure_details", {
|
|
1898
|
+
result_type: detail.resultType,
|
|
1899
|
+
ip_address: pick("ipAddress"),
|
|
1900
|
+
sending_mta_ip: ipOrNull(sendingMtaIp, "sending_mta_ip"),
|
|
1901
|
+
receiving_mx_hostname: receivingMxHostname,
|
|
1902
|
+
receiving_mx_helo: pick("receivingMxHelo"),
|
|
1903
|
+
receiving_ip: ipOrNull(receivingIp, "receiving_ip"),
|
|
1904
|
+
failed_session_count: detail.failedSessionCount,
|
|
1905
|
+
additional_information_uri: additionalInformationUri,
|
|
1906
|
+
failure_reason_code: pick("failureReasonCode")
|
|
1907
|
+
});
|
|
1908
|
+
appendTo(smtpTlsDoc, "failure_details_combined", [
|
|
1909
|
+
policyDomainCombined,
|
|
1910
|
+
policyTypeCombined,
|
|
1911
|
+
detail.resultType || "none",
|
|
1912
|
+
pyStr(sendingMtaIp || "none"),
|
|
1913
|
+
pyStr(receivingIp || "none"),
|
|
1914
|
+
pyStr(receivingMxHostname || "none")
|
|
1915
|
+
].join(" / "));
|
|
1916
|
+
}
|
|
1917
|
+
appendTo(smtpTlsDoc, "policies", policyDoc);
|
|
1918
|
+
}
|
|
1919
|
+
if (options.useDataStreams) await createDataStreams([index], indexSettings(options), INDEX_MAPPINGS.smtpTls, options);
|
|
1920
|
+
else await createIndexes([index], indexSettings(options), INDEX_MAPPINGS.smtpTls, options);
|
|
1921
|
+
try {
|
|
1922
|
+
await saveDocument(options, SMTP_TLS_REPORT_DOC, index, smtpTlsDoc);
|
|
1923
|
+
} catch (error) {
|
|
1924
|
+
throw new OpenSearchError(`OpenSearch error: ${errorMessage(error)}`);
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
/** Backward-compatible alias of {@link saveFailureReportToOpenSearch}. */
|
|
1928
|
+
const saveForensicReportToOpenSearch = saveFailureReportToOpenSearch;
|
|
1929
|
+
export { AlreadySaved, BACKFILL, BulkIndexError, DOCUMENT_MAPPINGS, OpenSearchError, ScanError, createClient, createDataStreams, createIndexes, getDefaultConnection, migrateIndexes, saveAggregateReportToOpenSearch, saveFailureReportToOpenSearch, saveForensicReportToOpenSearch, saveSmtpTlsReportToOpenSearch, setDefaultConnection, setHosts };
|
|
1930
|
+
|
|
1931
|
+
//# sourceMappingURL=index.js.map
|