@slicedearth/whoisleuth-cli 1.47.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/README.md +74 -177
  2. package/SECURITY.md +6 -3
  3. package/bin/whoisleuth.mjs +2 -1
  4. package/cli/archive-inspect.mjs +28 -10
  5. package/cli/arguments.mjs +204 -174
  6. package/cli/artifact-structure.mjs +837 -163
  7. package/cli/artifact-verify.mjs +90 -61
  8. package/cli/bounded-json.mjs +1 -1
  9. package/cli/bulk-checkpoint.mjs +33 -20
  10. package/cli/bulk.mjs +9 -2
  11. package/cli/case-pack.mjs +180 -576
  12. package/cli/ci-report.mjs +6 -1
  13. package/cli/collection-preflight.mjs +2 -2
  14. package/cli/command-catalogue.mjs +12 -3
  15. package/cli/command-reference.mjs +910 -92
  16. package/cli/completion.mjs +504 -226
  17. package/cli/config-profile.mjs +13 -3
  18. package/cli/discover.mjs +7 -1
  19. package/cli/discovery-observation-snapshot.mjs +24 -28
  20. package/cli/discovery-scan-command-runner.mjs +9 -9
  21. package/cli/discovery-scan.mjs +17 -3
  22. package/cli/discovery-snapshot.mjs +15 -7
  23. package/cli/domain-control-monitor.mjs +203 -30
  24. package/cli/domain-control-observations.mjs +15 -21
  25. package/cli/errors.mjs +8 -3
  26. package/cli/evidence-signing.mjs +10 -20
  27. package/cli/external-observation-mapping.mjs +3 -3
  28. package/cli/formatters/json.mjs +20 -32
  29. package/cli/formatters/terminal.mjs +583 -22
  30. package/cli/input.mjs +7 -2
  31. package/cli/interactive-launcher.mjs +226 -0
  32. package/cli/interchange-report.mjs +19 -19
  33. package/cli/investigation-manifest.mjs +1 -1
  34. package/cli/investigation-plan.mjs +167 -16
  35. package/cli/investigation-run.mjs +9 -1
  36. package/cli/lookalike-calibration.mjs +3 -1
  37. package/cli/lookup-brief.mjs +13 -8
  38. package/cli/lookup-browser.mjs +844 -0
  39. package/cli/lookup-command-runner.mjs +94 -31
  40. package/cli/lookup-diff.mjs +2 -2
  41. package/cli/lookup-plan.mjs +1 -0
  42. package/cli/mail-review.mjs +206 -94
  43. package/cli/manual.mjs +5 -5
  44. package/cli/offline-evidence-review.mjs +18 -7
  45. package/cli/open-asset-model-bridge.mjs +1 -1
  46. package/cli/output-file.mjs +70 -14
  47. package/cli/page-compare.mjs +11 -6
  48. package/cli/progress-events.mjs +2 -1
  49. package/cli/progress.mjs +3 -3
  50. package/cli/registry-cohort.mjs +9 -51
  51. package/cli/registry-doctor.mjs +67 -28
  52. package/cli/retained-artifact-diff.mjs +7 -8
  53. package/cli/risk-calibration.mjs +85 -38
  54. package/cli/runner.mjs +189 -75
  55. package/cli/saved-lookup.mjs +424 -57
  56. package/cli/sharing-review.mjs +6 -1
  57. package/cli/source-reliability.mjs +75 -36
  58. package/cli/terminal-presentation.mjs +43 -18
  59. package/docs/cli-reference.md +125 -1708
  60. package/docs/cli.md +86 -254
  61. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
  62. package/frontend/src/lib/analysis/domain-control-records.js +3 -88
  63. package/lib/availability.mjs +58 -40
  64. package/lib/bounded-contract-normalizers.mjs +2 -4
  65. package/lib/bounded-file.mjs +78 -4
  66. package/lib/bounded-json.mjs +51 -13
  67. package/lib/browser-library-profile.mjs +1 -1
  68. package/lib/classify.mjs +56 -5
  69. package/lib/client-behavior-profile.mjs +1 -1
  70. package/lib/credential-surface-profile.mjs +1 -1
  71. package/lib/cryptographic-assurance.mjs +35 -17
  72. package/lib/ct-query.mjs +3 -3
  73. package/lib/ct-search.mjs +37 -21
  74. package/lib/distributed-operation-budget.mjs +4 -2
  75. package/lib/dns-delegation-health.mjs +9 -6
  76. package/lib/dns-intelligence.mjs +1 -1
  77. package/lib/dnssec-chain-validation.mjs +2 -3
  78. package/lib/dnssec-evidence-validation.mjs +13 -7
  79. package/lib/domain-assurance.mjs +12 -12
  80. package/lib/domain-change-packet.mjs +1 -1
  81. package/lib/domain-control-flight-recorder.mjs +442 -78
  82. package/lib/domain-control-manifest.mjs +314 -59
  83. package/lib/domain-posture-analysis.mjs +5 -2
  84. package/lib/encrypted-dns-contract.mjs +2 -3
  85. package/lib/evidence-export.mjs +518 -36
  86. package/lib/external-intelligence-risk.mjs +26 -10
  87. package/lib/feature-policy.mjs +1 -1
  88. package/lib/homepage-metadata-contract.mjs +290 -0
  89. package/lib/hostname.mjs +1 -1
  90. package/lib/html-signals.mjs +89 -5
  91. package/lib/http-intelligence.mjs +331 -2
  92. package/lib/interchange-fidelity-registry.mjs +28 -26
  93. package/lib/lookup-cache.mjs +6 -4
  94. package/lib/lookup.mjs +25 -2
  95. package/lib/observed-network-context.mjs +1 -1
  96. package/lib/page-fingerprints.mjs +2 -2
  97. package/lib/page-role-profile.mjs +1 -1
  98. package/lib/portable-generator.mjs +8 -0
  99. package/lib/provider-policy-admission.mjs +3 -1
  100. package/lib/rdap-bootstrap.mjs +76 -16
  101. package/lib/rdap-client.mjs +20 -12
  102. package/lib/rdap-normalization.mjs +306 -75
  103. package/lib/rdap-transport.mjs +2 -2
  104. package/lib/rdap-validation.mjs +65 -14
  105. package/lib/rdap.mjs +2 -2
  106. package/lib/registrable-domain.mjs +36 -0
  107. package/lib/registry-insights.mjs +7 -0
  108. package/lib/response-policy.mjs +27 -4
  109. package/lib/risk-calibration-summary.mjs +35 -33
  110. package/lib/risk-scoring.mjs +4 -3
  111. package/lib/safe-fetch.mjs +62 -40
  112. package/lib/security-txt.mjs +1 -1
  113. package/lib/smtp-transport-review.mjs +8 -5
  114. package/lib/sslbl-intelligence.mjs +6 -8
  115. package/lib/static-html-analysis.mjs +301 -4
  116. package/lib/structured-data-identity.mjs +51 -19
  117. package/lib/threat-intelligence-fixtures.mjs +2 -4
  118. package/lib/threat-intelligence-manifests.mjs +2 -7
  119. package/lib/threat-intelligence-runtime.mjs +3 -5
  120. package/lib/threat-intelligence-types.mjs +3 -0
  121. package/lib/threatfox-intelligence.mjs +7 -3
  122. package/lib/tls-intelligence.mjs +4 -4
  123. package/lib/urlhaus-intelligence.mjs +4 -2
  124. package/lib/urlscan-intelligence.mjs +6 -5
  125. package/lib/website-security-posture.mjs +1 -1
  126. package/lib/website-technology.mjs +1 -1
  127. package/lib/whois-authority.mjs +43 -8
  128. package/lib/whois-chain.mjs +25 -3
  129. package/lib/whois-normalization.mjs +3 -1
  130. package/lib/whois-transport.mjs +12 -3
  131. package/lib/zone-intent-review.mjs +1 -1
  132. package/package.json +12 -4
  133. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  134. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  135. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  136. package/packages/cases/case-migration-model.mjs +476 -0
  137. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  138. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  139. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  140. package/packages/cases/case-record-model.mjs +9 -0
  141. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  142. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  143. package/packages/cases/case-response-model.mjs +1604 -0
  144. package/packages/cases/case-response-packet.mjs +1312 -0
  145. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  146. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  147. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  148. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  149. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  150. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  151. package/packages/comparison/favicon-similarity.mjs +78 -0
  152. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  153. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  154. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  155. package/packages/contracts/analyst-review-state.mjs +50 -0
  156. package/packages/contracts/case-portability.mjs +1310 -0
  157. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  158. package/packages/contracts/cli-lookup.mjs +394 -0
  159. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  160. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  161. package/packages/contracts/domain-control-monitor.mjs +310 -0
  162. package/packages/contracts/domain-control-review.mjs +828 -0
  163. package/packages/contracts/domain-name.mjs +4 -0
  164. package/packages/contracts/external-observation-interchange.mjs +224 -0
  165. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  166. package/packages/contracts/investigation-portability.mjs +331 -0
  167. package/packages/contracts/offline-comparison.mjs +160 -0
  168. package/packages/contracts/risk-calibration.mjs +1288 -0
  169. package/packages/contracts/schema-compatibility.mjs +71 -0
  170. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  171. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  172. package/packages/contracts/workspace-portability.mjs +1347 -0
  173. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  174. package/packages/evidence/decision-fact.mjs +735 -0
  175. package/packages/evidence/domain-control-runtime.mjs +547 -0
  176. package/packages/evidence/domain-name.mjs +41 -0
  177. package/packages/evidence/observation.mjs +384 -0
  178. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  179. package/packages/interchange/retained-text.mjs +8 -0
  180. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  181. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  182. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  183. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  184. package/packages/investigation/bulk-review-policy.mjs +1 -0
  185. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  186. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  187. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  188. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  189. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  190. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  191. package/packages/monitoring/analyst-review-state.mjs +680 -0
  192. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +70 -47
  193. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  194. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  195. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  196. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  197. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  198. package/packages/workspace/hostile-input.mjs +172 -0
  199. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  200. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  201. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  202. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  203. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  204. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  205. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  206. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  207. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  208. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  209. package/third-party-notices.txt +14 -14
  210. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  211. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  212. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  213. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  214. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  215. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  216. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  217. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  218. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  219. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  220. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  221. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  222. package/frontend/src/lib/analysis/scoring.js +0 -55
  223. package/frontend/src/lib/analysis/utils.js +0 -255
  224. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  225. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  226. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  227. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  228. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  229. package/lib/bounded-json-response.mjs +0 -170
  230. package/lib/lookup-response-contract.mjs +0 -1034
  231. package/lib/observation.mjs +0 -115
  232. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  233. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -1,6 +1,7 @@
