@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -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
+ }
@@ -3,9 +3,11 @@ import { getDomain } from 'tldts';
3
3
  import { scanBoundedJson } from "../lib/bounded-json.mjs";
4
4
  import { isValidAsciiDomainName } from "../lib/hostname.mjs";
5
5
  import { isRecord, recordOrEmpty } from "../lib/json-record.mjs";
6
+ import { classifyQuery } from "../lib/classify.mjs";
7
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
6
8
  import { CliUsageError } from "./errors.mjs";
7
9
  export const CLI_MAIL_REVIEW_SCHEMA = 'whoisleuth.cli.mail-review';
8
- export const CLI_MAIL_REVIEW_VERSION = 3;
10
+ export const CLI_MAIL_REVIEW_VERSION = 4;
9
11
  export const MAX_MAIL_REVIEW_INPUT_BYTES = 16 * 1024 * 1024;
10
12
  export const MAX_MAIL_REVIEW_ROWS = 500;
11
13
  const UNSAFE_TEXT_RE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f]/u;
@@ -20,10 +22,10 @@ function boolean(value) {
20
22
  return typeof value === 'boolean' ? value : null;
21
23
  }
22
24
  function timestamp(value, label) {
23
- if (typeof value !== 'string' || value.length > 64 || !Number.isFinite(Date.parse(value))) {
24
- throw new CliUsageError(`${label} must be a valid bounded timestamp.`);
25
- }
26
- return new Date(value).toISOString();
25
+ const result = normalizeExplicitIsoTimestamp(value);
26
+ if (!result)
27
+ throw new CliUsageError(`${label} must be a valid bounded timestamp with an explicit timezone.`);
28
+ return result;
27
29
  }
