@slicedearth/whoisleuth-cli 2.2.0 → 2.3.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/README.md +2 -1
- package/cli/arguments.mjs +389 -1745
- package/cli/artifact-validation/case-response.mjs +55 -30
- package/cli/artifact-validation/signed-review.mjs +30 -12
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/case-pack.mjs +39 -92
- package/cli/command-argument-grammar.mjs +211 -0
- package/cli/command-reference.mjs +200 -213
- package/cli/completion.mjs +5 -1
- package/cli/discovery-observation-snapshot.mjs +16 -3
- package/cli/discovery-scan-command-runner.mjs +19 -0
- package/cli/discovery-scan.mjs +6 -0
- package/cli/domain-control-observations.mjs +1 -1
- package/cli/inline-command-families.mjs +1 -0
- package/cli/input.mjs +116 -0
- package/cli/interchange-report.mjs +14 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/mail-header-review.mjs +467 -0
- package/cli/review-command-runner.mjs +25 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +7 -1
- package/docs/cli-reference.md +8 -1
- package/docs/cli.md +12 -1
- package/lib/bounded-json.mjs +9 -6
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-intelligence.mjs +37 -3
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/domain-change-packet.mjs +42 -14
- package/lib/domain-control-flight-recorder.mjs +4 -1
- package/lib/domain-control-manifest.mjs +16 -16
- package/lib/domain-posture-parsers.mjs +114 -3
- package/lib/domain-posture.mjs +34 -3
- package/lib/evidence-export.mjs +60 -11
- package/lib/evidence-report.mjs +2 -2
- package/lib/html-signals.mjs +39 -33
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-response.mjs +5 -4
- package/lib/lookup-source-progress.mjs +27 -4
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/registry-insights.mjs +35 -22
- package/lib/risk-calibration-summary.mjs +1 -3
- package/lib/security-txt.mjs +33 -1
- package/lib/static-html-analysis.mjs +69 -13
- package/lib/website-technology.mjs +79 -11
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +1 -1
- package/packages/cases/case-migration-model.mjs +71 -22
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +10 -21
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +6 -4
- package/packages/cases/case-record-projection.mjs +218 -0
- package/packages/cases/case-report.mjs +11 -6
- package/packages/cases/case-response-model.mjs +43 -6
- package/packages/cases/case-response-packet.mjs +78 -27
- package/packages/cases/case-storage-model.mjs +6 -2
- package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
- package/packages/comparison/page-similarity.mjs +3 -1
- package/packages/contracts/case-portability.mjs +171 -51
- package/packages/contracts/cli-command-semantics.mjs +93 -0
- package/packages/contracts/risk-calibration.mjs +15 -10
- package/packages/contracts/workspace-portability.mjs +44 -7
- package/packages/evidence/domain-control-runtime.mjs +46 -6
- package/packages/interchange/external-findings-import.mjs +3 -2
- package/packages/investigation/lookup-asset-graph.mjs +35 -27
- package/packages/workspace/brand-profile-model.mjs +118 -2
- package/packages/workspace/bulk-review-model.mjs +13 -7
- package/packages/workspace/bulk-session-model.mjs +5 -4
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/relationship-observation-model.mjs +4 -1
- package/packages/workspace/website-snapshot-model.mjs +106 -8
- package/packages/workspace/workspace-archive.mjs +2 -0
- package/cli/evidence-command-arguments.mjs +0 -156
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Buffer } from 'node:buffer';
|
|
3
|
+
import { isValidAsciiDomainName } from "../lib/hostname.mjs";
|
|
4
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
5
|
+
import { CliUsageError } from "./errors.mjs";
|
|
6
|
+
export const CLI_MAIL_HEADER_REVIEW_SCHEMA = 'whoisleuth.cli.mail-header-review';
|
|
7
|
+
export const CLI_MAIL_HEADER_REVIEW_VERSION = 1;
|
|
8
|
+
export const MAX_MAIL_HEADER_INPUT_BYTES = 256 * 1024;
|
|
9
|
+
export const MAX_MAIL_HEADER_FIELDS = 512;
|
|
10
|
+
export const MAX_MAIL_HEADER_LINE_BYTES = 4 * 1024;
|
|
11
|
+
export const MAX_MAIL_HEADER_VALUE_LENGTH = 16 * 1024;
|
|
12
|
+
export const MAX_MAIL_RECEIVED_HOPS = 64;
|
|
13
|
+
export const MAX_MAIL_REVIEW_DOMAINS = 128;
|
|
14
|
+
const UNSAFE_HEADER_CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f]/u;
|
|
15
|
+
const HEADER_NAME_RE = /^[A-Za-z0-9-]{1,78}$/u;
|
|
16
|
+
const AUTHENTICATION_METHODS = Object.freeze(['spf', 'dkim', 'dmarc', 'arc']);
|
|
17
|
+
const AUTHENTICATION_STATES = new Set(['fail', 'neutral', 'none', 'pass', 'permerror', 'softfail', 'temperror']);
|
|
18
|
+
function boundedHeaderSection(value) {
|
|
19
|
+
if (typeof value !== 'string' || !value)
|
|
20
|
+
throw new CliUsageError('Mail-header review requires one UTF-8 message or header block.');
|
|
21
|
+
const candidate = value.slice(0, MAX_MAIL_HEADER_INPUT_BYTES * 2 + 4);
|
|
22
|
+
const crlf = candidate.indexOf('\r\n\r\n');
|
|
23
|
+
const lf = candidate.indexOf('\n\n');
|
|
24
|
+
const separator = crlf < 0 ? lf : lf < 0 ? crlf : Math.min(crlf, lf);
|
|
25
|
+
const header = separator >= 0 ? candidate.slice(0, separator) : candidate;
|
|
26
|
+
const bytes = Buffer.byteLength(header, 'utf8');
|
|
27
|
+
if (bytes < 1 || bytes > MAX_MAIL_HEADER_INPUT_BYTES || (separator < 0 && value.length > candidate.length)) {
|
|
28
|
+
throw new CliUsageError(`Mail headers are limited to ${MAX_MAIL_HEADER_INPUT_BYTES} bytes before the message body.`);
|
|
29
|
+
}
|
|
30
|
+
return header;
|
|
31
|
+
}
|
|
32
|
+
function parseHeaderFields(value) {
|
|
33
|
+
const header = boundedHeaderSection(value);
|
|
34
|
+
const physicalLines = header.split(/\r\n|\n/u);
|
|
35
|
+
if (physicalLines.at(-1) === '')
|
|
36
|
+
physicalLines.pop();
|
|
37
|
+
const fields = [];
|
|
38
|
+
for (const [index, line] of physicalLines.entries()) {
|
|
39
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_MAIL_HEADER_LINE_BYTES) {
|
|
40
|
+
throw new CliUsageError(`Mail header line ${index + 1} exceeds the ${MAX_MAIL_HEADER_LINE_BYTES}-byte limit.`);
|
|
41
|
+
}
|
|
42
|
+
if (UNSAFE_HEADER_CONTROL_RE.test(line))
|
|
43
|
+
throw new CliUsageError(`Mail header line ${index + 1} contains unsafe control characters.`);
|
|
44
|
+
if (/^[ \t]/u.test(line)) {
|
|
45
|
+
const previous = fields.at(-1);
|
|
46
|
+
if (!previous)
|
|
47
|
+
throw new CliUsageError('Mail headers contain a continuation without a preceding field.');
|
|
48
|
+
const continuation = line.trim();
|
|
49
|
+
const combined = continuation ? `${previous.value} ${continuation}` : previous.value;
|
|
50
|
+
if (combined.length > MAX_MAIL_HEADER_VALUE_LENGTH)
|
|
51
|
+
throw new CliUsageError(`Mail header ${previous.name} exceeds its unfolded value limit.`);
|
|
52
|
+
previous.value = combined;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const separator = line.indexOf(':');
|
|
56
|
+
if (separator < 1)
|
|
57
|
+
throw new CliUsageError(`Mail header line ${index + 1} is malformed.`);
|
|
58
|
+
const name = line.slice(0, separator);
|
|
59
|
+
const fieldValue = line.slice(separator + 1).trim();
|
|
60
|
+
if (!HEADER_NAME_RE.test(name))
|
|
61
|
+
throw new CliUsageError(`Mail header line ${index + 1} has an invalid field name.`);
|
|
62
|
+
if (fieldValue.length > MAX_MAIL_HEADER_VALUE_LENGTH)
|
|
63
|
+
throw new CliUsageError(`Mail header ${name} exceeds its unfolded value limit.`);
|
|
64
|
+
fields.push({ name: name.toLowerCase(), value: fieldValue });
|
|
65
|
+
if (fields.length > MAX_MAIL_HEADER_FIELDS)
|
|
66
|
+
throw new CliUsageError(`Mail headers contain more than ${MAX_MAIL_HEADER_FIELDS} fields.`);
|
|
67
|
+
}
|
|
68
|
+
if (!fields.length)
|
|
69
|
+
throw new CliUsageError('Mail-header review did not find any header fields.');
|
|
70
|
+
return Object.freeze({
|
|
71
|
+
fields: Object.freeze(fields.map((field) => Object.freeze({ ...field }))),
|
|
72
|
+
bytes: Buffer.byteLength(header, 'utf8'),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function fieldsNamed(fields, name) {
|
|
76
|
+
return fields.filter((field) => field.name === name).map((field) => field.value);
|
|
77
|
+
}
|
|
78
|
+
function normalizeHeaderDomain(value) {
|
|
79
|
+
const normalized = value.trim().toLowerCase().replace(/^\[|\]$/gu, '').replace(/\.$/u, '');
|
|
80
|
+
return isValidAsciiDomainName(normalized, { requireLowercase: true }) ? normalized : null;
|
|
81
|
+
}
|
|
82
|
+
function lexicalSegments(value, delimiter, trackAngles = false) {
|
|
83
|
+
const segments = [];
|
|
84
|
+
let start = 0;
|
|
85
|
+
let quoted = false;
|
|
86
|
+
let escaped = false;
|
|
87
|
+
let commentDepth = 0;
|
|
88
|
+
let angleDepth = 0;
|
|
89
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
90
|
+
const character = value[index] ?? '';
|
|
91
|
+
if (escaped) {
|
|
92
|
+
escaped = false;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (character === '\\' && (quoted || commentDepth > 0)) {
|
|
96
|
+
escaped = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (commentDepth > 0) {
|
|
100
|
+
if (character === '(')
|
|
101
|
+
commentDepth += 1;
|
|
102
|
+
else if (character === ')')
|
|
103
|
+
commentDepth -= 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (quoted) {
|
|
107
|
+
if (character === '"')
|
|
108
|
+
quoted = false;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (character === '"')
|
|
112
|
+
quoted = true;
|
|
113
|
+
else if (character === '(')
|
|
114
|
+
commentDepth = 1;
|
|
115
|
+
else if (character === ')')
|
|
116
|
+
return null;
|
|
117
|
+
else if (trackAngles && character === '<') {
|
|
118
|
+
if (angleDepth !== 0)
|
|
119
|
+
return null;
|
|
120
|
+
angleDepth = 1;
|
|
121
|
+
}
|
|
122
|
+
else if (trackAngles && character === '>') {
|
|
123
|
+
if (angleDepth !== 1)
|
|
124
|
+
return null;
|
|
125
|
+
angleDepth = 0;
|
|
126
|
+
}
|
|
127
|
+
else if (character === delimiter && angleDepth === 0) {
|
|
128
|
+
segments.push(value.slice(start, index));
|
|
129
|
+
start = index + 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (escaped || quoted || commentDepth !== 0 || angleDepth !== 0)
|
|
133
|
+
return null;
|
|
134
|
+
segments.push(value.slice(start));
|
|
135
|
+
return segments;
|
|
136
|
+
}
|
|
137
|
+
function angleAddress(value) {
|
|
138
|
+
let quoted = false;
|
|
139
|
+
let escaped = false;
|
|
140
|
+
let commentDepth = 0;
|
|
141
|
+
let start = -1;
|
|
142
|
+
let end = -1;
|
|
143
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
144
|
+
const character = value[index] ?? '';
|
|
145
|
+
if (escaped) {
|
|
146
|
+
escaped = false;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (character === '\\' && (quoted || commentDepth > 0)) {
|
|
150
|
+
escaped = true;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (commentDepth > 0) {
|
|
154
|
+
if (character === '(')
|
|
155
|
+
commentDepth += 1;
|
|
156
|
+
else if (character === ')')
|
|
157
|
+
commentDepth -= 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (quoted) {
|
|
161
|
+
if (character === '"')
|
|
162
|
+
quoted = false;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (character === '"')
|
|
166
|
+
quoted = true;
|
|
167
|
+
else if (character === '(')
|
|
168
|
+
commentDepth = 1;
|
|
169
|
+
else if (character === ')')
|
|
170
|
+
return null;
|
|
171
|
+
else if (character === '<') {
|
|
172
|
+
if (start >= 0 || end >= 0)
|
|
173
|
+
return null;
|
|
174
|
+
start = index;
|
|
175
|
+
}
|
|
176
|
+
else if (character === '>') {
|
|
177
|
+
if (start < 0 || end >= 0)
|
|
178
|
+
return null;
|
|
179
|
+
end = index;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (escaped || quoted || commentDepth !== 0 || (start >= 0) !== (end >= 0))
|
|
183
|
+
return null;
|
|
184
|
+
return start >= 0 ? value.slice(start + 1, end) : value;
|
|
185
|
+
}
|
|
186
|
+
function addressDomain(value) {
|
|
187
|
+
let candidate = '';
|
|
188
|
+
let quoted = false;
|
|
189
|
+
let escaped = false;
|
|
190
|
+
let commentDepth = 0;
|
|
191
|
+
const separators = [];
|
|
192
|
+
for (const character of value) {
|
|
193
|
+
if (escaped) {
|
|
194
|
+
candidate += character;
|
|
195
|
+
escaped = false;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (character === '\\' && (quoted || commentDepth > 0)) {
|
|
199
|
+
if (quoted)
|
|
200
|
+
candidate += character;
|
|
201
|
+
escaped = true;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (commentDepth > 0) {
|
|
205
|
+
if (character === '(')
|
|
206
|
+
commentDepth += 1;
|
|
207
|
+
else if (character === ')')
|
|
208
|
+
commentDepth -= 1;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (quoted) {
|
|
212
|
+
candidate += character;
|
|
213
|
+
if (character === '"')
|
|
214
|
+
quoted = false;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (character === '"') {
|
|
218
|
+
quoted = true;
|
|
219
|
+
candidate += character;
|
|
220
|
+
}
|
|
221
|
+
else if (character === '(')
|
|
222
|
+
commentDepth = 1;
|
|
223
|
+
else if (character === ')')
|
|
224
|
+
return null;
|
|
225
|
+
else {
|
|
226
|
+
if (character === '@')
|
|
227
|
+
separators.push(candidate.length);
|
|
228
|
+
candidate += character;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (escaped || quoted || commentDepth !== 0 || separators.length !== 1)
|
|
232
|
+
return null;
|
|
233
|
+
const separator = separators[0] ?? -1;
|
|
234
|
+
const local = candidate.slice(0, separator).trim();
|
|
235
|
+
const domain = candidate.slice(separator + 1).trim();
|
|
236
|
+
const validLocal = /^(?:[^\s"<>(),:;@]+|"(?:[^"\\]|\\.)+")$/u.test(local);
|
|
237
|
+
if (!validLocal || !domain || /\s/u.test(domain))
|
|
238
|
+
return null;
|
|
239
|
+
return normalizeHeaderDomain(domain);
|
|
240
|
+
}
|
|
241
|
+
function addressDomains(values) {
|
|
242
|
+
const domains = new Set();
|
|
243
|
+
for (const value of values) {
|
|
244
|
+
const mailboxes = lexicalSegments(value, ',', true);
|
|
245
|
+
if (!mailboxes)
|
|
246
|
+
continue;
|
|
247
|
+
for (const mailbox of mailboxes) {
|
|
248
|
+
const selected = angleAddress(mailbox);
|
|
249
|
+
const domain = selected === null ? null : addressDomain(selected);
|
|
250
|
+
if (domain)
|
|
251
|
+
domains.add(domain);
|
|
252
|
+
if (domains.size >= MAX_MAIL_REVIEW_DOMAINS)
|
|
253
|
+
return [...domains].sort();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return [...domains].sort();
|
|
257
|
+
}
|
|
258
|
+
function messageIdDomains(values) {
|
|
259
|
+
return addressDomains(values.map((value) => value.replace(/[<>]/gu, '')));
|
|
260
|
+
}
|
|
261
|
+
function authenticationState(values, method, directValues = []) {
|
|
262
|
+
const states = new Set();
|
|
263
|
+
let observations = 0;
|
|
264
|
+
for (const value of values) {
|
|
265
|
+
const clauses = lexicalSegments(value, ';');
|
|
266
|
+
if (!clauses)
|
|
267
|
+
continue;
|
|
268
|
+
for (const clause of clauses) {
|
|
269
|
+
const match = /^\s*([a-z][a-z0-9_-]*)\s*=\s*([a-z0-9_-]+)(?=\s|$)/iu.exec(clause);
|
|
270
|
+
if (match?.[1]?.toLowerCase() === method) {
|
|
271
|
+
observations += 1;
|
|
272
|
+
const raw = (match[2] ?? '').toLowerCase();
|
|
273
|
+
states.add(AUTHENTICATION_STATES.has(raw) ? raw : 'unknown');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const value of directValues) {
|
|
278
|
+
const match = /^\s*([a-z0-9_-]+)(?=\s|\(|$)/iu.exec(value);
|
|
279
|
+
const raw = (match?.[1] ?? '').toLowerCase();
|
|
280
|
+
if (!raw)
|
|
281
|
+
continue;
|
|
282
|
+
observations += 1;
|
|
283
|
+
states.add(AUTHENTICATION_STATES.has(raw) ? raw : 'unknown');
|
|
284
|
+
}
|
|
285
|
+
return Object.freeze({
|
|
286
|
+
state: observations === 0 ? 'unknown' : states.size === 1 ? [...states][0] : 'mixed',
|
|
287
|
+
observations,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
function authenticationServiceDomains(values) {
|
|
291
|
+
const output = new Set();
|
|
292
|
+
for (const value of values) {
|
|
293
|
+
const clauses = lexicalSegments(value, ';');
|
|
294
|
+
const service = normalizeHeaderDomain((clauses?.[0] ?? '').trim().split(/\s/u, 1)[0] ?? '');
|
|
295
|
+
if (service)
|
|
296
|
+
output.add(service);
|
|
297
|
+
}
|
|
298
|
+
return [...output].sort().slice(0, MAX_MAIL_REVIEW_DOMAINS);
|
|
299
|
+
}
|
|
300
|
+
function dkimSigningDomains(values) {
|
|
301
|
+
const output = new Set();
|
|
302
|
+
for (const value of values) {
|
|
303
|
+
for (const clause of lexicalSegments(value, ';') ?? []) {
|
|
304
|
+
const match = /^\s*d\s*=\s*([^\s;]+)/iu.exec(clause);
|
|
305
|
+
const domain = normalizeHeaderDomain(match?.[1] ?? '');
|
|
306
|
+
if (domain)
|
|
307
|
+
output.add(domain);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return [...output].sort().slice(0, MAX_MAIL_REVIEW_DOMAINS);
|
|
311
|
+
}
|
|
312
|
+
function reportedDate(values) {
|
|
313
|
+
if (values.length !== 1 || values[0].length > 240)
|
|
314
|
+
return null;
|
|
315
|
+
const parsed = Date.parse(values[0]);
|
|
316
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
317
|
+
}
|
|
318
|
+
function receivedHost(value, keyword) {
|
|
319
|
+
const expression = keyword === 'from' ? /(?:^|\s)from\s+([^\s(;]+)/iu : /(?:^|\s)by\s+([^\s(;]+)/iu;
|
|
320
|
+
const match = expression.exec(value);
|
|
321
|
+
return normalizeHeaderDomain(match?.[1] ?? '');
|
|
322
|
+
}
|
|
323
|
+
function receivedTransport(value) {
|
|
324
|
+
const match = /(?:^|\s)with\s+([A-Za-z0-9_-]{1,40})/iu.exec(value);
|
|
325
|
+
return match?.[1]?.toUpperCase() ?? null;
|
|
326
|
+
}
|
|
327
|
+
function receivedTimestamp(value) {
|
|
328
|
+
const separator = value.lastIndexOf(';');
|
|
329
|
+
if (separator < 0)
|
|
330
|
+
return null;
|
|
331
|
+
const raw = value.slice(separator + 1).trim();
|
|
332
|
+
if (!raw || raw.length > 240)
|
|
333
|
+
return null;
|
|
334
|
+
const parsed = Date.parse(raw);
|
|
335
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
336
|
+
}
|
|
337
|
+
function exactAlignment(left, right) {
|
|
338
|
+
if (!left.length || !right.length)
|
|
339
|
+
return 'unavailable';
|
|
340
|
+
return left.some((domain) => right.includes(domain)) ? 'aligned' : 'divergent';
|
|
341
|
+
}
|
|
342
|
+
function addDomainRoles(store, domains, role) {
|
|
343
|
+
for (const domain of domains) {
|
|
344
|
+
if (!store.has(domain) && store.size >= MAX_MAIL_REVIEW_DOMAINS)
|
|
345
|
+
return;
|
|
346
|
+
const roles = store.get(domain) ?? new Set();
|
|
347
|
+
roles.add(role);
|
|
348
|
+
store.set(domain, roles);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
export function buildCliMailHeaderReview(value, generatedAt = new Date().toISOString()) {
|
|
352
|
+
const normalizedGeneratedAt = normalizeExplicitIsoTimestamp(generatedAt);
|
|
353
|
+
if (!normalizedGeneratedAt)
|
|
354
|
+
throw new CliUsageError('Mail-header review generation time must be a valid ISO 8601 timestamp.');
|
|
355
|
+
const parsed = parseHeaderFields(value);
|
|
356
|
+
const fromDomains = addressDomains(fieldsNamed(parsed.fields, 'from'));
|
|
357
|
+
const replyToDomains = addressDomains(fieldsNamed(parsed.fields, 'reply-to'));
|
|
358
|
+
const returnPathDomains = addressDomains(fieldsNamed(parsed.fields, 'return-path'));
|
|
359
|
+
const messageIdentifierDomains = messageIdDomains(fieldsNamed(parsed.fields, 'message-id'));
|
|
360
|
+
const authenticationResults = fieldsNamed(parsed.fields, 'authentication-results');
|
|
361
|
+
const receivedSpf = fieldsNamed(parsed.fields, 'received-spf');
|
|
362
|
+
const authentication = Object.fromEntries(AUTHENTICATION_METHODS.map((method) => [
|
|
363
|
+
method,
|
|
364
|
+
authenticationState(authenticationResults, method, method === 'spf' ? receivedSpf : []),
|
|
365
|
+
]));
|
|
366
|
+
const authServiceDomains = authenticationServiceDomains(authenticationResults);
|
|
367
|
+
const signerDomains = dkimSigningDomains(fieldsNamed(parsed.fields, 'dkim-signature'));
|
|
368
|
+
const receivedValues = fieldsNamed(parsed.fields, 'received');
|
|
369
|
+
const omittedReceivedHops = Math.max(0, receivedValues.length - MAX_MAIL_RECEIVED_HOPS);
|
|
370
|
+
const receivedHops = receivedValues.slice(0, MAX_MAIL_RECEIVED_HOPS).map((header, index) => Object.freeze({
|
|
371
|
+
position: index + 1,
|
|
372
|
+
order: 'most_recent_first',
|
|
373
|
+
fromDomain: receivedHost(header, 'from'),
|
|
374
|
+
byDomain: receivedHost(header, 'by'),
|
|
375
|
+
transport: receivedTransport(header),
|
|
376
|
+
reportedAt: receivedTimestamp(header),
|
|
377
|
+
}));
|
|
378
|
+
const roles = new Map();
|
|
379
|
+
addDomainRoles(roles, fromDomains, 'from');
|
|
380
|
+
addDomainRoles(roles, replyToDomains, 'reply_to');
|
|
381
|
+
addDomainRoles(roles, returnPathDomains, 'return_path');
|
|
382
|
+
addDomainRoles(roles, messageIdentifierDomains, 'message_id');
|
|
383
|
+
addDomainRoles(roles, authServiceDomains, 'authentication_service');
|
|
384
|
+
addDomainRoles(roles, signerDomains, 'dkim_signer');
|
|
385
|
+
for (const hop of receivedHops) {
|
|
386
|
+
if (hop.fromDomain)
|
|
387
|
+
addDomainRoles(roles, [hop.fromDomain], 'received_from');
|
|
388
|
+
if (hop.byDomain)
|
|
389
|
+
addDomainRoles(roles, [hop.byDomain], 'received_by');
|
|
390
|
+
}
|
|
391
|
+
const domains = [...roles.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([domain, domainRoles]) => Object.freeze({
|
|
392
|
+
domain,
|
|
393
|
+
roles: Object.freeze([...domainRoles].sort()),
|
|
394
|
+
}));
|
|
395
|
+
const digest = createHash('sha256').update(boundedHeaderSection(value), 'utf8').digest('hex');
|
|
396
|
+
return Object.freeze({
|
|
397
|
+
schema: CLI_MAIL_HEADER_REVIEW_SCHEMA,
|
|
398
|
+
version: CLI_MAIL_HEADER_REVIEW_VERSION,
|
|
399
|
+
generatedAt: normalizedGeneratedAt,
|
|
400
|
+
provenance: Object.freeze({
|
|
401
|
+
input: 'analyst_selected_header_block',
|
|
402
|
+
headerBytes: parsed.bytes,
|
|
403
|
+
headerFields: parsed.fields.length,
|
|
404
|
+
headerDigestSha256: `sha256:${digest}`,
|
|
405
|
+
bodyRetained: false,
|
|
406
|
+
attachmentsRetained: false,
|
|
407
|
+
localPartsRetained: false,
|
|
408
|
+
}),
|
|
409
|
+
identity: Object.freeze({
|
|
410
|
+
reportedAt: reportedDate(fieldsNamed(parsed.fields, 'date')),
|
|
411
|
+
fromDomains: Object.freeze(fromDomains),
|
|
412
|
+
replyToDomains: Object.freeze(replyToDomains),
|
|
413
|
+
returnPathDomains: Object.freeze(returnPathDomains),
|
|
414
|
+
messageIdentifierDomains: Object.freeze(messageIdentifierDomains),
|
|
415
|
+
}),
|
|
416
|
+
authentication: Object.freeze(authentication),
|
|
417
|
+
routing: Object.freeze({
|
|
418
|
+
receivedHops: Object.freeze(receivedHops),
|
|
419
|
+
omittedReceivedHops,
|
|
420
|
+
complete: omittedReceivedHops === 0,
|
|
421
|
+
}),
|
|
422
|
+
alignment: Object.freeze({
|
|
423
|
+
fromToReplyTo: exactAlignment(fromDomains, replyToDomains),
|
|
424
|
+
fromToReturnPath: exactAlignment(fromDomains, returnPathDomains),
|
|
425
|
+
fromToDkimSigner: exactAlignment(fromDomains, signerDomains),
|
|
426
|
+
}),
|
|
427
|
+
domains: Object.freeze(domains),
|
|
428
|
+
limitations: Object.freeze([
|
|
429
|
+
'This review parses analyst-selected message headers offline. It makes no DNS, SMTP, HTTP, registry or provider request.',
|
|
430
|
+
'Authentication results are claims reported in the supplied headers; they are not independently validated here.',
|
|
431
|
+
'Exact-domain alignment is descriptive. Divergence can be legitimate and does not establish spoofing, abuse or maliciousness.',
|
|
432
|
+
'Display names, subjects, address local parts, message bodies, attachments and raw header values are not retained in the output.',
|
|
433
|
+
...(omittedReceivedHops ? [`${omittedReceivedHops} older Received header${omittedReceivedHops === 1 ? ' was' : 's were'} omitted by the ${MAX_MAIL_RECEIVED_HOPS}-hop bound.`] : []),
|
|
434
|
+
]),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
export function formatCliMailHeaderReview(document) {
|
|
438
|
+
const lines = [
|
|
439
|
+
'Offline mail-header review',
|
|
440
|
+
`Header identity ${document.provenance.headerDigestSha256}`,
|
|
441
|
+
`Header fields ${document.provenance.headerFields}`,
|
|
442
|
+
`Reported date ${document.identity.reportedAt ?? 'Unavailable'}`,
|
|
443
|
+
`From domains ${document.identity.fromDomains.join(', ') || 'Unavailable'}`,
|
|
444
|
+
`Reply-To domains ${document.identity.replyToDomains.join(', ') || 'Unavailable'}`,
|
|
445
|
+
`Return-Path ${document.identity.returnPathDomains.join(', ') || 'Unavailable'}`,
|
|
446
|
+
'',
|
|
447
|
+
'Reported authentication',
|
|
448
|
+
...AUTHENTICATION_METHODS.map((method) => ` ${method.toUpperCase().padEnd(6)} ${document.authentication[method].state} (${document.authentication[method].observations} observation${document.authentication[method].observations === 1 ? '' : 's'})`),
|
|
449
|
+
'',
|
|
450
|
+
'Exact-domain alignment',
|
|
451
|
+
` From / Reply-To ${document.alignment.fromToReplyTo}`,
|
|
452
|
+
` From / Return-Path ${document.alignment.fromToReturnPath}`,
|
|
453
|
+
` From / DKIM signer ${document.alignment.fromToDkimSigner}`,
|
|
454
|
+
'',
|
|
455
|
+
`Received path ${document.routing.receivedHops.length} retained${document.routing.omittedReceivedHops ? ` · ${document.routing.omittedReceivedHops} omitted` : ''}`,
|
|
456
|
+
];
|
|
457
|
+
for (const hop of document.routing.receivedHops) {
|
|
458
|
+
lines.push(` ${hop.position}. ${hop.fromDomain ?? 'unknown'} → ${hop.byDomain ?? 'unknown'}${hop.transport ? ` · ${hop.transport}` : ''}${hop.reportedAt ? ` · ${hop.reportedAt}` : ''}`);
|
|
459
|
+
}
|
|
460
|
+
lines.push('', `Header domains ${document.domains.length}`);
|
|
461
|
+
for (const domain of document.domains)
|
|
462
|
+
lines.push(` ${domain.domain} ${domain.roles.join(', ')}`);
|
|
463
|
+
lines.push('', 'Limitations:');
|
|
464
|
+
for (const limitation of document.limitations)
|
|
465
|
+
lines.push(` - ${limitation}`);
|
|
466
|
+
return `${lines.join('\n')}\n`;
|
|
467
|
+
}
|
|
@@ -6,6 +6,7 @@ import EXIT_CODES from "./exit-codes.mjs";
|
|
|
6
6
|
import { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, } from "./offline-evidence-review.mjs";
|
|
7
7
|
import { buildCliLookupBrief, formatCliLookupBrief } from "./lookup-brief.mjs";
|
|
8
8
|
import { MAX_MAIL_REVIEW_INPUT_BYTES, buildCliMailReview, formatCliMailReview, } from "./mail-review.mjs";
|
|
9
|
+
import { MAX_MAIL_HEADER_INPUT_BYTES, buildCliMailHeaderReview, formatCliMailHeaderReview, } from "./mail-header-review.mjs";
|
|
9
10
|
import { buildCliPageComparison, formatCliPageComparison } from "./page-compare.mjs";
|
|
10
11
|
import { MAX_SOURCE_RELIABILITY_INPUT_BYTES, buildSourceReliabilityReport, formatSourceReliabilityReport, } from "./source-reliability.mjs";
|
|
11
12
|
import { buildInterchangeFidelityReport, formatInterchangeFidelityReport, } from "./interchange-report.mjs";
|
|
@@ -180,6 +181,29 @@ async function runMailReviewCommand(args, dependencies, context) {
|
|
|
180
181
|
}
|
|
181
182
|
return EXIT_CODES.SUCCESS;
|
|
182
183
|
}
|
|
184
|
+
async function runMailHeadersCommand(args, dependencies, context) {
|
|
185
|
+
context.setFailureLabel('Mail-header review');
|
|
186
|
+
let input;
|
|
187
|
+
try {
|
|
188
|
+
input = dependencies.readMailHeaderInput
|
|
189
|
+
? await dependencies.readMailHeaderInput(args.source)
|
|
190
|
+
: await context.readHeaderInput(args.source, MAX_MAIL_HEADER_INPUT_BYTES, 'Mail-header input');
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (error instanceof CliUsageError)
|
|
194
|
+
throw error;
|
|
195
|
+
throw new CliUsageError(`Could not read mail-header input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
196
|
+
}
|
|
197
|
+
if (!input.trim())
|
|
198
|
+
throw new CliUsageError('mail-headers requires one message or header file, or headers on stdin.');
|
|
199
|
+
const document = buildCliMailHeaderReview(input, context.now());
|
|
200
|
+
if (!args.quiet) {
|
|
201
|
+
context.writeStdout(args.output === 'json'
|
|
202
|
+
? formatJsonDocument(document)
|
|
203
|
+
: context.terminal(formatCliMailHeaderReview(document), args.color));
|
|
204
|
+
}
|
|
205
|
+
return EXIT_CODES.SUCCESS;
|
|
206
|
+
}
|
|
183
207
|
async function runOfflineEvidenceReviewCommand(args, dependencies, context) {
|
|
184
208
|
context.setFailureLabel('Offline evidence review');
|
|
185
209
|
let input;
|
|
@@ -259,6 +283,7 @@ const REVIEW_COMMAND_HANDLERS = Object.freeze({
|
|
|
259
283
|
'compare': runCompareCommand,
|
|
260
284
|
'page-compare': runPageCompareCommand,
|
|
261
285
|
'mail-review': runMailReviewCommand,
|
|
286
|
+
'mail-headers': runMailHeadersCommand,
|
|
262
287
|
'review-evidence': runOfflineEvidenceReviewCommand,
|
|
263
288
|
'brief': runBriefOrCasePackCommand,
|
|
264
289
|
'case-pack': runBriefOrCasePackCommand,
|
package/cli/risk-calibration.mjs
CHANGED
|
@@ -5,18 +5,14 @@ import { scanBoundedJson } from "../lib/bounded-json.mjs";
|
|
|
5
5
|
import { canonicalRegistrableDomain } from "../lib/registrable-domain.mjs";
|
|
6
6
|
import { ANALYST_REVIEW_REASON_VALUES, analystInteroperabilityTags } from "../lib/analyst-taxonomy.mjs";
|
|
7
7
|
import { buildRiskCalibrationSummaryReport, parseRiskCalibrationSummaryReport, RISK_CALIBRATION_SUMMARY_INTERPRETATION, RISK_CALIBRATION_SUMMARY_THRESHOLDS, } from "../lib/risk-calibration-summary.mjs";
|
|
8
|
-
import { MAX_RISK_CALIBRATION_ACTIVITY_LENGTH, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, MAX_RISK_CALIBRATION_DOMAIN_AGE_DAYS, MAX_RISK_CALIBRATION_DOMAIN_LENGTH, MAX_RISK_CALIBRATION_FINDING_CATEGORY_LENGTH, MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER, MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS, MAX_RISK_CALIBRATION_JSON_DEPTH, MAX_RISK_CALIBRATION_JSON_KEYS, MAX_RISK_CALIBRATION_JSON_VALUES, MAX_RISK_CALIBRATION_MUTATIONS, MAX_RISK_CALIBRATION_PROVIDER_ID_LENGTH, MAX_RISK_CALIBRATION_PROVIDER_STATE_LENGTH, MAX_RISK_CALIBRATION_PROVIDERS, MAX_RISK_CALIBRATION_RECORD_ID_LENGTH, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH, MAX_RISK_CALIBRATION_SCAN_DEPTH_LENGTH, MAX_RISK_CALIBRATION_STRING_LENGTH, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_DATASET_VERSION, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_VERSION, serializeRiskCalibrationSnapshot, snapshotRiskCalibrationReportForSerialization, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
|
|
8
|
+
import { MAX_RISK_CALIBRATION_ACTIVITY_LENGTH, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, MAX_RISK_CALIBRATION_DOMAIN_AGE_DAYS, MAX_RISK_CALIBRATION_DOMAIN_LENGTH, MAX_RISK_CALIBRATION_FINDING_CATEGORY_LENGTH, MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER, MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS, MAX_RISK_CALIBRATION_JSON_DEPTH, MAX_RISK_CALIBRATION_JSON_KEYS, MAX_RISK_CALIBRATION_JSON_VALUES, MAX_RISK_CALIBRATION_MUTATIONS, MAX_RISK_CALIBRATION_PROVIDER_ID_LENGTH, MAX_RISK_CALIBRATION_PROVIDER_STATE_LENGTH, MAX_RISK_CALIBRATION_PROVIDERS, MAX_RISK_CALIBRATION_RECORD_ID_LENGTH, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH, MAX_RISK_CALIBRATION_SCAN_DEPTH_LENGTH, MAX_RISK_CALIBRATION_STRING_LENGTH, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_DATASET_VERSION, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_VERSION, RISK_CALIBRATION_DISPOSITIONS, serializeRiskCalibrationSnapshot, snapshotRiskCalibrationReportForSerialization, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
|
|
9
9
|
import { CliUsageError } from "./arguments.mjs";
|
|
10
10
|
import { RISK_MUTATION_TYPES } from "../lib/risk-scoring.mjs";
|
|
11
11
|
import { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_ENVELOPE_VERSION, THREAT_INTELLIGENCE_SCHEMA, } from "../lib/threat-intelligence-types.mjs";
|
|
12
12
|
export { MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_STRING_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_DATASET_VERSION, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_VERSION, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
|
|
13
13
|
export const RISK_CALIBRATION_THRESHOLDS = RISK_CALIBRATION_SUMMARY_THRESHOLDS;
|
|
14
14
|
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
15
|
-
const DISPOSITIONS = new Set(
|
|
16
|
-
'unreviewed', 'suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action',
|
|
17
|
-
]);
|
|
18
|
-
const POSITIVE_DISPOSITIONS = new Set(['confirmed_abuse']);
|
|
19
|
-
const NEGATIVE_DISPOSITIONS = new Set(['false_positive', 'expected']);
|
|
15
|
+
const DISPOSITIONS = new Set(RISK_CALIBRATION_DISPOSITIONS);
|
|
20
16
|
const AVAILABILITY_STATES = new Set(['registered', 'for_sale', 'expiring', 'available', 'unknown', 'error']);
|
|
21
17
|
const ACTIVITY_STATES = new Set(['active', 'parked', 'unreachable', 'no_site']);
|
|
22
18
|
const BOOLEAN_FIELDS = [
|
|
@@ -26,6 +22,17 @@ const BOOLEAN_FIELDS = [
|
|
|
26
22
|
];
|
|
27
23
|
const REVIEW_REASON_CODES = ANALYST_REVIEW_REASON_VALUES;
|
|
28
24
|
const MUTATION_TYPES = new Set(RISK_MUTATION_TYPES);
|
|
25
|
+
const DISPOSITION_METRIC_CLASSES = Object.freeze({
|
|
26
|
+
unreviewed: 'excluded',
|
|
27
|
+
suspicious: 'excluded',
|
|
28
|
+
confirmed_abuse: 'positive',
|
|
29
|
+
false_positive: 'negative',
|
|
30
|
+
expected: 'negative',
|
|
31
|
+
closed_no_action: 'excluded',
|
|
32
|
+
});
|
|
33
|
+
function isRiskCalibrationDisposition(value) {
|
|
34
|
+
return DISPOSITIONS.has(value);
|
|
35
|
+
}
|
|
29
36
|
function object(value, field) {
|
|
30
37
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
31
38
|
throw new CliUsageError(`${field} must be an object.`);
|
|
@@ -214,7 +221,7 @@ export function parseRiskCalibrationDataset(text) {
|
|
|
214
221
|
if (!isValidAsciiDomainName(domain, { requireDot: true }))
|
|
215
222
|
throw new CliUsageError(`${prefix}.domain must be a valid ASCII DNS hostname, not an IP address.`);
|
|
216
223
|
const analystDisposition = boundedString(record.analystDisposition, `${prefix}.analystDisposition`, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH);
|
|
217
|
-
if (!
|
|
224
|
+
if (!isRiskCalibrationDisposition(analystDisposition))
|
|
218
225
|
throw new CliUsageError(`${prefix}.analystDisposition is unsupported.`);
|
|
219
226
|
let reviewReasonCode;
|
|
220
227
|
if (record.reviewReasonCode !== null && record.reviewReasonCode !== undefined) {
|
|
@@ -225,7 +232,7 @@ export function parseRiskCalibrationDataset(text) {
|
|
|
225
232
|
return {
|
|
226
233
|
id,
|
|
227
234
|
domain,
|
|
228
|
-
analystDisposition
|
|
235
|
+
analystDisposition,
|
|
229
236
|
...(reviewReasonCode ? { reviewReasonCode } : {}),
|
|
230
237
|
evidence: projectEvidence(record.evidence, `${prefix}.evidence`),
|
|
231
238
|
};
|
|
@@ -276,9 +283,7 @@ function metricsForThreshold(records, threshold) {
|
|
|
276
283
|
recall,
|
|
277
284
|
specificity,
|
|
278
285
|
falsePositiveRate: ratio(falsePositive, falsePositive + trueNegative),
|
|
279
|
-
f1:
|
|
280
|
-
? null
|
|
281
|
-
: Number(((2 * precision * recall) / (precision + recall)).toFixed(4)),
|
|
286
|
+
f1: ratio(2 * truePositive, 2 * truePositive + falsePositive + falseNegative),
|
|
282
287
|
balancedAccuracy: recall === null || specificity === null
|
|
283
288
|
? null
|
|
284
289
|
: Number(((recall + specificity) / 2).toFixed(4)),
|
|
@@ -290,11 +295,7 @@ function metricsForThreshold(records, threshold) {
|
|
|
290
295
|
};
|
|
291
296
|
}
|
|
292
297
|
function metricClass(disposition) {
|
|
293
|
-
|
|
294
|
-
return 'positive';
|
|
295
|
-
if (NEGATIVE_DISPOSITIONS.has(disposition))
|
|
296
|
-
return 'negative';
|
|
297
|
-
return 'excluded';
|
|
298
|
+
return DISPOSITION_METRIC_CLASSES[disposition];
|
|
298
299
|
}
|
|
299
300
|
function scoreBand(score) {
|
|
300
301
|
if (score === null)
|
|
@@ -395,7 +396,8 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
|
|
|
395
396
|
if (!source || !current)
|
|
396
397
|
continue;
|
|
397
398
|
const previous = options.explainPreviousRiskScore(scoringEvidence(source));
|
|
398
|
-
|
|
399
|
+
const previousScore = previous?.score ?? null;
|
|
400
|
+
if (previousScore !== current.score)
|
|
399
401
|
scoresChanged += 1;
|
|
400
402
|
if (scoreBand(previous?.score ?? null) !== current.band)
|
|
401
403
|
bandsChanged += 1;
|
package/cli/runner.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { HELP, commandDefinition, commandHelp, } from "./command-reference.mjs";
|
|
|
5
5
|
import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
6
6
|
import { evidenceCommandFailureLabel, isEvidenceCommand, runEvidenceCommand, } from "./evidence-command-runner.mjs";
|
|
7
7
|
import EXIT_CODES from "./exit-codes.mjs";
|
|
8
|
-
import { readCliTextInput } from "./input.mjs";
|
|
8
|
+
import { readCliHeaderInput, readCliTextInput } from "./input.mjs";
|
|
9
9
|
import { INLINE_CLI_COMMANDS, runInlineCommand } from "./inline-command-runner.mjs";
|
|
10
10
|
import { canLaunchInteractiveCli, launchInteractiveCli, } from "./interactive-launcher.mjs";
|
|
11
11
|
import { MAX_OFFLINE_PASSPHRASE_FILE_BYTES, } from "./artifact-verify.mjs";
|
|
@@ -98,6 +98,11 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
98
98
|
label,
|
|
99
99
|
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
100
100
|
}));
|
|
101
|
+
const readHeaderInput = async (source, maximumBytes, label) => (readCliHeaderInput(source, dependencies.stdin || process.stdin, {
|
|
102
|
+
maximumBytes,
|
|
103
|
+
label,
|
|
104
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
105
|
+
}));
|
|
101
106
|
const readPassphraseSource = async (source) => {
|
|
102
107
|
let passphraseText;
|
|
103
108
|
try {
|
|
@@ -126,6 +131,7 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
126
131
|
writeStderr: (value) => write(stderr, value),
|
|
127
132
|
readSingleInput,
|
|
128
133
|
readInput,
|
|
134
|
+
readHeaderInput,
|
|
129
135
|
readPassphraseSource,
|
|
130
136
|
now: () => dependencies.now ? dependencies.now() : new Date().toISOString(),
|
|
131
137
|
beginProgress,
|
package/docs/cli-reference.md
CHANGED
|
@@ -63,6 +63,13 @@ disclosures without collecting. Fast is the Lookup default. Deep adds the
|
|
|
63
63
|
applicable registration, DNS, HTTP, TLS, page, technology and network context.
|
|
64
64
|
Optional browser providers are not implicit CLI actions.
|
|
65
65
|
|
|
66
|
+
`mail-headers` is an offline review of one analyst-selected RFC-style header
|
|
67
|
+
block. It reports domain-only identity, header-reported authentication,
|
|
68
|
+
exact-domain alignment, and bounded `Received` routing. It retains no address
|
|
69
|
+
local parts, display names, subject, body, attachment, or raw header value in
|
|
70
|
+
its output. It does not independently validate SPF, DKIM, DMARC, or ARC and
|
|
71
|
+
does not treat divergence as proof of abuse.
|
|
72
|
+
|
|
66
73
|
`dnssec-validate` and `mail-transport` are isolated authorised actions. Both
|
|
67
74
|
require a selected literal public resolver, a local trust-anchor document and
|
|
68
75
|
`--owned-or-authorized`; mail transport also requires `--active-probe`. Mail
|
|
@@ -109,7 +116,7 @@ Readers accept only their declared public and current versions. Unknown,
|
|
|
109
116
|
unreleased historical and future schemas fail before partial interpretation;
|
|
110
117
|
an invalid import is not treated as an empty document.
|
|
111
118
|
|
|
112
|
-
`inspect-archive` reads current workspace archive
|
|
119
|
+
`inspect-archive` reads current workspace archive v8 and exact versions 5, 6 and 7. It
|
|
113
120
|
reports section metadata and digest-only search results unless `--reveal` is
|
|
114
121
|
explicitly selected. It never searches notes, contacts or arbitrary raw fields.
|
|
115
122
|
|