@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1

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 (300) hide show
  1. package/NOTICE +2 -2
  2. package/cli/argument-values.mjs +55 -0
  3. package/cli/arguments.mjs +24 -470
  4. package/cli/artifact-validation/case-response.mjs +21 -6
  5. package/cli/artifact-validation/lookup-evidence.mjs +3 -3
  6. package/cli/artifact-validation/signed-review.mjs +59 -13
  7. package/cli/artifact-verify.mjs +2 -0
  8. package/cli/assurance-arguments.mjs +56 -0
  9. package/cli/assurance-command-definitions.mjs +179 -0
  10. package/cli/assurance-command-runner.mjs +1 -1
  11. package/cli/bounded-json.mjs +1 -1
  12. package/cli/bulk-checkpoint.mjs +1 -1
  13. package/cli/bulk-command-runner.mjs +2 -2
  14. package/cli/bulk.mjs +2 -3
  15. package/cli/case-command.mjs +20 -3
  16. package/cli/case-pack.mjs +12 -2
  17. package/cli/ci-report.mjs +6 -3
  18. package/cli/collection-arguments.mjs +123 -0
  19. package/cli/collection-command-definitions.mjs +330 -0
  20. package/cli/collection-preflight.mjs +8 -1
  21. package/cli/command-argument-grammar.mjs +8 -210
  22. package/cli/command-definition.mjs +329 -0
  23. package/cli/command-reference.mjs +55 -1388
  24. package/cli/command-seeds.mjs +18 -0
  25. package/cli/compare.mjs +1 -1
  26. package/cli/config-profile.mjs +1 -1
  27. package/cli/ct-event-intake.mjs +4 -4
  28. package/cli/discover.mjs +2 -2
  29. package/cli/discovery-observation-snapshot.mjs +2 -2
  30. package/cli/discovery-scan-command-runner.mjs +3 -3
  31. package/cli/discovery-scan.mjs +1 -1
  32. package/cli/discovery-snapshot.mjs +2 -2
  33. package/cli/discovery-workflow.mjs +4 -4
  34. package/cli/domain-control-monitor.mjs +6 -6
  35. package/cli/domain-control-observations.mjs +2 -1
  36. package/cli/errors.mjs +5 -6
  37. package/cli/evidence-arguments.mjs +46 -0
  38. package/cli/evidence-command-definitions.mjs +69 -0
  39. package/cli/evidence-command-runner.mjs +2 -3
  40. package/cli/evidence-signing.mjs +3 -2
  41. package/cli/external-observation-mapping.mjs +2 -2
  42. package/cli/formatters/terminal-command-formats.mjs +6 -0
  43. package/cli/formatters/terminal-lookup.mjs +6 -0
  44. package/cli/history-arguments.mjs +39 -0
  45. package/cli/history-command-definitions.mjs +88 -0
  46. package/cli/indicator-set-command.mjs +33 -0
  47. package/cli/inline-command-families.mjs +6 -47
  48. package/cli/inline-command-runner.mjs +4 -31
  49. package/cli/input.mjs +2 -2
  50. package/cli/intake-command.mjs +107 -0
  51. package/cli/interchange-report.mjs +4 -2
  52. package/cli/investigation-artifacts.mjs +1 -1
  53. package/cli/investigation-plan.mjs +32 -154
  54. package/cli/investigation-recipes.mjs +177 -0
  55. package/cli/investigation-run.mjs +78 -12
  56. package/cli/local-mmdb-review.mjs +197 -17
  57. package/cli/lookalike-calibration.mjs +1 -1
  58. package/cli/lookup-brief.mjs +2 -2
  59. package/cli/lookup-command-runner.mjs +18 -35
  60. package/cli/lookup-diff.mjs +10 -9
  61. package/cli/lookup-plan.mjs +10 -14
  62. package/cli/lookup-reconcile.mjs +6 -5
  63. package/cli/lookup-timeline.mjs +7 -6
  64. package/cli/mail-header-review.mjs +24 -247
  65. package/cli/mail-review.mjs +9 -7
  66. package/cli/network-arguments.mjs +51 -0
  67. package/cli/network-command-definitions.mjs +130 -0
  68. package/cli/network-command-runner.mjs +43 -31
  69. package/cli/offline-evidence-review.mjs +39 -6
  70. package/cli/posture.mjs +1 -1
  71. package/cli/registry-fixture-scaffold.mjs +1 -1
  72. package/cli/retained-artifact-diff.mjs +8 -7
  73. package/cli/review-arguments.mjs +131 -0
  74. package/cli/review-command-definitions.mjs +273 -0
  75. package/cli/review-command-runner.mjs +68 -158
  76. package/cli/risk-calibration.mjs +8 -8
  77. package/cli/runner.mjs +20 -32
  78. package/cli/saved-lookup.mjs +2 -2
  79. package/cli/selected-input-worker.mjs +63 -0
  80. package/cli/sharing-review.mjs +5 -64
  81. package/cli/source-reliability.mjs +2 -2
  82. package/cli/support-arguments.mjs +49 -0
  83. package/cli/support-command-definitions.mjs +209 -0
  84. package/cli/support-command-runner.mjs +2 -2
  85. package/cli/workflow-arguments.mjs +86 -0
  86. package/cli/workflow-command-definitions.mjs +78 -0
  87. package/cli/workflow-command-runner.mjs +8 -1
  88. package/docs/cli-reference.md +27 -0
  89. package/docs/cli.md +223 -2
  90. package/docs/contextual-reviews.md +155 -0
  91. package/lib/analyst-taxonomy.mjs +2 -40
  92. package/lib/application-version.mjs +1 -1
  93. package/lib/availability.mjs +13 -789
  94. package/lib/bounded-contract-normalizers.mjs +4 -0
  95. package/lib/bounded-json.mjs +2 -330
  96. package/lib/browser-library-profile.mjs +62 -84
  97. package/lib/candidate-provenance-bounds.mjs +2 -0
  98. package/lib/classify.mjs +1 -1
  99. package/lib/ct-query.mjs +2 -27
  100. package/lib/ct-response-bounds.mjs +2 -9
  101. package/lib/ct-search.mjs +83 -47
  102. package/lib/dmarc-discovery.mjs +62 -0
  103. package/lib/dns-convergence-review.mjs +21 -8
  104. package/lib/dns-delegation-health.mjs +12 -8
  105. package/lib/dns-inheritance-review.mjs +53 -63
  106. package/lib/dns-intelligence.mjs +9 -7
  107. package/lib/dns-query-coverage.mjs +40 -0
  108. package/lib/domain-assurance.mjs +1 -1
  109. package/lib/domain-change-packet.mjs +12 -13
  110. package/lib/domain-change-review.mjs +32 -14
  111. package/lib/domain-control-flight-recorder.mjs +103 -27
  112. package/lib/domain-control-manifest.mjs +25 -17
  113. package/lib/domain-posture-analysis.mjs +22 -5
  114. package/lib/domain-posture.mjs +16 -15
  115. package/lib/domain-registration.mjs +392 -0
  116. package/lib/evidence-export-privacy.mjs +22 -15
  117. package/lib/evidence-export.mjs +3 -3
  118. package/lib/evidence-report.mjs +4 -1
  119. package/lib/external-intelligence-risk.mjs +2 -124
  120. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  121. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  122. package/lib/generated/retire-browser-catalog.mjs +332 -19
  123. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  124. package/lib/html-signals.mjs +7 -2
  125. package/lib/http-evidence-bounds.mjs +2 -8
  126. package/lib/http-intelligence.mjs +13 -1
  127. package/lib/idn-confusable-policy.mjs +6 -4
  128. package/lib/idn-confusables.mjs +1 -1
  129. package/lib/interchange-fidelity-registry.mjs +10 -0
  130. package/lib/lookup-contract-primitives.mjs +1 -1
  131. package/lib/lookup-diagnostics.mjs +1 -23
  132. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  133. package/lib/lookup-progress.mjs +50 -1
  134. package/lib/lookup-response-contract.mjs +69 -40
  135. package/lib/lookup-response.mjs +5 -15
  136. package/lib/lookup-source-progress.mjs +8 -22
  137. package/lib/lookup.mjs +1 -1
  138. package/lib/opportunity-scoring.mjs +2 -107
  139. package/lib/outbound-identity.mjs +1 -1
  140. package/lib/page-fingerprints.mjs +18 -1
  141. package/lib/perceptual-hash-comparison.mjs +2 -34
  142. package/lib/perceptual-hash.mjs +36 -10
  143. package/lib/portable-generator.mjs +2 -29
  144. package/lib/project-metadata.mjs +2 -8
  145. package/lib/rdap-client.mjs +11 -7
  146. package/lib/registrable-domain.mjs +2 -56
  147. package/lib/registrar-standing-contract.mjs +1 -1
  148. package/lib/registrar-standing.mjs +9 -13
  149. package/lib/registry-capabilities.mjs +2 -2
  150. package/lib/registry-capability-catalogue.mjs +27 -19
  151. package/lib/risk-calibration-summary.mjs +2 -352
  152. package/lib/risk-scoring.mjs +2 -345
  153. package/lib/scoring-evidence-quality.mjs +2 -107
  154. package/lib/semantic-version.mjs +2 -54
  155. package/lib/service-binding-dns.mjs +27 -0
  156. package/lib/smtp-transport-review.mjs +48 -19
  157. package/lib/sslbl-certificates.generated.mjs +141 -139
  158. package/lib/static-html-analysis.mjs +1 -1
  159. package/lib/structured-data-identity.mjs +1 -1
  160. package/lib/threat-intelligence-manifests.mjs +1 -1
  161. package/lib/threat-intelligence-runtime.mjs +1 -1
  162. package/lib/threat-intelligence-targets.mjs +1 -1
  163. package/lib/threat-intelligence-types.mjs +2 -75
  164. package/lib/typosquat-generator.mjs +11 -2
  165. package/lib/website-enrichment.mjs +388 -0
  166. package/lib/whois-authority.mjs +43 -5
  167. package/lib/whois-normalization.mjs +3 -1
  168. package/lib/whois-parser.mjs +5 -3
  169. package/package.json +13 -14
  170. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  171. package/packages/analysis/bounded-json.mjs +330 -0
  172. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  173. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  174. package/packages/analysis/ct-query.mjs +27 -0
  175. package/packages/analysis/ct-response-bounds.mjs +9 -0
  176. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  177. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  178. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  179. package/packages/analysis/opportunity-scoring.mjs +107 -0
  180. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  181. package/packages/analysis/portable-generator.mjs +29 -0
  182. package/packages/analysis/project-metadata.mjs +8 -0
  183. package/packages/analysis/registrable-domain.mjs +56 -0
  184. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  185. package/packages/analysis/risk-scoring.mjs +345 -0
  186. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  187. package/packages/analysis/semantic-version.mjs +54 -0
  188. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  189. package/packages/cases/case-evidence-links.mjs +135 -0
  190. package/packages/cases/case-evidence-model.mjs +78 -12
  191. package/packages/cases/case-evidence-quality.mjs +5 -0
  192. package/packages/cases/case-export-input.mjs +7 -5
  193. package/packages/cases/case-incident-context.mjs +52 -0
  194. package/packages/cases/case-markdown.mjs +11 -0
  195. package/packages/cases/case-migration-model.mjs +40 -24
  196. package/packages/cases/case-recheck-model.mjs +90 -32
  197. package/packages/cases/case-record-contracts.mjs +1 -1
  198. package/packages/cases/case-record-core.mjs +1 -1
  199. package/packages/cases/case-record-operations.mjs +40 -112
  200. package/packages/cases/case-record-projection.mjs +8 -1
  201. package/packages/cases/case-record-version-input.mjs +19 -0
  202. package/packages/cases/case-report.mjs +63 -27
  203. package/packages/cases/case-requested-evidence.mjs +146 -0
  204. package/packages/cases/case-response-actions.mjs +103 -1
  205. package/packages/cases/case-response-model.mjs +9 -1
  206. package/packages/cases/case-response-outcomes.mjs +29 -8
  207. package/packages/cases/case-response-packet-render.mjs +13 -9
  208. package/packages/cases/case-response-packet.mjs +25 -11
  209. package/packages/cases/case-response-records.mjs +0 -1
  210. package/packages/cases/case-response-review-inputs.mjs +14 -3
  211. package/packages/cases/case-storage-model.mjs +1 -1
  212. package/packages/cases/case-workflow-metadata.mjs +225 -0
  213. package/packages/cases/case-workflow-migration.mjs +43 -0
  214. package/packages/cases/http-summary.mjs +6 -1
  215. package/packages/comparison/favicon-similarity.mjs +1 -1
  216. package/packages/comparison/page-similarity.mjs +1 -1
  217. package/packages/comparison/relationship-evidence.mjs +21 -9
  218. package/packages/comparison/relationship-provenance.mjs +7 -1
  219. package/packages/contracts/analyst-interchange.mjs +28 -10
  220. package/packages/contracts/case-portability.mjs +118 -25
  221. package/packages/contracts/cli-command-semantics.mjs +2 -0
  222. package/packages/contracts/cli-grammar.mjs +6 -0
  223. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  224. package/packages/contracts/context-review.mjs +24 -0
  225. package/packages/contracts/document-intake.mjs +11 -0
  226. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  227. package/packages/contracts/domain-control-manifest.mjs +1 -1
  228. package/packages/contracts/domain-control-monitor.mjs +13 -2
  229. package/packages/contracts/domain-control-review.mjs +36 -15
  230. package/packages/contracts/har-review.mjs +2 -0
  231. package/packages/contracts/identity-events.mjs +4 -0
  232. package/packages/contracts/investigation-run.mjs +2 -0
  233. package/packages/contracts/ip-address.mjs +10 -0
  234. package/packages/contracts/mail-authentication.mjs +5 -0
  235. package/packages/contracts/message-intake.mjs +31 -0
  236. package/packages/contracts/offline-comparison.mjs +4 -1
  237. package/packages/contracts/passive-mail.mjs +10 -0
  238. package/packages/contracts/web-capture.mjs +2 -2
  239. package/packages/contracts/workspace-portability.mjs +63 -17
  240. package/packages/evidence/collection-quality.mjs +67 -0
  241. package/packages/evidence/dns-query-outcome.mjs +72 -0
  242. package/packages/evidence/email-recipient.mjs +18 -0
  243. package/packages/evidence/lookup-target.mjs +5 -1
  244. package/packages/evidence/observation.mjs +2 -0
  245. package/packages/evidence/png-pixels.mjs +117 -0
  246. package/packages/evidence/public-address-policy.mjs +83 -0
  247. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  248. package/packages/interchange/external-findings-import.mjs +4 -12
  249. package/packages/interchange/managed-indicator-export.mjs +35 -0
  250. package/packages/interchange/managed-indicator-set.mjs +210 -0
  251. package/packages/interchange/misp-indicator-export.mjs +93 -0
  252. package/packages/interchange/sharing-policy.mjs +56 -0
  253. package/packages/interchange/stix-indicator-export.mjs +129 -0
  254. package/packages/interchange/web-capture-import.mjs +22 -15
  255. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  256. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  257. package/packages/investigation/capture-coverage.mjs +57 -0
  258. package/packages/investigation/connector-provenance-review.mjs +100 -0
  259. package/packages/investigation/context-review.mjs +35 -0
  260. package/packages/investigation/document-intake.mjs +81 -0
  261. package/packages/investigation/docx-intake.mjs +133 -0
  262. package/packages/investigation/domain-history-review.mjs +100 -0
  263. package/packages/investigation/domain-transition-review.mjs +47 -0
  264. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  265. package/packages/investigation/har-intake.mjs +68 -0
  266. package/packages/investigation/identity-event-intake.mjs +131 -0
  267. package/packages/investigation/identity-incident-review.mjs +39 -0
  268. package/packages/investigation/incident-sequence-review.mjs +58 -0
  269. package/packages/investigation/intake-report.mjs +19 -0
  270. package/packages/investigation/investigation-manifest.mjs +2 -2
  271. package/packages/investigation/link-intake.mjs +179 -0
  272. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  273. package/packages/investigation/mail-authentication-review.mjs +222 -0
  274. package/packages/investigation/mail-header-identity.mjs +209 -0
  275. package/packages/investigation/message-intake.mjs +195 -0
  276. package/packages/investigation/page-behaviour.mjs +113 -0
  277. package/packages/investigation/pdf-intake.mjs +155 -0
  278. package/packages/investigation/platform-continuity-review.mjs +60 -0
  279. package/packages/investigation/qr-intake.mjs +56 -0
  280. package/packages/investigation/requested-action-hints.mjs +11 -0
  281. package/packages/investigation/selected-input-review.mjs +17 -0
  282. package/packages/investigation/storefront-review.mjs +70 -0
  283. package/packages/workspace/brand-profile-model.mjs +2 -2
  284. package/packages/workspace/bulk-review-model.mjs +28 -26
  285. package/packages/workspace/bulk-session-model.mjs +23 -13
  286. package/packages/workspace/campaign-model.mjs +1 -1
  287. package/packages/workspace/detection-rule-model.mjs +1 -1
  288. package/packages/workspace/hostile-input.mjs +1 -1
  289. package/packages/workspace/investigation-guide.mjs +2 -2
  290. package/packages/workspace/investigation-template-model.mjs +26 -2
  291. package/packages/workspace/page-baseline.mjs +2 -2
  292. package/packages/workspace/relationship-observation-model.mjs +17 -13
  293. package/packages/workspace/shortlist-model.mjs +14 -5
  294. package/packages/workspace/watchlist-history.mjs +28 -12
  295. package/packages/workspace/watchlist-store.mjs +17 -5
  296. package/packages/workspace/website-snapshot-model.mjs +28 -3
  297. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  298. package/packages/workspace/workspace-archive.mjs +5 -4
  299. package/third-party-notices.txt +549 -16
  300. package/lib/hostname.mjs +0 -2