28
30
  function booleanOrNull(value, label) {
29
31
  if (value === null || typeof value === 'boolean')
@@ -87,8 +89,8 @@ function validateMxEvidence(availability, summary, label, hasMx, hasNullMx) {
87
89
  function validateMailRow(value, index, expectedVersion) {
88
90
  const item = record(value);
89
91
  const label = `Mail review row ${index + 1}`;
90
- const version = Number(item.version);
91
- if (item.schema !== 'whoisleuth.cli.bulk.item' || ![2, 3].includes(version)
92
+ const version = item.version;
93
+ if (item.schema !== 'whoisleuth.cli.bulk.item' || typeof version !== 'number' || ![2, 3].includes(version)
92
94
  || (expectedVersion !== null && version !== expectedVersion)) {
93
95
  throw new CliUsageError(`${label} must use the matching WHOISleuth Bulk item schema version 2 or 3.`);
94
96
  }
@@ -193,6 +195,7 @@ function normalizeMailRow(value) {
193
195
  const providers = providerDomains(hosts);
194
196
  return {
195
197
  domain,
198
+ provenance: mailRowProvenance(item),
196
199
  state,
197
200
  dnsStatus,
198
201
  hasMx,
@@ -210,6 +213,29 @@ function normalizeMailRow(value) {
210
213
  ],
211
214
  };
212
215
  }
216
+ function mailRowProvenance(item) {
217
+ return Object.freeze({
218
+ sourceGeneratedAt: timestamp(item.generatedAt, 'Bulk row generation time'),
219
+ observedAt: item.version === 3 ? normalizeExplicitIsoTimestamp(item.observedAt) : null,
220
+ dnsObservedAt: item.ok === true ? normalizeExplicitIsoTimestamp(record(record(item.availability).dns).observedAt) : null,
221
+ collectionOrigin: item.version === 3
222
+ ? item.collectionOrigin
223
+ : 'unknown',
224
+ });
225
+ }
226
+ function failedMailRow(item, index) {
227
+ let target = null;
228
+ try {
229
+ target = classifyQuery(String(item.query)).value;
230
+ }
231
+ catch { /* Invalid targets remain identifiable by input position. */ }
232
+ return Object.freeze({
233
+ rowNumber: index + 1,
234
+ target,
235
+ state: 'collection_failed',
236
+ provenance: mailRowProvenance(item),
237
+ });
238
+ }
213
239
  function parseInput(textValue) {
214
240
  if (typeof textValue !== 'string' || Buffer.byteLength(textValue, 'utf8') > MAX_MAIL_REVIEW_INPUT_BYTES) {
215
241
  throw new CliUsageError(`Mail review input is limited to ${MAX_MAIL_REVIEW_INPUT_BYTES} bytes.`);
@@ -223,12 +249,12 @@ function parseInput(textValue) {
223
249
  scanBoundedJson(textInput);
224
250
  const parsed = JSON.parse(textInput);
225
251
  const document = record(parsed);
226
- if (document.schema === 'whoisleuth.cli.bulk' && [2, 3].includes(Number(document.version)) && Array.isArray(document.results)) {
252
+ if (document.schema === 'whoisleuth.cli.bulk' && typeof document.version === 'number' && [2, 3].includes(document.version) && Array.isArray(document.results)) {
227
253
  values = document.results;
228
254
  containerVersion = Number(document.version);
229
255
  timestamp(document.generatedAt, 'Mail review Bulk generation time');
230
256
  }
231
- else if (document.schema === 'whoisleuth.cli.bulk.item' && [2, 3].includes(Number(document.version))) {
257
+ else if (document.schema === 'whoisleuth.cli.bulk.item' && typeof document.version === 'number' && [2, 3].includes(document.version)) {
232
258
  values = [document];
233
259
  }
234
260
  else {
@@ -258,6 +284,13 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
258
284
  const rows = inputRows.map(normalizeMailRow).filter((row) => Boolean(row));
259
285
  if (!rows.length)
260
286
  throw new CliUsageError('Mail review input contains no completed domain results.');
287
+ const failedRows = inputRows.flatMap((row, index) => row.ok === false ? [failedMailRow(row, index)] : []);
288
+ const inputCoverage = Object.freeze({
289
+ inputRows: inputRows.length,
290
+ reviewedRows: rows.length,
291
+ failedRows: failedRows.length,
292
+ unmeasuredObservationRows: [...rows, ...failedRows].filter((row) => row.provenance.observedAt === null).length,
293
+ });
261
294
  const counts = {
262
295
  authenticated_mail: 0,
263
296
  evidence_incomplete: 0,
@@ -295,10 +328,14 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
295
328
  })
296
329
  .sort((left, right) => left.providerDomain.localeCompare(right.providerDomain));
297
330
  const providerRelationships = allProviderRelationships.slice(0, 100);
331
+ const rowsWithIncompleteDns = rows.filter((row) => row.dnsStatus !== 'success' || row.hasMx === null || row.hasNullMx === null).length;
298
332
  const providerCoverage = {
299
- complete: rows.every((row) => !row.providerDomainsTruncated)
333
+ complete: failedRows.length === 0 && rowsWithIncompleteDns === 0
334
+ && rows.every((row) => !row.providerDomainsTruncated)
300
335
  && allProviderRelationships.length <= 100
301
336
  && allProviderRelationships.every((relationship) => !relationship.domainsTruncated),
337
+ failedRows: failedRows.length,
338
+ rowsWithIncompleteDns,
302
339
  rowsWithOmittedProviders: rows.filter((row) => row.providerDomainsTruncated).length,
303
340
  omittedProviderDomains: rows.reduce((sum, row) => sum + row.providerDomainsOmitted, 0),
304
341
  relationshipCount: allProviderRelationships.length,
@@ -309,16 +346,20 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
309
346
  return {
310
347
  schema: CLI_MAIL_REVIEW_SCHEMA,
311
348
  version: CLI_MAIL_REVIEW_VERSION,
312
- generatedAt,
349
+ generatedAt: timestamp(generatedAt, 'Mail review generation time'),
313
350
  counts,
314
351
  rows,
352
+ failedRows,
353
+ inputCoverage,
315
354
  providerRelationships,
316
355
  providerCoverage,
317
356
  limitations: [
318
357
  'This review is passive and uses DNS evidence already retained in a WHOISleuth Bulk result; it makes no network request.',
319
358
  'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
320
359
  'SMTP delivery, mailbox existence, catch-all behaviour, banner collection, and message acceptance were not tested.',
321
- ...(!providerCoverage.complete
360
+ ...(failedRows.length ? [`${failedRows.length} input target${failedRows.length === 1 ? '' : 's'} failed collection and cannot contribute mail evidence. Input positions and normalised targets are retained; consult the original input for failure diagnostics.`] : []),
361
+ ...(rowsWithIncompleteDns ? [`${rowsWithIncompleteDns} reviewed domain${rowsWithIncompleteDns === 1 ? ' has' : 's have'} incomplete DNS evidence; unobserved provider relationships remain unknown.`] : []),
362
+ ...(providerCoverage.rowsWithOmittedProviders || providerCoverage.omittedRelationships || providerCoverage.omittedRelationshipDomains
322
363
  ? ['Mail-provider relationship coverage reached a configured row, relationship, or domain bound; omitted relationships remain unknown.']
323
364
  : []),
324
365
  ],
@@ -327,19 +368,30 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
327
368
  function formatCliMailReview(document) {
328
369
  const lines = [
329
370
  'Passive mail exposure review',
371
+ `Input targets ${document.inputCoverage.inputRows}`,
330
372
  `Domains ${document.rows.length}`,
373
+ `Failed targets ${document.inputCoverage.failedRows}`,
331
374
  `Authenticated ${document.counts.authenticated_mail}`,
332
375
  `Auth gaps ${document.counts.mail_auth_gap}`,
333
376
  `Null MX ${document.counts.null_mx}`,
334
377
  `No explicit MX ${document.counts.no_explicit_mx}`,
335
- `Incomplete ${document.counts.evidence_incomplete + document.counts.mail_auth_incomplete}`,
378
+ `Incomplete ${document.counts.evidence_incomplete + document.counts.mail_auth_incomplete + document.inputCoverage.failedRows}`,
336
379
  '',
337
380
  ];
338
381
  for (const row of document.rows) {
339
382
  lines.push(`${row.domain} ${row.state.replaceAll('_', ' ')}`);
383
+ lines.push(` Observed ${row.provenance.observedAt ?? 'unknown'} · ${row.provenance.collectionOrigin.replaceAll('_', ' ')}`);
384
+ lines.push(` DNS observed ${row.provenance.dnsObservedAt ?? 'unknown'} · Source output ${row.provenance.sourceGeneratedAt}`);
340
385
  lines.push(` MX providers ${row.providerDomains.join(', ') || 'None observed'}${row.providerDomainsOmitted ? ` · +${row.providerDomainsOmitted} omitted` : ''}`);
341
386
  lines.push(` SPF / DMARC ${row.hasSpf === null ? 'unknown' : row.hasSpf ? 'observed' : 'not observed'} / ${row.hasDmarc === null ? 'unknown' : row.hasDmarc ? 'observed' : 'not observed'}`);
342
387
  }
388
+ if (document.failedRows.length) {
389
+ lines.push('', 'Failed collection inputs');
390
+ for (const row of document.failedRows) {
391
+ lines.push(`Input ${row.rowNumber} ${row.target ?? 'unrecognised target'} · collection failed`);
392
+ lines.push(` Observed ${row.provenance.observedAt ?? 'unknown'} · ${row.provenance.collectionOrigin.replaceAll('_', ' ')} · Source output ${row.provenance.sourceGeneratedAt}`);
393
+ }
394
+ }
343
395
  if (document.providerRelationships.length) {
344
396
  lines.push('', 'Shared mail-provider relationships');
345
397
  for (const relationship of document.providerRelationships) {
@@ -347,7 +399,7 @@ function formatCliMailReview(document) {
347
399
  }
348
400
  }
349
401
  if (!document.providerCoverage.complete) {
350
- lines.push('', `Provider coverage partial ${document.providerCoverage.omittedProviderDomains} row providers · ${document.providerCoverage.omittedRelationships} relationships · ${document.providerCoverage.omittedRelationshipDomains} relationship domains omitted`);
402
+ lines.push('', `Provider coverage partial ${document.providerCoverage.failedRows} failed targets · ${document.providerCoverage.rowsWithIncompleteDns} incomplete DNS rows · ${document.providerCoverage.omittedProviderDomains} row providers · ${document.providerCoverage.omittedRelationships} relationships · ${document.providerCoverage.omittedRelationshipDomains} relationship domains omitted`);
351
403
  }
352
404
  lines.push('', 'Limitations:');
353
405
  for (const limitation of document.limitations)
package/cli/manual.mjs CHANGED
@@ -24,6 +24,7 @@ ${commands}
24
24
  Human-readable output is the default. Versioned JSON and JSONL are available where documented. Diagnostics and optional progress events use standard error. Use --output and optional --force for atomic private file output. Use --palette auto, light, or dark for a fixed terminal palette. Lookup --browse provides an interactive terminal view.
25
25
  .SH EXIT STATUS
26
26
  0 indicates command completion, 2 invalid usage, 3 a collection or comparison failure, 4 an explicitly detected partial result, 70 an internal bootstrap failure, 130 analyst cancellation, and 143 service termination.
27
+ For verify-artifact, use --strict-exit in scripts: incomplete verification then returns 4. Without it, producing a verification report can return 0 even when its checks fail. Read the report state and checks.
27
28
  .SH PRIVACY
28
29
  Network commands disclose the target to the sources named in focused help. Offline commands read local input only. Output files use private permissions and remain on the operator's machine.
29
30
  .SH LICENSE
@@ -44,6 +44,8 @@ async function runNetworkCommand(args, dependencies, context) {
44
44
  dkimSelectors,
45
45
  retiredDkimSelectors,
46
46
  mailProtectionProfile: args.mailProfile,
47
+ ...(args.includeInheritedDns ? { includeInheritedDns: true } : {}),
48
+ ...(dependencies.signal ? { signal: dependencies.signal } : {}),
47
49
  }));
48
50
  const document = buildCliPostureDocument(requestedDomain, report, context.now());
49
51
  if (!args.quiet) {