1
1
  import { abortable } from "../lib/abort.mjs";
2
2
  import { classifyQuery } from "../lib/classify.mjs";
3
3
  import { runUnifiedLookup } from "../lib/lookup.mjs";
4
+ import { plannedLookupProgressSources } from "../lib/lookup-source-progress.mjs";
4
5
  import { CliUsageError, boundedCliErrorMessage } from "./errors.mjs";
5
6
  import EXIT_CODES from "./exit-codes.mjs";
6
7
  import { buildCliEvidenceExport } from "./export-evidence.mjs";
@@ -10,10 +11,23 @@ import { formatLookupEvidenceMarkdown } from "./formatters/markdown.mjs";
10
11
  import { formatTerminalLookup } from "./formatters/terminal.mjs";
11
12
  import { buildCliLookupPlan, formatCliLookupPlan } from "./lookup-plan.mjs";
12
13
  import { createCliProgressEvents } from "./progress-events.mjs";
14
+ import { serializeCliLookupDocument } from "./saved-lookup.mjs";
13
15
  import { lookupStrictExitFindings } from "./strict-exit.mjs";
14
16
  import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
15
17
  import { formatCliJunit } from "./ci-report.mjs";
18
+ import { browseLookupOperation, canBrowseLookup } from "./lookup-browser.mjs";
19
+ import { writePrivateFile } from "./output-file.mjs";
16
20
  async function runLookupCommand(args, dependencies, context) {
21
+ const browserInput = dependencies.stdin || process.stdin;
22
+ const browserEnvironment = dependencies.environment || process.env;
23
+ if (args.browse === true) {
24
+ if (!args.query)
25
+ throw new CliUsageError('--browse requires a positional target because interactive stdin is reserved for navigation.');
26
+ const supportsBrowser = dependencies.canBrowseLookup || canBrowseLookup;
27
+ if (!supportsBrowser(browserInput, context.stdout, browserEnvironment)) {
28
+ throw new CliUsageError('--browse requires interactive terminal input and output. Use ordinary terminal output or --json when redirecting.');
29
+ }
30
+ }
17
31
  const eventProgress = createCliProgressEvents(context.stderr, {
18
32
  command: 'lookup',
19
33
  enabled: args.events,
@@ -43,51 +57,100 @@ async function runLookupCommand(args, dependencies, context) {
43
57
  : context.terminal(formatCliLookupPlan(plan), args.color));
44
58
  return EXIT_CODES.SUCCESS;
45
59
  }
46
- const indicator = context.beginProgress(args.deep
47
- ? 'Collecting deep Lookup evidence'
48
- : 'Collecting registration evidence');
49
- let settledSources = 0;
50
- let result;
51
- try {
52
- result = await abortable(() => executeLookup(classified, args.deep
53
- ? {
54
- fast: false,
55
- compact: false,
56
- ...(dependencies.signal ? { signal: dependencies.signal } : {}),
57
- onSourceSettled: (settlement) => {
58
- settledSources += 1;
59
- indicator.update(`Collected ${settledSources} source${settledSources === 1 ? '' : 's'} · ${settlement.source.replaceAll('_', ' ')} ${settlement.state}`);
60
- eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
61
- },
60
+ let documentGeneratedAt = '';
61
+ const buildDocument = (result) => {
62
+ documentGeneratedAt = context.now();
63
+ return buildCliLookupDocument(query, classified, result, documentGeneratedAt, args.deep ? 'deep' : 'fast', {
64
+ ...(args.observerLabel ? { observerLabel: args.observerLabel } : {}),
65
+ ...(args.vantageLabel ? { vantageLabel: args.vantageLabel } : {}),
66
+ });
67
+ };
68
+ let document;
69
+ if (args.browse === true) {
70
+ const browse = dependencies.browseLookupOperation || browseLookupOperation;
71
+ document = await browse({
72
+ input: browserInput,
73
+ output: context.stdout,
74
+ environment: browserEnvironment,
75
+ color: args.color,
76
+ ...(args.palette ? { palette: args.palette } : {}),
77
+ query,
78
+ mode: args.deep ? 'deep' : 'fast',
79
+ plannedSources: args.deep ? plannedLookupProgressSources(classified) : [],
80
+ ...(dependencies.signal ? { signal: dependencies.signal } : {}),
81
+ collect: async ({ signal, onSourceSettled }) => {
82
+ const result = await abortable(() => executeLookup(classified, args.deep
83
+ ? {
84
+ fast: false,
85
+ compact: false,
86
+ signal,
87
+ onSourceSettled: (settlement) => {
88
+ onSourceSettled(settlement);
89
+ eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
90
+ },
91
+ }
92
+ : {
93
+ fast: true,
94
+ compact: false,
95
+ signal,
96
+ }), signal);
97
+ return buildDocument(result);
98
+ },
99
+ });
100
+ if (args.saveLookup) {
101
+ if (dependencies.signal?.aborted) {
102
+ throw dependencies.signal.reason || new DOMException('Aborted', 'AbortError');
62
103
  }
63
- : {
64
- fast: true,
65
- compact: false,
66
- ...(dependencies.signal ? { signal: dependencies.signal } : {}),
67
- }), dependencies.signal);
104
+ const save = dependencies.writePrivateFile || writePrivateFile;
105
+ await save(args.saveLookup, serializeCliLookupDocument(document), {
106
+ existingFileMessage: 'Saved Lookup file already exists. Choose a new --save-lookup path or remove the existing file explicitly.',
107
+ });
108
+ context.writeStderr('Saved the completed private Lookup JSON. It can contain raw public registry and WHOIS responses plus target and collection context; review it before sharing.\n');
109
+ }
68
110
  }
69
- finally {
70
- context.endProgress();
111
+ else {
112
+ const indicator = context.beginProgress(args.deep
113
+ ? 'Collecting deep Lookup evidence'
114
+ : 'Collecting registration evidence');
115
+ let settledSources = 0;
116
+ let result;
117
+ try {
118
+ result = await abortable(() => executeLookup(classified, args.deep
119
+ ? {
120
+ fast: false,
121
+ compact: false,
122
+ ...(dependencies.signal ? { signal: dependencies.signal } : {}),
123
+ onSourceSettled: (settlement) => {
124
+ settledSources += 1;
125
+ indicator.update(`Collected ${settledSources} source${settledSources === 1 ? '' : 's'} · ${settlement.source.replaceAll('_', ' ')} ${settlement.state}`);
126
+ eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
127
+ },
128
+ }
129
+ : {
130
+ fast: true,
131
+ compact: false,
132
+ ...(dependencies.signal ? { signal: dependencies.signal } : {}),
133
+ }), dependencies.signal);
134
+ }
135
+ finally {
136
+ context.endProgress();
137
+ }
138
+ document = buildDocument(result);
71
139
  }
72
- const now = context.now();
73
- const document = buildCliLookupDocument(query, classified, result, now, args.deep ? 'deep' : 'fast', {
74
- ...(args.observerLabel ? { observerLabel: args.observerLabel } : {}),
75
- ...(args.vantageLabel ? { vantageLabel: args.vantageLabel } : {}),
76
- });
77
140
  if (!args.quiet) {
78
141
  if (args.output === 'json')
79
- context.writeStdout(formatJsonDocument(document));
142
+ context.writeStdout(serializeCliLookupDocument(document));
80
143
  else if (args.output === 'junit')
81
144
  context.writeStdout(formatCliJunit(document));
82
145
  else if (args.output === 'markdown' || args.output === 'html') {
83
146
  const loadEvidence = dependencies.loadEvidenceExport || (() => import("../lib/evidence-export.mjs"));
84
147
  const evidenceModule = await loadEvidence();
85
- const report = buildCliEvidenceExport(JSON.stringify(document), evidenceModule, now);
148
+ const report = buildCliEvidenceExport(JSON.stringify(document), evidenceModule, documentGeneratedAt);
86
149
  context.writeStdout(args.output === 'markdown'
87
150
  ? formatLookupEvidenceMarkdown(report, { includeAttribution: args.includeAttribution })
88
151
  : formatLookupEvidenceHtml(report, { includeAttribution: args.includeAttribution }));
89
152
  }
90
- else {
153
+ else if (args.browse !== true) {
91
154
  context.writeStdout(context.terminal(formatTerminalLookup(document, { detail: args.detail }), args.color));
92
155
  }
93
156
  }
@@ -1,7 +1,7 @@
1
1
  import { buildBulkComparisonEvidence } from "../lib/bulk-comparison-evidence.mjs";
2
2
  import { recordOrEmpty } from "../lib/bounded-contract-normalizers.mjs";
3
- import { buildBulkDomainComparison, } from "../frontend/src/lib/analysis/bulk-domain-comparison.js";
4
- import { relationshipObservation } from "../frontend/src/lib/analysis/relationship-evidence.js";
3
+ import { buildBulkDomainComparison, } from "../packages/investigation/bulk-domain-comparison.mjs";
4
+ import { relationshipObservation } from "../packages/comparison/relationship-evidence.mjs";
5
5
  import { CliUsageError } from "./errors.mjs";
6
6
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
7
7
  export const CLI_LOOKUP_DIFF_SCHEMA = 'whoisleuth.cli.lookup-diff';
@@ -84,6 +84,7 @@ function formatCliLookupPlan(plan) {
84
84
  `Type: ${plan.target.type}`,
85
85
  `Mode: ${plan.mode}`,
86
86
  'Network requests made: no',
87
+ 'Collection requires network: yes',
87
88
  '',
88
89
  'Planned collection:',
89
90
  ];
@@ -1,45 +1,169 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { getDomain } from 'tldts';
3
- import { analyzeTlsaEvidence } from "../lib/tlsa-evidence.mjs";
3
+ import { scanBoundedJson } from "../lib/bounded-json.mjs";
4
+ import { isValidAsciiDomainName } from "../lib/hostname.mjs";
4
5
  import { CliUsageError } from "./errors.mjs";
5
6
  export const CLI_MAIL_REVIEW_SCHEMA = 'whoisleuth.cli.mail-review';
6
- export const CLI_MAIL_REVIEW_VERSION = 2;
7
+ export const CLI_MAIL_REVIEW_VERSION = 3;
7
8
  export const MAX_MAIL_REVIEW_INPUT_BYTES = 16 * 1024 * 1024;
8
9
  export const MAX_MAIL_REVIEW_ROWS = 500;
10
+ const UNSAFE_TEXT_RE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f]/u;
11
+ const UNSAFE_TEXT_GLOBAL_RE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f]+/gu;
9
12
  function record(value) {
10
13
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
11
14
  }
15
+ function isRecord(value) {
16
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
17
+ }
12
18
  function text(value, maximum = 500) {
13
19
  return typeof value === 'string'
14
- ? value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
20
+ ? value.replace(UNSAFE_TEXT_GLOBAL_RE, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
15
21
  : '';
16
22
  }
17
23
  function boolean(value) {
18
24
  return typeof value === 'boolean' ? value : null;
19
25
  }
20
- function mxHosts(availability, summary) {
21
- const direct = Array.isArray(availability.mxHosts) ? availability.mxHosts : [];
22
- const summarized = Array.isArray(summary.mx) ? summary.mx : [];
23
- const output = new Set();
24
- for (const candidate of [...direct, ...summarized].slice(0, 200)) {
25
- const normalized = text(candidate, 300).replace(/^\d+\s+/u, '').replace(/\.$/u, '').toLowerCase();
26
- if (normalized && normalized.length <= 253)
27
- output.add(normalized);
28
- if (output.size >= 50)
29
- break;
26
+ function timestamp(value, label) {
27
+ if (typeof value !== 'string' || value.length > 64 || !Number.isFinite(Date.parse(value))) {
28
+ throw new CliUsageError(`${label} must be a valid bounded timestamp.`);
30
29
  }
31
- return [...output];
30
+ return new Date(value).toISOString();
32
31
  }
33
- function providerDomains(hosts) {
32
+ function booleanOrNull(value, label) {
33
+ if (value === null || typeof value === 'boolean')
34
+ return value;
35
+ throw new CliUsageError(`${label} must be true, false, or null.`);
36
+ }
37
+ const DNS_STATES = new Set([
38
+ 'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped',
39
+ 'disabled', 'not_applicable', 'unavailable', 'rate_limited',
40
+ ]);
41
+ function parseMxValues(value, label) {
42
+ if (!Array.isArray(value) || value.length > 100) {
43
+ throw new CliUsageError(`${label} must be a bounded MX string array.`);
44
+ }
45
+ const hosts = new Set();
46
+ let nullMx = false;
47
+ for (const [index, candidate] of value.entries()) {
48
+ const itemLabel = `${label} item ${index + 1}`;
49
+ if (typeof candidate !== 'string' || candidate.length < 1 || candidate.length > 300
50
+ || candidate.trim() !== candidate || UNSAFE_TEXT_RE.test(candidate)) {
51
+ throw new CliUsageError(`${itemLabel} is not a safe canonical MX value.`);
52
+ }
53
+ const match = /^(?:(\d{1,5}) )?([^\s]+)$/u.exec(candidate);
54
+ if (!match || (match[1] !== undefined && Number(match[1]) > 65_535)) {
55
+ throw new CliUsageError(`${itemLabel} is not a valid MX priority and hostname.`);
56
+ }
57
+ const exchange = match[2];
58
+ if (!exchange)
59
+ throw new CliUsageError(`${itemLabel} is missing its MX hostname.`);
60
+ if (exchange === '.') {
61
+ nullMx = true;
62
+ continue;
63
+ }
64
+ const hostname = exchange.replace(/\.$/u, '').toLowerCase();
65
+ if (!isValidAsciiDomainName(hostname, { requireDot: true, requireLowercase: true })) {
66
+ throw new CliUsageError(`${itemLabel} is not a valid ASCII MX hostname.`);
67
+ }
68
+ hosts.add(hostname);
69
+ }
70
+ if (nullMx && hosts.size)
71
+ throw new CliUsageError(`${label} mixes a null MX marker with mail hosts.`);
72
+ return Object.freeze({ hosts: Object.freeze([...hosts].sort()), nullMx });
73
+ }
74
+ function sameStrings(left, right) {
75
+ return left.length === right.length && left.every((value, index) => value === right[index]);
76
+ }
77
+ function validateMxEvidence(availability, summary, label, hasMx, hasNullMx) {
78
+ const direct = parseMxValues(availability.mxHosts, `${label} availability MX hosts`);
79
+ const summarized = parseMxValues(summary.mx, `${label} DNS summary MX hosts`);
80
+ if (!sameStrings(direct.hosts, summarized.hosts)) {
81
+ throw new CliUsageError(`${label} has inconsistent availability and DNS summary MX hosts.`);
82
+ }
83
+ if ((direct.nullMx || summarized.nullMx) && hasNullMx !== true) {
84
+ throw new CliUsageError(`${label} has a null MX marker without matching null-MX evidence.`);
85
+ }
86
+ if (hasNullMx === true && (hasMx !== false || summarized.hosts.length > 0)) {
87
+ throw new CliUsageError(`${label} has contradictory null-MX and mail-host evidence.`);
88
+ }
89
+ return summarized.hosts;
90
+ }
91
+ function validateMailRow(value, index, expectedVersion) {
92
+ const item = record(value);
93
+ const label = `Mail review row ${index + 1}`;
94
+ const version = Number(item.version);
95
+ if (item.schema !== 'whoisleuth.cli.bulk.item' || ![2, 3].includes(version)
96
+ || (expectedVersion !== null && version !== expectedVersion)) {
97
+ throw new CliUsageError(`${label} must use the matching WHOISleuth Bulk item schema version 2 or 3.`);
98
+ }
99
+ timestamp(item.generatedAt, `${label} generation time`);
100
+ if (version === 3
101
+ && (!(item.observedAt === null || (typeof item.observedAt === 'string' && timestamp(item.observedAt, `${label} observation time`)))
102
+ || !['current_run', 'resumed_checkpoint'].includes(String(item.collectionOrigin)))) {
103
+ throw new CliUsageError(`${label} has invalid version 3 collection provenance.`);
104
+ }
105
+ if (item.ok === false) {
106
+ if (typeof item.query !== 'string' || !item.query || item.query.length > 2_048
107
+ || typeof item.error !== 'string' || !item.error || item.error.length > 500) {
108
+ throw new CliUsageError(`${label} contains an invalid failed result.`);
109
+ }
110
+ return item;
111
+ }
112
+ const availability = record(item.availability);
113
+ const diagnostics = record(item.diagnostics);
114
+ const summary = record(item.dnsSummary);
115
+ const domain = item.registrableDomain;
116
+ const inputHostname = item.inputHostname;
117
+ if (item.ok !== true || item.type !== 'domain' || typeof item.query !== 'string' || !item.query
118
+ || typeof domain !== 'string' || !isValidAsciiDomainName(domain, { requireDot: true, requireLowercase: true })
119
+ || typeof inputHostname !== 'string' || !isValidAsciiDomainName(inputHostname, { requireDot: true, requireLowercase: true })
120
+ || getDomain(inputHostname, { allowPrivateDomains: true }) !== domain
121
+ || typeof item.isSubdomain !== 'boolean' || !['fast', 'deep'].includes(String(item.mode))
122
+ || !isRecord(item.availability) || !isRecord(item.diagnostics) || !isRecord(item.dnsSummary)) {
123
+ throw new CliUsageError(`${label} does not match a completed domain Bulk result.`);
124
+ }
125
+ const dns = record(availability.dns);
126
+ const dnsStatus = text(summary.status, 40).toLowerCase();
127
+ if (!DNS_STATES.has(dnsStatus) || text(dns.status, 40).toLowerCase() !== dnsStatus) {
128
+ throw new CliUsageError(`${label} has missing or inconsistent DNS source status.`);
129
+ }
130
+ for (const key of ['a', 'aaaa', 'ns', 'mx']) {
131
+ if (!Array.isArray(summary[key]) || summary[key].length > 100) {
132
+ throw new CliUsageError(`${label} has an invalid bounded DNS ${key} summary.`);
133
+ }
134
+ }
135
+ const summaryNullMx = booleanOrNull(summary.hasNullMx, `${label} null-MX summary`);
136
+ const summarySpf = booleanOrNull(summary.hasSpf, `${label} SPF summary`);
137
+ const summaryDmarc = booleanOrNull(summary.hasDmarc, `${label} DMARC summary`);
138
+ const availabilityMx = booleanOrNull(availability.hasMx, `${label} MX state`);
139
+ const availabilityNullMx = booleanOrNull(availability.hasNullMx, `${label} null-MX state`);
140
+ const availabilitySpf = booleanOrNull(availability.hasSpf, `${label} SPF state`);
141
+ const availabilityDmarc = booleanOrNull(availability.hasDmarc, `${label} DMARC state`);
142
+ if (availabilityNullMx !== summaryNullMx || availabilitySpf !== summarySpf || availabilityDmarc !== summaryDmarc) {
143
+ throw new CliUsageError(`${label} has contradictory DNS and availability mail states.`);
144
+ }
145
+ const summarizedMx = validateMxEvidence(availability, summary, label, availabilityMx, availabilityNullMx);
146
+ if (availabilityMx !== (summarizedMx.length ? true : availabilityNullMx === null ? null : false)) {
147
+ throw new CliUsageError(`${label} has contradictory MX evidence.`);
148
+ }
149
+ return {
150
+ ...item,
151
+ availability: { ...availability, mxHosts: summarizedMx },
152
+ dnsSummary: { ...summary, mx: summarizedMx },
153
+ };
154
+ }
155
+ function allProviderDomains(hosts) {
34
156
  return [...new Set(hosts.flatMap((host) => {
35
- const labels = host.split('.').filter(Boolean);
36
- const domain = getDomain(host, { allowPrivateDomains: true })
37
- ?? (labels.length >= 2 ? labels.slice(-2).join('.') : null);
157
+ const domain = getDomain(host, { allowPrivateDomains: true });
38
158
  return domain ? [domain.toLowerCase()] : [];
39
- }))].sort().slice(0, 20);
159
+ }))].sort();
160
+ }
161
+ function providerDomains(hosts) {
162
+ const domains = allProviderDomains(hosts);
163
+ return { domains: domains.slice(0, 20), omitted: Math.max(0, domains.length - 20) };
40
164
  }
41
165
  function mailState(input) {
42
- if (!['success', 'partial'].includes(input.dnsStatus))
166
+ if (input.dnsStatus !== 'success')
43
167
  return 'evidence_incomplete';
44
168
  if (input.hasNullMx === true)
45
169
  return 'null_mx';
@@ -54,50 +178,23 @@ function mailState(input) {
54
178
  return 'no_explicit_mx';
55
179
  return 'evidence_incomplete';
56
180
  }
57
- function alignMailDaneEvidence(report, hosts) {
58
- const aligned = report.service?.transport === 'tcp'
59
- && report.service.port === 25
60
- && hosts.includes(report.service.hostname);
61
- if (aligned || report.state === 'invalid')
62
- return report;
63
- return Object.freeze({
64
- ...report,
65
- state: 'invalid',
66
- limitations: Object.freeze([
67
- ...report.limitations,
68
- 'The TLSA service name did not identify port 25 on an MX hostname retained for this domain.',
69
- ]),
70
- });
71
- }
72
181
  function normalizeMailRow(value) {
73
182
  const item = record(value);
74
183
  if (item.ok !== true)
75
184
  return null;
76
185
  const availability = record(item.availability);
77
186
  const summary = record(item.dnsSummary);
78
- const domain = text(item.registrableDomain ?? item.query ?? availability.domain, 253).toLowerCase().replace(/\.$/u, '');
79
- if (!domain || !domain.includes('.'))
80
- return null;
81
- const dns = record(availability.dns);
82
- const dnsStatus = text(summary.status ?? dns.status, 40).toLowerCase() || 'unavailable';
83
- const hosts = mxHosts(availability, summary);
84
- const hasMx = boolean(availability.hasMx) ?? (hosts.length ? true : null);
85
- const hasNullMx = boolean(availability.hasNullMx) ?? boolean(summary.hasNullMx);
86
- const hasSpf = boolean(availability.hasSpf) ?? boolean(summary.hasSpf);
87
- const hasDmarc = boolean(availability.hasDmarc) ?? boolean(summary.hasDmarc);
187
+ const domain = text(item.registrableDomain, 253).toLowerCase().replace(/\.$/u, '');
188
+ const dnsStatus = text(summary.status, 40).toLowerCase();
189
+ const hosts = Array.isArray(availability.mxHosts)
190
+ ? availability.mxHosts.filter((value) => typeof value === 'string')
191
+ : [];
192
+ const hasMx = boolean(availability.hasMx);
193
+ const hasNullMx = boolean(availability.hasNullMx);
194
+ const hasSpf = boolean(availability.hasSpf);
195
+ const hasDmarc = boolean(availability.hasDmarc);
88
196
  const state = mailState({ dnsStatus, hasMx, hasNullMx, hasSpf, hasDmarc });
89
- const tlsaInput = record(item.tlsaEvidence);
90
- const dane = Object.keys(tlsaInput).length
91
- ? alignMailDaneEvidence(analyzeTlsaEvidence({
92
- serviceName: tlsaInput.serviceName,
93
- dnssecState: tlsaInput.dnssecState,
94
- pkixValidationState: tlsaInput.pkixValidationState,
95
- records: tlsaInput.records,
96
- certificateDerBase64: tlsaInput.certificateDerBase64,
97
- spkiDerBase64: tlsaInput.spkiDerBase64,
98
- authorityMaterials: tlsaInput.authorityMaterials,
99
- }), hosts)
100
- : null;
197
+ const providers = providerDomains(hosts);
101
198
  return {
102
199
  domain,
103
200
  state,
@@ -107,12 +204,12 @@ function normalizeMailRow(value) {
107
204
  hasSpf,
108
205
  hasDmarc,
109
206
  mxHosts: hosts,
110
- providerDomains: providerDomains(hosts),
111
- dane,
207
+ providerDomains: providers.domains,
208
+ providerDomainsOmitted: providers.omitted,
209
+ providerDomainsTruncated: providers.omitted > 0,
112
210
  limitations: [
113
211
  ...(dnsStatus === 'partial' ? ['DNS evidence was partial.'] : []),
114
212
  ...(state === 'no_explicit_mx' ? ['No explicit MX is not equivalent to a null MX or proof that delivery is impossible.'] : []),
115
- ...(dane?.state === 'matched' ? [] : dane ? ['Supplied TLSA evidence did not establish one complete DNSSEC-validated match.'] : ['No TLSA evidence was supplied for offline DANE comparison.']),
116
213
  'Mail configuration does not establish use, control, intent, safety, or maliciousness.',
117
214
  ],
118
215
  };
@@ -125,11 +222,15 @@ function parseInput(textValue) {
125
222
  if (!textInput)
126
223
  throw new CliUsageError('Mail review requires one Bulk JSON or JSONL document.');
127
224
  let values;
225
+ let containerVersion = null;
128
226
  try {
227
+ scanBoundedJson(textInput);
129
228
  const parsed = JSON.parse(textInput);
130
229
  const document = record(parsed);
131
230
  if (document.schema === 'whoisleuth.cli.bulk' && [2, 3].includes(Number(document.version)) && Array.isArray(document.results)) {
132
231
  values = document.results;
232
+ containerVersion = Number(document.version);
233
+ timestamp(document.generatedAt, 'Mail review Bulk generation time');
133
234
  }
134
235
  else if (document.schema === 'whoisleuth.cli.bulk.item' && [2, 3].includes(Number(document.version))) {
135
236
  values = [document];
@@ -142,7 +243,10 @@ function parseInput(textValue) {
142
243
  if (error instanceof CliUsageError)
143
244
  throw error;
144
245
  try {
145
- values = textInput.split(/\r?\n/u).filter(Boolean).map((line) => JSON.parse(line));
246
+ values = textInput.split(/\r?\n/u).filter(Boolean).map((line) => {
247
+ scanBoundedJson(line);
248
+ return JSON.parse(line);
249
+ });
146
250
  }
147
251
  catch {
148
252
  throw new CliUsageError('Mail review input must be valid WHOISleuth Bulk JSON or JSONL.');
@@ -151,11 +255,7 @@ function parseInput(textValue) {
151
255
  if (!values.length || values.length > MAX_MAIL_REVIEW_ROWS) {
152
256
  throw new CliUsageError(`Mail review supports between 1 and ${MAX_MAIL_REVIEW_ROWS} Bulk rows.`);
153
257
  }
154
- const rows = values.map((value) => record(value));
155
- if (rows.some((value) => value.schema !== 'whoisleuth.cli.bulk.item' || ![2, 3].includes(Number(value.version)))) {
156
- throw new CliUsageError('Every Mail review row must use WHOISleuth Bulk item schema version 2 or 3.');
157
- }
158
- return rows;
258
+ return values.map((value, index) => validateMailRow(value, index, containerVersion));
159
259
  }
160
260
  function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
161
261
  const inputRows = parseInput(textValue);
@@ -172,48 +272,59 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
172
272
  };
173
273
  for (const row of rows)
174
274
  counts[row.state] += 1;
175
- const daneCounts = {
176
- matched: 0,
177
- different: 0,
178
- partial: 0,
179
- untrusted: 0,
180
- unavailable: 0,
181
- invalid: 0,
182
- not_supplied: 0,
183
- };
184
- for (const row of rows)
185
- daneCounts[row.dane?.state ?? 'not_supplied'] += 1;
186
275
  const providerMap = new Map();
187
276
  for (const row of rows) {
188
- for (const provider of row.providerDomains) {
277
+ // Row presentation is capped at 20 providers, but correlation operates on
278
+ // the complete already-validated MX set (itself bounded to 100 entries).
279
+ for (const provider of allProviderDomains(row.mxHosts)) {
189
280
  const domains = providerMap.get(provider) ?? [];
190
281
  domains.push(row.domain);
191
282
  providerMap.set(provider, domains);
192
283
  }
193
284
  }
194
- const providerRelationships = [...providerMap.entries()]
285
+ const allProviderRelationships = [...providerMap.entries()]
195
286
  .filter(([, domains]) => new Set(domains).size >= 2)
196
- .map(([providerDomain, domains]) => ({
197
- providerDomain,
198
- domains: [...new Set(domains)].sort().slice(0, 100),
199
- method: 'Shared registrable domain of an observed MX hostname',
200
- limitation: 'Shared mail providers are common and do not establish common ownership, control, intent, safety, or maliciousness.',
201
- }))
202
- .sort((left, right) => left.providerDomain.localeCompare(right.providerDomain))
203
- .slice(0, 100);
287
+ .map(([providerDomain, domains]) => {
288
+ const uniqueDomains = [...new Set(domains)].sort();
289
+ const omittedDomains = Math.max(0, uniqueDomains.length - 100);
290
+ return {
291
+ providerDomain,
292
+ domains: uniqueDomains.slice(0, 100),
293
+ domainCount: uniqueDomains.length,
294
+ domainsTruncated: omittedDomains > 0,
295
+ omittedDomains,
296
+ method: 'Shared registrable domain of an observed MX hostname',
297
+ limitation: 'Shared mail providers are common and do not establish common ownership, control, intent, safety, or maliciousness.',
298
+ };
299
+ })
300
+ .sort((left, right) => left.providerDomain.localeCompare(right.providerDomain));
301
+ const providerRelationships = allProviderRelationships.slice(0, 100);
302
+ const providerCoverage = {
303
+ complete: rows.every((row) => !row.providerDomainsTruncated)
304
+ && allProviderRelationships.length <= 100
305
+ && allProviderRelationships.every((relationship) => !relationship.domainsTruncated),
306
+ rowsWithOmittedProviders: rows.filter((row) => row.providerDomainsTruncated).length,
307
+ omittedProviderDomains: rows.reduce((sum, row) => sum + row.providerDomainsOmitted, 0),
308
+ relationshipCount: allProviderRelationships.length,
309
+ retainedRelationshipCount: providerRelationships.length,
310
+ omittedRelationships: Math.max(0, allProviderRelationships.length - 100),
311
+ omittedRelationshipDomains: allProviderRelationships.reduce((sum, relationship) => sum + relationship.omittedDomains, 0),
312
+ };
204
313
  return {
205
314
  schema: CLI_MAIL_REVIEW_SCHEMA,
206
315
  version: CLI_MAIL_REVIEW_VERSION,
207
316
  generatedAt,
208
317
  counts,
209
- daneCounts,
210
318
  rows,
211
319
  providerRelationships,
320
+ providerCoverage,
212
321
  limitations: [
213
322
  'This review is passive and uses DNS evidence already retained in a WHOISleuth Bulk result; it makes no network request.',
214
323
  'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
215
- 'Optional TLSA evidence is compared offline. A DANE match requires separately validated DNSSEC evidence from the same observation.',
216
324
  'SMTP delivery, mailbox existence, catch-all behaviour, banner collection, and message acceptance were not tested.',
325
+ ...(!providerCoverage.complete
326
+ ? ['Mail-provider relationship coverage reached a configured row, relationship, or domain bound; omitted relationships remain unknown.']
327
+ : []),
217
328
  ],
218
329
  };
219
330
  }
@@ -226,24 +337,25 @@ function formatCliMailReview(document) {
226
337
  `Null MX ${document.counts.null_mx}`,
227
338
  `No explicit MX ${document.counts.no_explicit_mx}`,
228
339
  `Incomplete ${document.counts.evidence_incomplete + document.counts.mail_auth_incomplete}`,
229
- `DANE matched ${document.daneCounts.matched}`,
230
340
  '',
231
341
  ];
232
342
  for (const row of document.rows) {
233
343
  lines.push(`${row.domain} ${row.state.replaceAll('_', ' ')}`);
234
- lines.push(` MX providers ${row.providerDomains.join(', ') || 'None observed'}`);
344
+ lines.push(` MX providers ${row.providerDomains.join(', ') || 'None observed'}${row.providerDomainsOmitted ? ` · +${row.providerDomainsOmitted} omitted` : ''}`);
235
345
  lines.push(` SPF / DMARC ${row.hasSpf === null ? 'unknown' : row.hasSpf ? 'observed' : 'not observed'} / ${row.hasDmarc === null ? 'unknown' : row.hasDmarc ? 'observed' : 'not observed'}`);
236
- lines.push(` DANE / TLSA ${row.dane?.state ?? 'not supplied'}`);
237
346
  }
238
347
  if (document.providerRelationships.length) {
239
348
  lines.push('', 'Shared mail-provider relationships');
240
349
  for (const relationship of document.providerRelationships) {
241
- lines.push(`${relationship.providerDomain} ${relationship.domains.join(', ')}`);
350
+ lines.push(`${relationship.providerDomain} ${relationship.domains.join(', ')}${relationship.omittedDomains ? ` · +${relationship.omittedDomains} omitted` : ''}`);
242
351
  }
243
352
  }
353
+ if (!document.providerCoverage.complete) {
354
+ lines.push('', `Provider coverage partial ${document.providerCoverage.omittedProviderDomains} row providers · ${document.providerCoverage.omittedRelationships} relationships · ${document.providerCoverage.omittedRelationshipDomains} relationship domains omitted`);
355
+ }
244
356
  lines.push('', 'Limitations:');
245
357
  for (const limitation of document.limitations)
246
358
  lines.push(` - ${limitation}`);
247
359
  return `${lines.join('\n')}\n`;
248
360
  }
249
- export { alignMailDaneEvidence, buildCliMailReview, formatCliMailReview, mailState, normalizeMailRow };
361
+ export { buildCliMailReview, formatCliMailReview, mailState, normalizeMailRow };
package/cli/manual.mjs CHANGED
@@ -12,20 +12,20 @@ function buildCliManual(options) {
12
12
  }).join('\n');
13
13
  return `.TH WHOISLEUTH 1 "" "WHOISleuth ${roffText(options.version)}" "User Commands"
14
14
  .SH NAME
15
- whoisleuth \- source-aware domain investigation from the terminal
15
+ whoisleuth \- domain investigation from the terminal
16
16
  .SH SYNOPSIS
17
17
  .B whoisleuth
18
- command [options]
18
+ [command|target] [options]
19
19
  .SH DESCRIPTION
20
- WHOISleuth performs bounded WHOIS, RDAP, DNS, HTTP, TLS, certificate-transparency, posture, lookalike, and offline evidence operations. Evidence sources remain separately attributed, and missing or failed collection is not converted into a claim of absence or safety.
20
+ WHOISleuth performs WHOIS, RDAP, DNS, HTTP, TLS, certificate-transparency, posture, lookalike and offline evidence operations with explicit source and collection states. With no arguments, an eligible interactive terminal opens the Lookup and command launcher; unsupported or redirected terminals print help.
21
21
  .SH COMMANDS
22
22
  ${commands}
23
23
  .SH OUTPUT
24
- Human-readable output is the default. Versioned JSON and JSONL are available where documented. Diagnostics and optional progress events are written to standard error. Use --output with an optional --force flag for atomic private file output.
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
27
  .SH PRIVACY
28
- Network commands disclose the target to the directly queried upstream services. Offline commands do not make network requests. Output files are created with private permissions and are never uploaded by the CLI.
28
+ 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
29
  .SH LICENSE
30
30
  AGPL-3.0-only. Copyright 2026 slicedearth.
31
31
  .SH SEE ALSO