@@ -38,7 +38,7 @@ async function runLookupCommand(args, dependencies, context) {
38
38
  eventProgress.emit({ event: 'started' });
39
39
  const input = args.query || await context.readSingleInput();
40
40
  if (!input)
41
- throw new CliUsageError('lookup requires one domain, IP address, or ASN as an argument or on stdin.');
41
+ throw new CliUsageError('lookup requires one domain, IP address, or ASN as an argument or on stdin.', 'missing_input');
42
42
  const classify = dependencies.classifyQuery || classifyQuery;
43
43
  const executeLookup = dependencies.runUnifiedLookup || runUnifiedLookup;
44
44
  let classified;
@@ -74,6 +74,18 @@ async function runLookupCommand(args, dependencies, context) {
74
74
  ...(args.vantageLabel ? { vantageLabel: args.vantageLabel } : {}),
75
75
  });
76
76
  };
77
+ const collect = (signal, onSourceSettled) => abortable(() => executeLookup(classified, {
78
+ fast: !args.deep,
79
+ compact: false,
80
+ ...(signal ? { signal } : {}),
81
+ ...(args.deep ? {
82
+ ...(selectedUrl ? { selectedUrl } : {}),
83
+ onSourceSettled: settlement => {
84
+ onSourceSettled(settlement);
85
+ eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
86
+ },
87
+ } : {}),
88
+ }), signal);
77
89
  let document;
