@slicedearth/whoisleuth-cli 2.1.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.
Files changed (77) hide show
  1. package/README.md +2 -1
  2. package/cli/arguments.mjs +389 -1745
  3. package/cli/artifact-validation/case-response.mjs +55 -30
  4. package/cli/artifact-validation/signed-review.mjs +30 -12
  5. package/cli/bulk-checkpoint.mjs +34 -3
  6. package/cli/case-pack.mjs +39 -92
  7. package/cli/command-argument-grammar.mjs +211 -0
  8. package/cli/command-reference.mjs +200 -213
  9. package/cli/completion.mjs +5 -1
  10. package/cli/discovery-observation-snapshot.mjs +16 -3
  11. package/cli/discovery-scan-command-runner.mjs +19 -0
  12. package/cli/discovery-scan.mjs +6 -0
  13. package/cli/domain-control-observations.mjs +1 -1
  14. package/cli/inline-command-families.mjs +1 -0
  15. package/cli/input.mjs +116 -0
  16. package/cli/interchange-report.mjs +14 -0
  17. package/cli/lookalike-calibration.mjs +7 -2
  18. package/cli/lookup-brief.mjs +4 -2
  19. package/cli/lookup-browser-view.mjs +1 -1
  20. package/cli/mail-header-review.mjs +467 -0
  21. package/cli/review-command-runner.mjs +25 -0
  22. package/cli/risk-calibration.mjs +19 -17
  23. package/cli/runner.mjs +7 -1
  24. package/docs/cli-reference.md +8 -1
  25. package/docs/cli.md +24 -1
  26. package/lib/bounded-json.mjs +9 -6
  27. package/lib/dns-convergence-review.mjs +1 -0
  28. package/lib/dns-intelligence.mjs +37 -3
  29. package/lib/dnssec-chain-validation.mjs +6 -0
  30. package/lib/domain-change-packet.mjs +42 -14
  31. package/lib/domain-control-flight-recorder.mjs +4 -1
  32. package/lib/domain-control-manifest.mjs +16 -16
  33. package/lib/domain-posture-parsers.mjs +114 -3
  34. package/lib/domain-posture.mjs +34 -3
  35. package/lib/evidence-export.mjs +63 -13
  36. package/lib/evidence-report.mjs +2 -2
  37. package/lib/html-signals.mjs +39 -33
  38. package/lib/local-geoip-evidence.mjs +19 -3
  39. package/lib/lookup-response.mjs +5 -4
  40. package/lib/lookup-source-progress.mjs +27 -4
  41. package/lib/nameserver-preflight-review.mjs +1 -0
  42. package/lib/rdap-capabilities.mjs +1 -1
  43. package/lib/registry-insights.mjs +35 -22
  44. package/lib/risk-calibration-summary.mjs +1 -3
  45. package/lib/security-txt.mjs +33 -1
  46. package/lib/static-html-analysis.mjs +69 -13
  47. package/lib/website-technology.mjs +79 -11
  48. package/lib/zone-intent-review.mjs +16 -6
  49. package/package.json +1 -1
  50. package/packages/cases/case-migration-model.mjs +71 -22
  51. package/packages/cases/case-record-contracts.mjs +5 -20
  52. package/packages/cases/case-record-core.mjs +10 -21
  53. package/packages/cases/case-record-decisions.mjs +72 -0
  54. package/packages/cases/case-record-model.mjs +1 -0
  55. package/packages/cases/case-record-operations.mjs +240 -3
  56. package/packages/cases/case-record-projection.mjs +218 -0
  57. package/packages/cases/case-report.mjs +11 -6
  58. package/packages/cases/case-response-model.mjs +43 -6
  59. package/packages/cases/case-response-packet.mjs +78 -27
  60. package/packages/cases/case-storage-model.mjs +6 -2
  61. package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
  62. package/packages/comparison/page-similarity.mjs +3 -1
  63. package/packages/contracts/case-portability.mjs +183 -53
  64. package/packages/contracts/cli-command-semantics.mjs +93 -0
  65. package/packages/contracts/risk-calibration.mjs +15 -10
  66. package/packages/contracts/workspace-portability.mjs +44 -7
  67. package/packages/evidence/domain-control-runtime.mjs +46 -6
  68. package/packages/interchange/external-findings-import.mjs +115 -46
  69. package/packages/investigation/lookup-asset-graph.mjs +35 -27
  70. package/packages/workspace/brand-profile-model.mjs +118 -2
  71. package/packages/workspace/bulk-review-model.mjs +13 -7
  72. package/packages/workspace/bulk-session-model.mjs +5 -4
  73. package/packages/workspace/detection-rule-model.mjs +70 -22
  74. package/packages/workspace/relationship-observation-model.mjs +4 -1
  75. package/packages/workspace/website-snapshot-model.mjs +106 -8
  76. package/packages/workspace/workspace-archive.mjs +2 -0
  77. 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,
@@ -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 (!DISPOSITIONS.has(analystDisposition))
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: 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: precision === null || recall === null || precision + recall === 0
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
- if (POSITIVE_DISPOSITIONS.has(disposition))
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
- if (previous?.score !== current.score)
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,
@@ -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 v7 and exact versions 5 and 6. It
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