78
90
  if (args.browse === true) {
79
91
  const browse = dependencies.browseLookupOperation || browseLookupOperation;
@@ -88,23 +100,7 @@ async function runLookupCommand(args, dependencies, context) {
88
100
  plannedSources: args.deep ? plannedLookupProgressSources(classified) : [],
89
101
  ...(dependencies.signal ? { signal: dependencies.signal } : {}),
90
102
  collect: async ({ signal, onSourceSettled }) => {
91
- const result = await abortable(() => executeLookup(classified, args.deep
92
- ? {
93
- fast: false,
94
- compact: false,
95
- ...(selectedUrl ? { selectedUrl } : {}),
96
- signal,
97
- onSourceSettled: (settlement) => {
98
- onSourceSettled(settlement);
99
- eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
100
- },
101
- }
102
- : {
103
- fast: true,
104
- compact: false,
105
- signal,
106
- }), signal);
107
- return buildDocument(result);
103
+ return buildDocument(await collect(signal, onSourceSettled));
108
104
  },
109
105
  });
110
106
  if (args.saveLookup) {
@@ -125,23 +121,10 @@ async function runLookupCommand(args, dependencies, context) {
125
121
  let settledSources = 0;
126
122
  let result;
127
123
  try {
128
- result = await abortable(() => executeLookup(classified, args.deep
129
- ? {
130
- fast: false,
131
- compact: false,
132
- ...(selectedUrl ? { selectedUrl } : {}),
133
- ...(dependencies.signal ? { signal: dependencies.signal } : {}),
134
- onSourceSettled: (settlement) => {
135
- settledSources += 1;
136
- indicator.update(`Collected ${settledSources} source${settledSources === 1 ? '' : 's'} · ${settlement.source.replaceAll('_', ' ')} ${settlement.state}`);
137
- eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
138
- },
139
- }
140
- : {
141
- fast: true,
142
- compact: false,
143
- ...(dependencies.signal ? { signal: dependencies.signal } : {}),
144
- }), dependencies.signal);
124
+ result = await collect(dependencies.signal, settlement => {
125
+ settledSources += 1;
126
+ indicator.update(`Collected ${settledSources} source${settledSources === 1 ? '' : 's'} · ${settlement.source.replaceAll('_', ' ')} ${settlement.state}`);
127
+ });
145
128
  }
146
129
  finally {
147
130
  context.endProgress();
@@ -5,6 +5,7 @@ import { relationshipObservation } from "../packages/comparison/relationship-evi
5
5
  import { CliUsageError } from "./errors.mjs";
6
6
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
7
7
  import { lookupObservationHostname } from "../packages/evidence/lookup-target.mjs";
8
+ import { safeTerminalValue } from "./formatters/terminal-shared.mjs";
8
9
  export const CLI_LOOKUP_DIFF_SCHEMA = 'whoisleuth.cli.lookup-diff';
9
10
  export const CLI_LOOKUP_DIFF_VERSION = 1;
10
11
  function text(value, maximum = 300) {
@@ -198,10 +199,10 @@ function formatCliLookupDiff(document) {
198
199
  const rows = document.comparison.rows.filter((row) => row.state !== 'equal');
199
200
  const output = [
200
201
  'Domain evidence diff',
201
- `Left ${document.left.domain}`,
202
- `Left observed ${document.left.generatedAt}`,
203
- `Right ${document.right.domain}`,
204
- `Right observed ${document.right.generatedAt}`,
202
+ `Left ${safeTerminalValue(document.left.domain)}`,
203
+ `Left observed ${safeTerminalValue(document.left.generatedAt)}`,
204
+ `Right ${safeTerminalValue(document.right.domain)}`,
205
+ `Right observed ${safeTerminalValue(document.right.generatedAt)}`,
205
206
  `Changed ${document.comparison.counts.different + document.comparison.counts.conflicting}`,
206
207
  `One-sided ${document.comparison.counts.missing}`,
207
208
  `Unavailable ${document.comparison.counts.unavailable + document.comparison.counts.not_recorded}`,
@@ -210,14 +211,14 @@ function formatCliLookupDiff(document) {
210
211
  if (!rows.length)
211
212
  output.push('No bounded differences were observed in the comparable fields.');
212
213
  for (const row of rows) {
213
- output.push(`${row.label} [${row.state.replaceAll('_', ' ')}]`);
214
- output.push(` Left: ${row.left}`);
215
- output.push(` Right: ${row.right}`);
216
- output.push(` Source: ${row.source}`);
214
+ output.push(`${safeTerminalValue(row.label)} [${safeTerminalValue(row.state).replaceAll('_', ' ')}]`);
215
+ output.push(` Left: ${safeTerminalValue(row.left)}`);
216
+ output.push(` Right: ${safeTerminalValue(row.right)}`);
217
+ output.push(` Source: ${safeTerminalValue(row.source)}`);
217
218
  }
218
219
  output.push('', 'Limitations:');
219
220
  for (const limitation of document.limitations)
220
- output.push(` - ${limitation}`);
221
+ output.push(` - ${safeTerminalValue(limitation)}`);
221
222
  return `${output.join('\n')}\n`;
222
223
  }
223
224
  export { buildCliLookupDiff, formatCliLookupDiff };
@@ -1,3 +1,4 @@
1
+ import { plannedLookupSources } from "../lib/lookup-progress.mjs";
1
2
  export const CLI_LOOKUP_PLAN_SCHEMA = 'whoisleuth.cli.lookup-plan';
2
3
  export const CLI_LOOKUP_PLAN_VERSION = 1;
3
4
  const RDAP = Object.freeze({
@@ -43,20 +44,15 @@ const REVERSE_DNS = Object.freeze({
43
44
  conditional: false,
44
45
  });
45
46
  function plannedSources(classified, deep, selectedUrl) {
46
- if (!deep) {
47
- return Object.freeze(classified.type === 'domain' ? [RDAP, FAST_DOMAIN_EVIDENCE] : [RDAP]);
48
- }
49
- if (classified.type === 'domain') {
50
- const domainEvidence = selectedUrl ? Object.freeze({
51
- ...DEEP_DOMAIN_EVIDENCE,
52
- disclosure: 'The selected URL path and query are sent to the website and followed redirects. The fragment is not sent. DNS and TLS use its hostname; registration uses the registrable domain. Saved HTTP provenance omits queries, but page-derived text may contain sensitive information.',
53
- }) : DEEP_DOMAIN_EVIDENCE;
54
- return Object.freeze([RDAP, WHOIS, domainEvidence, REGISTRAR_RDAP, NETWORK_CONTEXT]);
55
- }
56
- if (classified.type === 'ipv4' || classified.type === 'ipv6') {
57
- return Object.freeze([RDAP, WHOIS, REVERSE_DNS]);
58
- }
59
- return Object.freeze([RDAP, WHOIS]);
47
+ const domainEvidence = selectedUrl && deep ? Object.freeze({
48
+ ...DEEP_DOMAIN_EVIDENCE,
49
+ disclosure: 'The selected URL path and query are sent to the website and followed redirects. The fragment is not sent. DNS and TLS use its hostname; registration uses the registrable domain. Saved HTTP provenance omits queries, but page-derived text may contain sensitive information.',
50
+ }) : deep ? DEEP_DOMAIN_EVIDENCE : FAST_DOMAIN_EVIDENCE;
51
+ const sources = {
52
+ rdap: RDAP, whois: WHOIS, domain_evidence: domainEvidence,
53
+ registrar_rdap: REGISTRAR_RDAP, network_context: NETWORK_CONTEXT, reverse_dns: REVERSE_DNS,
54
+ };
55
+ return Object.freeze(plannedLookupSources(classified.type, deep ? 'deep' : 'fast').map(id => sources[id]));
60
56
  }
61
57
  function buildCliLookupPlan(query, classified, deep, selectedUrl = false) {
62
58
  return Object.freeze({
@@ -2,6 +2,7 @@ import { CliUsageError } from "./errors.mjs";
2
2
  import { buildCliLookupDiff } from "./lookup-diff.mjs";
3
3
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
4
4
  import { compareObservationContexts } from "../packages/comparison/capture-context.mjs";
5
+ import { safeTerminalValue } from "./formatters/terminal-shared.mjs";
5
6
  const CLI_LOOKUP_RECONCILIATION_SCHEMA = 'whoisleuth.cli.lookup-reconciliation';
6
7
  const CLI_LOOKUP_RECONCILIATION_VERSION = 1;
7
8
  const MAX_LOOKUP_RECONCILIATION_INPUT_BYTES = 32 * 1024 * 1024;
@@ -117,7 +118,7 @@ function formatCliLookupReconciliation(document) {
117
118
  const context = compareObservationContexts(document.observations.map(observation => ({ ...observation, observedAt: observation.generatedAt })));
118
119
  const output = [
119
120
  'Lookup observation reconciliation',
120
- `Domain ${document.domain}`,
121
+ `Domain ${safeTerminalValue(document.domain)}`,
121
122
  `Observations ${document.summary.observationCount}`,
122
123
  `Declared labels ${context.labels}; collection independence not verified`,
123
124
  `Capture time span ${context.time.spanMilliseconds === null ? 'unknown' : `${context.time.spanMilliseconds / 1000} seconds`}`,
@@ -125,18 +126,18 @@ function formatCliLookupReconciliation(document) {
125
126
  `Disagreement ${document.summary.disagreement}`,
126
127
  `Non-comparable ${document.summary.nonComparable}`,
127
128
  '',
128
- ...document.observations.map((observation, index) => `Observation ${index + 1}: ${observation.generatedAt} · ${observation.mode} · observer ${observation.observerLabel ?? 'not declared'} · vantage ${observation.vantageLabel ?? 'not declared'}`),
129
+ ...document.observations.map((observation, index) => `Observation ${index + 1}: ${safeTerminalValue(observation.generatedAt)} · ${safeTerminalValue(observation.mode)} · observer ${safeTerminalValue(observation.observerLabel, 'not declared')} · vantage ${safeTerminalValue(observation.vantageLabel, 'not declared')}`),
129
130
  '',
130
131
  ];
131
132
  for (const field of document.fields.filter((candidate) => candidate.state !== 'agreement')) {
132
- output.push(`${field.label} [${field.state.replaceAll('_', ' ')}]`);
133
+ output.push(`${safeTerminalValue(field.label)} [${safeTerminalValue(field.state).replaceAll('_', ' ')}]`);
133
134
  for (const value of field.values) {
134
- output.push(` Observation ${value.observation + 1}: ${value.value} (${value.sourceState.replaceAll('_', ' ')})`);
135
+ output.push(` Observation ${value.observation + 1}: ${safeTerminalValue(value.value)} (${safeTerminalValue(value.sourceState).replaceAll('_', ' ')})`);
135
136
  }
136
137
  }
137
138
  output.push('', 'Limitations:');
138
139
  for (const limitation of document.limitations)
139
- output.push(` - ${limitation}`);
140
+ output.push(` - ${safeTerminalValue(limitation)}`);
140
141
  output.push(' - These selected observations do not establish worldwide availability, removal or takedown.');
141
142
  return `${output.join('\n')}\n`;
142
143
  }
@@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer';
2
2
  import { CliUsageError } from "./errors.mjs";
3
3
  import { buildCliLookupDiff } from "./lookup-diff.mjs";
4
4
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
5
+ import { safeTerminalValue } from "./formatters/terminal-shared.mjs";
5
6
  export const CLI_LOOKUP_TIMELINE_SCHEMA = 'whoisleuth.cli.lookup-timeline';
6
7
  export const CLI_LOOKUP_TIMELINE_VERSION = 1;
7
8
  export const MAX_LOOKUP_TIMELINE_OBSERVATIONS = 20;
@@ -95,10 +96,10 @@ function buildCliLookupTimeline(inputs, generatedAt = new Date().toISOString())
95
96
  function formatCliLookupTimeline(document) {
96
97
  const output = [
97
98
  'Same-domain observation timeline',
98
- `Domain ${document.domain}`,
99
+ `Domain ${safeTerminalValue(document.domain)}`,
99
100
  `Observations ${document.summary.observationCount}`,
100
- `First observed ${document.observations[0]?.generatedAt || 'Unavailable'}`,
101
- `Last observed ${document.observations.at(-1)?.generatedAt || 'Unavailable'}`,
101
+ `First observed ${safeTerminalValue(document.observations[0]?.generatedAt, 'Unavailable')}`,
102
+ `Last observed ${safeTerminalValue(document.observations.at(-1)?.generatedAt, 'Unavailable')}`,
102
103
  `Changed transitions ${document.summary.transitionsWithObservedChanges}`,
103
104
  ];
104
105
  for (const transition of document.transitions) {
@@ -106,18 +107,18 @@ function formatCliLookupTimeline(document) {
106
107
  const to = document.observations[transition.toObservation];
107
108
  const counts = transition.comparison.counts;
108
109
  const changedRows = transition.comparison.rows.filter((row) => (row.state === 'conflicting' || row.state === 'different' || row.state === 'missing'));
109
- output.push('', `${from?.generatedAt || 'Unavailable'} to ${to?.generatedAt || 'Unavailable'}`, ` Observed changes ${counts.conflicting + counts.different}`, ` One-sided fields ${counts.missing}`, ` Unavailable ${counts.unavailable + counts.not_recorded}`);
110
+ output.push('', `${safeTerminalValue(from?.generatedAt, 'Unavailable')} to ${safeTerminalValue(to?.generatedAt, 'Unavailable')}`, ` Observed changes ${counts.conflicting + counts.different}`, ` One-sided fields ${counts.missing}`, ` Unavailable ${counts.unavailable + counts.not_recorded}`);
110
111
  if (!changedRows.length)
111
112
  output.push(' No observed field changes in comparable evidence.');
112
113
  for (const row of changedRows.slice(0, 10)) {
113
- output.push(` ${row.label} [${row.state.replaceAll('_', ' ')}]: ${row.left} -> ${row.right}`);
114
+ output.push(` ${safeTerminalValue(row.label)} [${safeTerminalValue(row.state).replaceAll('_', ' ')}]: ${safeTerminalValue(row.left)} -> ${safeTerminalValue(row.right)}`);
114
115
  }
115
116
  if (changedRows.length > 10)
116
117
  output.push(` ${changedRows.length - 10} additional changed fields omitted; use --json for the complete bounded comparison.`);
117
118
  }
118
119
  output.push('', 'Limitations:');
119
120
  for (const limitation of document.limitations)
120
- output.push(` - ${limitation}`);
121
+ output.push(` - ${safeTerminalValue(limitation)}`);
121
122
  return `${output.join('\n')}\n`;
122
123
  }
123
124
  export { buildCliLookupTimeline, formatCliLookupTimeline };
@@ -1,20 +1,19 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { Buffer } from 'node:buffer';
3
- import { isValidAsciiDomainName } from "../lib/hostname.mjs";
3
+ import { MAX_MAIL_REVIEW_DOMAINS, addressDomains, messageIdDomains, authenticationServiceDomains, dkimSigningDomains, normalizeHeaderDomain } from "../packages/investigation/mail-header-identity.mjs";
4
+ export { MAX_MAIL_REVIEW_DOMAINS } from "../packages/investigation/mail-header-identity.mjs";
4
5
  import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
5
6
  import { CliUsageError } from "./errors.mjs";
7
+ import { reviewMailAuthentication, selectReceiverTrust, authenticationHeaderLabel, aggregateMailAuthentication } from "../packages/investigation/mail-authentication-review.mjs";
6
8
  export const CLI_MAIL_HEADER_REVIEW_SCHEMA = 'whoisleuth.cli.mail-header-review';
7
- export const CLI_MAIL_HEADER_REVIEW_VERSION = 1;
9
+ export const CLI_MAIL_HEADER_REVIEW_VERSION = 2;
8
10
  export const MAX_MAIL_HEADER_INPUT_BYTES = 256 * 1024;
9
11
  export const MAX_MAIL_HEADER_FIELDS = 512;
10
12
  export const MAX_MAIL_HEADER_LINE_BYTES = 4 * 1024;
11
13
  export const MAX_MAIL_HEADER_VALUE_LENGTH = 16 * 1024;
12
14
  export const MAX_MAIL_RECEIVED_HOPS = 64;
13
- export const MAX_MAIL_REVIEW_DOMAINS = 128;
14
15
  const UNSAFE_HEADER_CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f]/u;
15
16
  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
17
  function boundedHeaderSection(value) {
19
18
  if (typeof value !== 'string' || !value)
20
19
  throw new CliUsageError('Mail-header review requires one UTF-8 message or header block.');
@@ -75,240 +74,6 @@ function parseHeaderFields(value) {
75
74
  function fieldsNamed(fields, name) {
76
75
  return fields.filter((field) => field.name === name).map((field) => field.value);
77
76
  }
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
77
  function reportedDate(values) {
313
78
  if (values.length !== 1 || values[0].length > 240)
314
79
  return null;
@@ -348,21 +113,26 @@ function addDomainRoles(store, domains, role) {
348
113
  store.set(domain, roles);
349
114
  }
350
115
  }
351
- export function buildCliMailHeaderReview(value, generatedAt = new Date().toISOString()) {
116
+ export function buildCliMailHeaderReview(value, generatedAt = new Date().toISOString(), trustedHeaders = []) {
352
117
  const normalizedGeneratedAt = normalizeExplicitIsoTimestamp(generatedAt);
353
118
  if (!normalizedGeneratedAt)
354
119
  throw new CliUsageError('Mail-header review generation time must be a valid ISO 8601 timestamp.');
355
120
  const parsed = parseHeaderFields(value);
121
+ let authenticationReview;
122
+ try {
123
+ authenticationReview = selectReceiverTrust(reviewMailAuthentication(parsed.fields), trustedHeaders);
124
+ }
125
+ catch (cause) {
126
+ if (cause instanceof TypeError)
127
+ throw new CliUsageError(cause.message);
128
+ throw cause;
129
+ }
356
130
  const fromDomains = addressDomains(fieldsNamed(parsed.fields, 'from'));
357
131
  const replyToDomains = addressDomains(fieldsNamed(parsed.fields, 'reply-to'));
358
132
  const returnPathDomains = addressDomains(fieldsNamed(parsed.fields, 'return-path'));
359
133
  const messageIdentifierDomains = messageIdDomains(fieldsNamed(parsed.fields, 'message-id'));
360
134
  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
- ]));
135
+ const authentication = aggregateMailAuthentication(authenticationReview);
366
136
  const authServiceDomains = authenticationServiceDomains(authenticationResults);
367
137
  const signerDomains = dkimSigningDomains(fieldsNamed(parsed.fields, 'dkim-signature'));
368
138
  const receivedValues = fieldsNamed(parsed.fields, 'received');
@@ -414,6 +184,7 @@ export function buildCliMailHeaderReview(value, generatedAt = new Date().toISOSt
414
184
  messageIdentifierDomains: Object.freeze(messageIdentifierDomains),
415
185
  }),
416
186
  authentication: Object.freeze(authentication),
187
+ authenticationReview,
417
188
  routing: Object.freeze({
418
189
  receivedHops: Object.freeze(receivedHops),
419
190
  omittedReceivedHops,
@@ -428,6 +199,7 @@ export function buildCliMailHeaderReview(value, generatedAt = new Date().toISOSt
428
199
  limitations: Object.freeze([
429
200
  'This review parses analyst-selected message headers offline. It makes no DNS, SMTP, HTTP, registry or provider request.',
430
201
  'Authentication results are claims reported in the supplied headers; they are not independently validated here.',
202
+ 'Receiver trust is an explicit analyst selection for an exact header, not a conclusion from a matching service name. Aggregate counts combine all reported sources, not a trusted verdict.',
431
203
  'Exact-domain alignment is descriptive. Divergence can be legitimate and does not establish spoofing, abuse or maliciousness.',
432
204
  'Display names, subjects, address local parts, message bodies, attachments and raw header values are not retained in the output.',
433
205
  ...(omittedReceivedHops ? [`${omittedReceivedHops} older Received header${omittedReceivedHops === 1 ? ' was' : 's were'} omitted by the ${MAX_MAIL_RECEIVED_HOPS}-hop bound.`] : []),
@@ -444,8 +216,13 @@ export function formatCliMailHeaderReview(document) {
444
216
  `Reply-To domains ${document.identity.replyToDomains.join(', ') || 'Unavailable'}`,
445
217
  `Return-Path ${document.identity.returnPathDomains.join(', ') || 'Unavailable'}`,
446
218
  '',
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'})`),
219
+ 'Authentication by source header',
220
+ ...document.authenticationReview.headers.flatMap(header => [
221
+ ` ${authenticationHeaderLabel(header)} · ${header.state} · receiver trust: ${header.receiverTrust.replaceAll('_', ' ')}`,
222
+ ...header.claims.map(claim => ` ${claim.method.toUpperCase()}/${claim.methodVersion}: ${claim.result}${claim.state === 'unsupported' ? ' (unsupported)' : ''}${claim.domains.map(value => ` · ${value.property}=${value.domain}`).join('')}${claim.duplicateProperties.length ? ` · ambiguous: ${claim.duplicateProperties.join(', ')}` : ''}`),
223
+ ...(header.duplicateOf ? [` Duplicate of header ${header.duplicateOf} in this part.`] : []),
224
+ ...header.issues.map(issue => ` ${issue}`),
225
+ ]),
449
226
  '',
450
227
  'Exact-domain alignment',
451
228
  ` From / Reply-To ${document.alignment.fromToReplyTo}`,
@@ -1,11 +1,13 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { getDomain } from 'tldts';
3
- import { scanBoundedJson } from "../lib/bounded-json.mjs";
4
- import { isValidAsciiDomainName } from "../lib/hostname.mjs";
3
+ import { scanBoundedJson } from "../packages/analysis/bounded-json.mjs";
4
+ import { compareCodeUnits } from "../lib/bounded-contract-normalizers.mjs";
5
+ import { isValidAsciiDomainName } from "../packages/contracts/domain-name.mjs";
5
6
  import { isRecord, recordOrEmpty } from "../lib/json-record.mjs";
6
7
  import { classifyQuery } from "../lib/classify.mjs";
7
8
  import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
8
9
  import { CliUsageError } from "./errors.mjs";
10
+ import { PASSIVE_MAIL_INTERPRETATION, PASSIVE_MAIL_LABELS } from "../packages/contracts/passive-mail.mjs";
9
11
  export const CLI_MAIL_REVIEW_SCHEMA = 'whoisleuth.cli.mail-review';
10
12
  export const CLI_MAIL_REVIEW_VERSION = 4;
11
13
  export const MAX_MAIL_REVIEW_INPUT_BYTES = 16 * 1024 * 1024;
@@ -326,7 +328,7 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
326
328
  limitation: 'Shared mail providers are common and do not establish common ownership, control, intent, safety, or maliciousness.',
327
329
  };
328
330
  })
329
- .sort((left, right) => left.providerDomain.localeCompare(right.providerDomain));
331
+ .sort((left, right) => compareCodeUnits(left.providerDomain, right.providerDomain));
330
332
  const providerRelationships = allProviderRelationships.slice(0, 100);
331
333
  const rowsWithIncompleteDns = rows.filter((row) => row.dnsStatus !== 'success' || row.hasMx === null || row.hasNullMx === null).length;
332
334
  const providerCoverage = {
@@ -355,7 +357,7 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
355
357
  providerCoverage,
356
358
  limitations: [
357
359
  'This review is passive and uses DNS evidence already retained in a WHOISleuth Bulk result; it makes no network request.',
358
- 'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
360
+ PASSIVE_MAIL_INTERPRETATION,
359
361
  'SMTP delivery, mailbox existence, catch-all behaviour, banner collection, and message acceptance were not tested.',
360
362
  ...(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
363
  ...(rowsWithIncompleteDns ? [`${rowsWithIncompleteDns} reviewed domain${rowsWithIncompleteDns === 1 ? ' has' : 's have'} incomplete DNS evidence; unobserved provider relationships remain unknown.`] : []),
@@ -371,15 +373,15 @@ function formatCliMailReview(document) {
371
373
  `Input targets ${document.inputCoverage.inputRows}`,
372
374
  `Domains ${document.rows.length}`,
373
375
  `Failed targets ${document.inputCoverage.failedRows}`,
374
- `Authenticated ${document.counts.authenticated_mail}`,
375
- `Auth gaps ${document.counts.mail_auth_gap}`,
376
+ `MX + SPF + DMARC ${document.counts.authenticated_mail}`,
377
+ `DNS policy gaps ${document.counts.mail_auth_gap}`,
376
378
  `Null MX ${document.counts.null_mx}`,
377
379
  `No explicit MX ${document.counts.no_explicit_mx}`,
378
380
  `Incomplete ${document.counts.evidence_incomplete + document.counts.mail_auth_incomplete + document.inputCoverage.failedRows}`,
379
381
  '',
380
382
  ];
381
383
  for (const row of document.rows) {
382
- lines.push(`${row.domain} ${row.state.replaceAll('_', ' ')}`);
384
+ lines.push(`${row.domain} ${PASSIVE_MAIL_LABELS[row.state]}`);
383
385
  lines.push(` Observed ${row.provenance.observedAt ?? 'unknown'} · ${row.provenance.collectionOrigin.replaceAll('_', ' ')}`);
384
386
  lines.push(` DNS observed ${row.provenance.dnsObservedAt ?? 'unknown'} · Source output ${row.provenance.sourceGeneratedAt}`);
385
387
  lines.push(` MX providers ${row.providerDomains.join(', ') || 'None observed'}${row.providerDomainsOmitted ? ` · +${row.providerDomainsOmitted} omitted` : ''}`);