@slicedearth/whoisleuth-cli 1.32.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 (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. package/third-party-notices.txt +5571 -0
package/cli/runner.mjs ADDED
@@ -0,0 +1,726 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createReadStream } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { abortable } from "../lib/abort.mjs";
5
+ import { resolvePublicAddresses } from "../lib/safe-fetch.mjs";
6
+ import { whoisQuery } from "../lib/whois-transport.mjs";
7
+ import { REGISTRY_CAPABILITIES_VERSION, registryCapabilityFor } from "../lib/registry-capabilities.mjs";
8
+ import { explainRiskScore, RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD } from "../lib/risk-scoring.mjs";
9
+ import { CLI_COMMANDS, parseCliArguments } from "./arguments.mjs";
10
+ import { runBulkCommand } from "./bulk-command-runner.mjs";
11
+ import { buildShellCompletion } from "./completion.mjs";
12
+ import { buildDoctorReport, formatDoctorReport } from "./doctor.mjs";
13
+ import { MAX_COMPARE_INPUT_BYTES, compareLookupDocument, parseCliLookupDocument, readCompareInputBounded, } from "./compare.mjs";
14
+ import { runDiscoveryCommand } from "./discovery-command-runner.mjs";
15
+ import { runDiscoveryScanCommand } from "./discovery-scan-command-runner.mjs";
16
+ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
17
+ import { evidenceCommandFailureLabel, isEvidenceCommand, runEvidenceCommand, } from "./evidence-command-runner.mjs";
18
+ import { buildCliEvidenceExport, formatCliEvidenceExport } from "./export-evidence.mjs";
19
+ import EXIT_CODES from "./exit-codes.mjs";
20
+ import { formatLookupEvidenceHtml } from "./formatters/html.mjs";
21
+ import { buildCliCompareDocument, formatJsonDocument, } from "./formatters/json.mjs";
22
+ import { formatLookupEvidenceMarkdown } from "./formatters/markdown.mjs";
23
+ import { formatTerminalCompare, formatTerminalRegistrySupport, formatTerminalRiskCalibration, } from "./formatters/terminal.mjs";
24
+ import { buildCliLookupDiff, formatCliLookupDiff } from "./lookup-diff.mjs";
25
+ import { buildCliPageComparison, formatCliPageComparison } from "./page-compare.mjs";
26
+ import { MAX_MAIL_REVIEW_INPUT_BYTES, buildCliMailReview, formatCliMailReview, } from "./mail-review.mjs";
27
+ import { runLookupCommand } from "./lookup-command-runner.mjs";
28
+ import { buildCliManual } from "./manual.mjs";
29
+ import { createBufferedOutput, writePrivateFile } from "./output-file.mjs";
30
+ import { createTerminalProgress } from "./progress.mjs";
31
+ import { buildRegistrySupportDocument } from "./registry-support.mjs";
32
+ import { MAX_OFFLINE_ARTIFACT_BYTES, MAX_OFFLINE_PASSPHRASE_FILE_BYTES, formatOfflineArtifactVerification, verifyOfflineArtifact, } from "./artifact-verify.mjs";
33
+ import { MAX_SOURCE_RELIABILITY_INPUT_BYTES, buildSourceReliabilityReport, formatSourceReliabilityReport, } from "./source-reliability.mjs";
34
+ import { MAX_RISK_CALIBRATION_INPUT_BYTES, buildRiskCalibrationReport, parseRiskCalibrationDataset, readRiskCalibrationInputBounded, } from "./risk-calibration.mjs";
35
+ import { MAX_SAVED_LOOKUP_INPUT_BYTES, readSavedLookupInputBounded } from "./saved-lookup.mjs";
36
+ import { presentTerminalOutput, terminalPresentation, } from "./terminal-presentation.mjs";
37
+ import { runNetworkCommand } from "./network-command-runner.mjs";
38
+ const require = createRequire(import.meta.url);
39
+ const { version: VERSION } = require('../package.json');
40
+ const MAX_STDIN_BYTES = 4096;
41
+ const HELP = `WHOISleuth CLI
42
+ Source-aware domain investigation from your terminal.
43
+
44
+ Quick start:
45
+ whoisleuth lookup example.test
46
+ whoisleuth lookup example.test --deep
47
+ cat domains.txt | whoisleuth bulk --jsonl
48
+
49
+ Investigate:
50
+ lookup Collect one domain, IP, or ASN.
51
+ bulk Triage newline-delimited targets with bounded concurrency.
52
+ http Inspect one homepage request and redirect chain.
53
+ tls Inspect one hostname's current certificate connection.
54
+ posture Review DNS mail and domain-control posture.
55
+
56
+ Discover:
57
+ ct-search Search certificate-transparency observations.
58
+ discover Generate offline lookalike candidates.
59
+ discover-scan Collect a bounded candidate review queue.
60
+ registry-support Explain local registry coverage without a request.
61
+
62
+ Review saved evidence:
63
+ source-report Summarise source reliability without retaining targets.
64
+ compare Compare saved registry publications.
65
+ page-compare Compare saved static page and TLS evidence.
66
+ mail-review Review saved passive mail exposure evidence.
67
+ diff Compare two saved domain observations.
68
+ export Convert a saved lookup into an evidence report.
69
+ inspect-archive Inspect a workspace archive, redacted by default.
70
+ verify-artifact Validate an archive, packet, or manifest offline.
71
+
72
+ Integrity and calibration:
73
+ sign-artifact Sign one reviewed packet or manifest locally.
74
+ verify-signature Verify one signed evidence package locally.
75
+ risk-calibrate Replay reviewed labels without changing the model.
76
+
77
+ Terminal:
78
+ doctor Check the local runtime; network tests require --network.
79
+ completion Print a completion script for bash, zsh, or fish.
80
+ manual Print the generated manual page.
81
+
82
+ Run "whoisleuth <command> --help" for focused usage and an example.
83
+ Use --json or --jsonl where supported for machine-readable stdout.
84
+ Use --output <file> for atomic private file output and --force to replace it.
85
+ Diagnostics are written to stderr. Fast lookup is the default; deep collection
86
+ must be requested explicitly and can disclose a target to additional sources.
87
+
88
+ Copyright 2026 slicedearth. Licensed under AGPL-3.0-only.
89
+ Source and licence: https://github.com/slicedearth/whoisleuth
90
+ `;
91
+ const COMMAND_USAGE = Object.freeze({
92
+ completion: 'whoisleuth completion <bash|zsh|fish>',
93
+ doctor: 'whoisleuth doctor [--network] [--json] [--quiet] [--no-color]',
94
+ manual: 'whoisleuth manual',
95
+ lookup: 'whoisleuth lookup <domain|IP|ASN> [--json|--markdown|--html] [--fast|--deep] [--summary|--verbose] [--strict-exit] [--events] [--quiet] [--no-color]',
96
+ bulk: 'whoisleuth bulk [file] [--json|--jsonl|--csv|--domains] [--registered-only|--inconclusive-only] [--fast|--deep] [--concurrency <1-8>] [--checkpoint <file> [--resume]] [--events]',
97
+ 'ct-search': 'whoisleuth ct-search <keyword> [--json] [--quiet] [--no-color]',
98
+ discover: 'whoisleuth discover <brand|domain> [--tlds <list>] [--preset <name>|--families <ids>] [--keyboard <layout>] [--dictionary <file>] [--snapshot <file>] [--json|--jsonl|--domains]',
99
+ 'discover-scan': 'whoisleuth discover-scan <brand|domain> [--fast|--deep] [--scan-limit <n>] [--chunk-size <n>] [--concurrency <n>] [--resolver <IPs>] [--allowlist <file>] [--checkpoint <file> [--resume]] [--observation-snapshot <file>] [--json|--jsonl|--csv|--domains]',
100
+ posture: 'whoisleuth posture <domain> [--selectors <list>] [--retired-selectors <list>] [--mail-profile <profile>] [--json] [--quiet] [--no-color]',
101
+ http: 'whoisleuth http <domain> [--json] [--quiet] [--no-color]',
102
+ tls: 'whoisleuth tls <hostname> [--json] [--quiet] [--no-color]',
103
+ 'registry-support': 'whoisleuth registry-support <domain|suffix> [--json] [--quiet] [--no-color]',
104
+ 'risk-calibrate': 'whoisleuth risk-calibrate [dataset.json] [--json] [--quiet] [--no-color]',
105
+ 'verify-artifact': 'whoisleuth verify-artifact [artifact.json] [--passphrase-file <file>] [--json] [--quiet] [--no-color]',
106
+ 'inspect-archive': 'whoisleuth inspect-archive [archive.json] [--passphrase-file <file>] [--search <value>] [--require-match] [--reveal] [--json]',
107
+ 'sign-artifact': 'whoisleuth sign-artifact [artifact.json] --private-key-file <file>',
108
+ 'verify-signature': 'whoisleuth verify-signature [package.json] [--public-key-file <file>] [--json] [--quiet] [--no-color]',
109
+ 'source-report': 'whoisleuth source-report [lookup.json] [--json] [--quiet] [--no-color]',
110
+ compare: 'whoisleuth compare [lookup.json] [--json] [--quiet] [--no-color]',
111
+ 'page-compare': 'whoisleuth page-compare <left.json> <right.json> [--json] [--quiet] [--no-color]',
112
+ 'mail-review': 'whoisleuth mail-review [bulk.json|bulk.jsonl] [--json] [--quiet] [--no-color]',
113
+ diff: 'whoisleuth diff <left.json> <right.json> [--json] [--quiet] [--no-color]',
114
+ export: 'whoisleuth export [lookup.json] [--markdown|--html|--compact]',
115
+ });
116
+ const COMMAND_DETAILS = Object.freeze({
117
+ completion: {
118
+ description: 'Print a static shell-completion script for the installed CLI.',
119
+ example: 'whoisleuth completion zsh > ~/.zfunc/_whoisleuth',
120
+ boundary: 'Generation is offline and writes only the script to stdout. The command never modifies shell configuration.',
121
+ },
122
+ doctor: {
123
+ description: 'Check the supported runtime and local terminal capabilities.',
124
+ example: 'whoisleuth doctor --json',
125
+ boundary: 'The default check is offline. Public DNS and port 43 checks run only when --network is explicitly supplied.',
126
+ },
127
+ manual: {
128
+ description: 'Print a generated roff manual page for local installation.',
129
+ example: 'whoisleuth manual | man -l -',
130
+ boundary: 'Generation is offline and derives from the same command catalogue as focused help.',
131
+ },
132
+ lookup: {
133
+ description: 'Collect registration evidence for one domain, IP, or ASN.',
134
+ example: 'whoisleuth lookup example.test --deep',
135
+ boundary: 'Fast is the default. Deep mode adds bounded WHOIS, DNS, HTTP, TLS, technology, posture, and network context where applicable.',
136
+ },
137
+ bulk: {
138
+ description: 'Triage newline-delimited domains, IPs, or ASNs with bounded concurrency.',
139
+ example: 'cat domains.txt | whoisleuth bulk --jsonl',
140
+ boundary: 'Fast and deep jobs use separate concurrency ceilings. Filters affect output only; collection failures and inconclusive authority states remain explicit in JSON, JSONL, and CSV.',
141
+ },
142
+ 'ct-search': {
143
+ description: 'Search certificate-transparency observations for one bounded keyword.',
144
+ example: 'whoisleuth ct-search "example brand" --json',
145
+ boundary: 'Certificate observations do not prove website activity, registration ownership, or malicious intent.',
146
+ },
147
+ discover: {
148
+ description: 'Generate bounded lookalike-domain candidates from local mutation rules.',
149
+ example: 'whoisleuth discover example.test --preset common --jsonl',
150
+ boundary: 'Generation and optional local snapshot comparison are offline. Candidates are leads only and are not resolved, registered, or classified as malicious.',
151
+ },
152
+ 'discover-scan': {
153
+ description: 'Generate a bounded candidate set, collect a selected subset, and produce a supervised review queue.',
154
+ example: 'whoisleuth discover-scan example.test --scan-limit 50 --checkpoint scan.json --json',
155
+ boundary: 'This command performs network collection. Fast compact lookup is the default; deep mode is capped at 50 candidates. Allowlisting changes review priority only and shared infrastructure remains a lead, not attribution.',
156
+ },
157
+ posture: {
158
+ description: 'Review bounded DNS mail, delegation, and domain-control posture.',
159
+ example: 'whoisleuth posture example.test --mail-profile standard --json',
160
+ boundary: 'Missing or failed DNS observations remain inconclusive and are not reported as absent controls.',
161
+ },
162
+ http: {
163
+ description: 'Inspect one homepage request, redirects, and bounded response metadata.',
164
+ example: 'whoisleuth http example.test --json',
165
+ boundary: 'Requests use the shared public-address and redirect guards. This is not a rendered browser or vulnerability scan.',
166
+ },
167
+ tls: {
168
+ description: 'Inspect one hostname certificate through a bounded TLS connection.',
169
+ example: 'whoisleuth tls example.test --json',
170
+ boundary: 'One observed connection is point-in-time evidence and does not establish every address, edge, or historical certificate.',
171
+ },
172
+ 'registry-support': {
173
+ description: 'Explain the local registry capability profile for one domain or suffix.',
174
+ example: 'whoisleuth registry-support example.test --json',
175
+ boundary: 'This command is offline. Catalogue coverage does not test live reachability or decide registration or availability.',
176
+ },
177
+ 'risk-calibrate': {
178
+ description: 'Replay reviewed labels against the current explainable Risk model.',
179
+ example: 'whoisleuth risk-calibrate calibration.json --json',
180
+ boundary: 'Calibration is offline and diagnostic. It never trains, tunes, or changes the scoring model automatically.',
181
+ },
182
+ 'verify-artifact': {
183
+ description: 'Validate a supported archive, packet, or manifest without printing evidence contents.',
184
+ example: 'whoisleuth verify-artifact workspace.json --json',
185
+ boundary: 'Verification is offline and redacted. Encrypted archives require an explicitly supplied passphrase file.',
186
+ },
187
+ 'inspect-archive': {
188
+ description: 'Summarise or search one workspace archive with redacted output by default.',
189
+ example: 'whoisleuth inspect-archive workspace.json --search example.test --json',
190
+ boundary: 'Exact matches require --reveal. The archive is read locally and is never uploaded.',
191
+ },
192
+ 'sign-artifact': {
193
+ description: 'Sign one reviewed response packet or supported manifest with a local private key.',
194
+ example: 'whoisleuth sign-artifact packet.json --private-key-file analyst-private.pem',
195
+ boundary: 'The command never creates, stores, or transmits keys. Key custody and signer identity remain the operator\'s responsibility.',
196
+ },
197
+ 'verify-signature': {
198
+ description: 'Verify the integrity and signature of one signed evidence package.',
199
+ example: 'whoisleuth verify-signature packet.signed.json --json',
200
+ boundary: 'A valid signature proves package consistency for the embedded key, not the real-world identity or authority of its holder.',
201
+ },
202
+ 'source-report': {
203
+ description: 'Create a target-free reliability summary from a saved lookup.',
204
+ example: 'whoisleuth source-report lookup.json --json',
205
+ boundary: 'The report retains source states and timings but excludes targets, queries, endpoints, and raw evidence.',
206
+ },
207
+ compare: {
208
+ description: 'Compare separately attributed registry publications in a saved lookup.',
209
+ example: 'whoisleuth compare lookup.json --json',
210
+ boundary: 'Comparison is offline. Differences are review context and do not by themselves prove which publication is current.',
211
+ },
212
+ 'page-compare': {
213
+ description: 'Compare static page identity, favicon, technology, and TLS evidence in two saved deep lookups.',
214
+ example: 'whoisleuth page-compare official.json candidate.json --json',
215
+ boundary: 'Comparison is offline and component-based. It executes no page code and produces no aggregate similarity or maliciousness score.',
216
+ },
217
+ 'mail-review': {
218
+ description: 'Review passive MX, null MX, SPF, DMARC, and shared mail-provider evidence from saved Bulk results.',
219
+ example: 'whoisleuth mail-review candidates.json --json',
220
+ boundary: 'Review is offline and sends no SMTP traffic. Missing or partial DNS evidence remains inconclusive.',
221
+ },
222
+ diff: {
223
+ description: 'Compare bounded evidence retained in two saved domain lookups.',
224
+ example: 'whoisleuth diff first.json second.json --json',
225
+ boundary: 'Comparison is offline. Missing, unavailable, equal, and different evidence remain separate states.',
226
+ },
227
+ export: {
228
+ description: 'Convert one saved lookup into a versioned evidence report.',
229
+ example: 'whoisleuth export lookup.json --markdown',
230
+ boundary: 'Exports preserve source attribution and limitations. Compact output intentionally omits raw registry payloads.',
231
+ },
232
+ });
233
+ function commandHelp(command) {
234
+ const detail = COMMAND_DETAILS[command];
235
+ return `WHOISleuth ${command}\n${detail.description}\n\nUsage:\n ${COMMAND_USAGE[command]}\n\nExample:\n ${detail.example}\n\nBoundary:\n ${detail.boundary}\n\nRun "whoisleuth --help" to see the grouped command list.\n`;
236
+ }
237
+ async function readStdinBounded(stream, limit = MAX_STDIN_BYTES) {
238
+ if (!stream || stream.isTTY)
239
+ return '';
240
+ const chunks = [];
241
+ let total = 0;
242
+ for await (const chunk of stream) {
243
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
244
+ total += buffer.length;
245
+ if (total > limit)
246
+ throw new CliUsageError(`Standard input is limited to ${limit} bytes.`);
247
+ chunks.push(buffer);
248
+ }
249
+ const text = Buffer.concat(chunks).toString('utf8').trim();
250
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
251
+ if (lines.length > 1)
252
+ throw new CliUsageError('Single-value commands accept one stdin line. Use the bulk command for multiple inputs.');
253
+ return lines[0] || '';
254
+ }
255
+ function write(stream, value) {
256
+ if (stream && typeof stream.write === 'function')
257
+ stream.write(value);
258
+ }
259
+ function formatForTerminal(value, stream, color, environment) {
260
+ return presentTerminalOutput(value, terminalPresentation(stream, color, environment));
261
+ }
262
+ function isCancellation(error, signal) {
263
+ return signal?.aborted === true
264
+ || Boolean(error && typeof error === 'object' && 'name' in error && error.name === 'AbortError');
265
+ }
266
+ function usageEventReason(error) {
267
+ const message = boundedCliErrorMessage(error, 'Invalid command input').toLowerCase();
268
+ if (message.includes('could not read'))
269
+ return 'input_unavailable';
270
+ if (message.includes('cannot be combined') || message.includes('mutually exclusive'))
271
+ return 'conflicting_options';
272
+ if (message.includes('requires') || message.includes('did not contain'))
273
+ return 'missing_input';
274
+ return 'invalid_input';
275
+ }
276
+ async function runParsedCli(args, dependencies = {}) {
277
+ const stdout = dependencies.stdout || process.stdout;
278
+ const stderr = dependencies.stderr || process.stderr;
279
+ const environment = dependencies.environment || process.env;
280
+ let progress = null;
281
+ const eventProgress = { current: null };
282
+ let failureLabel = 'Lookup';
283
+ try {
284
+ const terminal = (value, color = true) => formatForTerminal(value, stdout, color, environment);
285
+ const beginProgress = (message) => {
286
+ const terminalOutput = 'output' in args
287
+ && args.output === 'terminal'
288
+ && 'quiet' in args
289
+ && 'color' in args;
290
+ const eventOutput = 'events' in args && args.events;
291
+ const enabled = terminalOutput && !args.quiet && !eventOutput;
292
+ progress = createTerminalProgress(stderr, {
293
+ enabled,
294
+ color: terminalOutput ? args.color : false,
295
+ environment,
296
+ ...(dependencies.nowMs ? { now: dependencies.nowMs } : {}),
297
+ });
298
+ progress.start(message);
299
+ return progress;
300
+ };
301
+ const endProgress = () => {
302
+ progress?.stop();
303
+ progress = null;
304
+ };
305
+ const withProgress = async (message, operation) => {
306
+ beginProgress(message);
307
+ try {
308
+ return await abortable(operation, dependencies.signal);
309
+ }
310
+ finally {
311
+ endProgress();
312
+ }
313
+ };
314
+ const readSingleInput = async () => (dependencies.readStdin
315
+ ? await dependencies.readStdin()
316
+ : await readStdinBounded(dependencies.stdin || process.stdin));
317
+ const commandContext = Object.freeze({
318
+ stdout,
319
+ stderr,
320
+ terminal,
321
+ writeStdout: (value) => write(stdout, value),
322
+ writeStderr: (value) => write(stderr, value),
323
+ readSingleInput,
324
+ now: () => dependencies.now ? dependencies.now() : new Date().toISOString(),
325
+ beginProgress,
326
+ endProgress,
327
+ withProgress,
328
+ setEventProgress: (next) => {
329
+ eventProgress.current = next;
330
+ },
331
+ });
332
+ if (args.action === 'help') {
333
+ write(stdout, terminal(args.command ? commandHelp(args.command) : HELP));
334
+ return EXIT_CODES.SUCCESS;
335
+ }
336
+ if (args.action === 'version') {
337
+ write(stdout, `${VERSION}\n`);
338
+ return EXIT_CODES.SUCCESS;
339
+ }
340
+ if (args.action === 'completion') {
341
+ write(stdout, buildShellCompletion(args.shell));
342
+ return EXIT_CODES.SUCCESS;
343
+ }
344
+ if (args.action === 'manual') {
345
+ write(stdout, buildCliManual({ commands: CLI_COMMANDS, details: COMMAND_DETAILS, usage: COMMAND_USAGE, version: VERSION }));
346
+ return EXIT_CODES.SUCCESS;
347
+ }
348
+ if (args.action === 'doctor') {
349
+ failureLabel = 'CLI diagnostics';
350
+ const buildReport = () => buildDoctorReport({
351
+ version: VERSION,
352
+ generatedAt: dependencies.now ? dependencies.now() : new Date().toISOString(),
353
+ network: args.network,
354
+ presentation: terminalPresentation(stdout, args.color, environment),
355
+ ...(dependencies.resolvePublicAddresses ? { resolveAddresses: dependencies.resolvePublicAddresses } : {}),
356
+ ...(dependencies.whoisQuery ? { queryWhois: dependencies.whoisQuery } : {}),
357
+ });
358
+ const report = args.network
359
+ ? await withProgress('Checking public DNS and WHOIS connectivity', buildReport)
360
+ : await buildReport();
361
+ if (!args.quiet) {
362
+ write(stdout, args.output === 'json'
363
+ ? formatJsonDocument(report)
364
+ : terminal(formatDoctorReport(report), args.color));
365
+ }
366
+ return report.state === 'partial' ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
367
+ }
368
+ if (args.action === 'registry-support') {
369
+ failureLabel = 'Registry support';
370
+ const readInput = dependencies.readStdin || (() => readStdinBounded(dependencies.stdin || process.stdin));
371
+ const requestedInput = args.target || await readInput();
372
+ if (!requestedInput)
373
+ throw new CliUsageError('registry-support requires one domain or suffix as an argument or on stdin.');
374
+ const lookupCapability = dependencies.registryCapabilityFor || registryCapabilityFor;
375
+ const capability = lookupCapability(requestedInput);
376
+ if (!capability)
377
+ throw new CliUsageError('registry-support requires a valid domain or suffix.');
378
+ const now = dependencies.now ? dependencies.now() : new Date().toISOString();
379
+ const catalogueVersion = dependencies.registryCapabilitiesVersion || REGISTRY_CAPABILITIES_VERSION;
380
+ const document = buildRegistrySupportDocument(requestedInput, capability, catalogueVersion, now);
381
+ if (!args.quiet)
382
+ write(stdout, args.output === 'json' ? formatJsonDocument(document) : terminal(formatTerminalRegistrySupport(document), args.color));
383
+ return EXIT_CODES.SUCCESS;
384
+ }
385
+ if (args.action === 'risk-calibrate') {
386
+ failureLabel = 'Risk calibration';
387
+ let input;
388
+ try {
389
+ input = dependencies.readRiskCalibrationInput
390
+ ? await dependencies.readRiskCalibrationInput(args.source)
391
+ : await readRiskCalibrationInputBounded(args.source
392
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
393
+ : dependencies.stdin || process.stdin, MAX_RISK_CALIBRATION_INPUT_BYTES);
394
+ }
395
+ catch (error) {
396
+ if (error instanceof CliUsageError)
397
+ throw error;
398
+ throw new CliUsageError(`Could not read Risk calibration input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
399
+ }
400
+ if (!input.trim())
401
+ throw new CliUsageError('risk-calibrate requires one dataset JSON file or a dataset on stdin.');
402
+ const dataset = parseRiskCalibrationDataset(input);
403
+ const report = buildRiskCalibrationReport(dataset, dependencies.explainRiskScore || explainRiskScore, {
404
+ generatedAt: dependencies.now ? dependencies.now() : new Date().toISOString(),
405
+ modelVersion: dependencies.riskModelVersion || RISK_MODEL_VERSION,
406
+ reviewThreshold: dependencies.riskReviewThreshold || RISK_REVIEW_THRESHOLD,
407
+ });
408
+ if (!args.quiet)
409
+ write(stdout, args.output === 'json' ? formatJsonDocument(report) : terminal(formatTerminalRiskCalibration(report), args.color));
410
+ return EXIT_CODES.SUCCESS;
411
+ }
412
+ if (args.action === 'verify-artifact') {
413
+ failureLabel = 'Artifact verification';
414
+ let input;
415
+ try {
416
+ input = dependencies.readArtifactInput
417
+ ? await dependencies.readArtifactInput(args.source)
418
+ : await readSavedLookupInputBounded(args.source
419
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
420
+ : dependencies.stdin || process.stdin, {
421
+ limit: MAX_OFFLINE_ARTIFACT_BYTES,
422
+ label: 'Artifact input',
423
+ });
424
+ }
425
+ catch (error) {
426
+ if (error instanceof CliUsageError)
427
+ throw error;
428
+ throw new CliUsageError(`Could not read artifact input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
429
+ }
430
+ if (!input.trim())
431
+ throw new CliUsageError('verify-artifact requires one JSON file or an artifact on stdin.');
432
+ let passphrase = null;
433
+ if (args.passphraseSource) {
434
+ let passphraseText;
435
+ try {
436
+ passphraseText = dependencies.readPassphraseFile
437
+ ? await dependencies.readPassphraseFile(args.passphraseSource)
438
+ : await readSavedLookupInputBounded(createReadStream(args.passphraseSource, { highWaterMark: MAX_OFFLINE_PASSPHRASE_FILE_BYTES }), {
439
+ limit: MAX_OFFLINE_PASSPHRASE_FILE_BYTES,
440
+ label: 'Passphrase file',
441
+ });
442
+ }
443
+ catch (error) {
444
+ if (error instanceof CliUsageError)
445
+ throw error;
446
+ throw new CliUsageError(`Could not read passphrase file: ${boundedCliErrorMessage(error, 'File could not be read')}`);
447
+ }
448
+ passphrase = passphraseText.replace(/\r?\n$/u, '');
449
+ if (!passphrase || /[\r\n\u0000]/u.test(passphrase)) {
450
+ throw new CliUsageError('Passphrase file must contain exactly one non-empty UTF-8 line.');
451
+ }
452
+ }
453
+ const report = await verifyOfflineArtifact(input, { passphrase });
454
+ if (!args.quiet) {
455
+ write(stdout, args.output === 'json'
456
+ ? formatJsonDocument(report)
457
+ : terminal(formatOfflineArtifactVerification(report), args.color));
458
+ }
459
+ return EXIT_CODES.SUCCESS;
460
+ }
461
+ if (isEvidenceCommand(args)) {
462
+ failureLabel = evidenceCommandFailureLabel(args.action);
463
+ const evidenceStdout = args.action !== 'sign-artifact' && args.output === 'terminal'
464
+ ? { write: (value) => write(stdout, terminal(value, args.color)) }
465
+ : stdout;
466
+ return await runEvidenceCommand(args, {
467
+ stdout: evidenceStdout,
468
+ stdin: dependencies.stdin || process.stdin,
469
+ readArtifactInput: dependencies.readArtifactInput,
470
+ readPassphraseFile: dependencies.readPassphraseFile,
471
+ readPrivateKeyFile: dependencies.readPrivateKeyFile,
472
+ readPublicKeyFile: dependencies.readPublicKeyFile,
473
+ now: dependencies.now,
474
+ });
475
+ }
476
+ if (args.action === 'source-report') {
477
+ failureLabel = 'Source reliability report';
478
+ let input;
479
+ try {
480
+ input = dependencies.readSourceReliabilityInput
481
+ ? await dependencies.readSourceReliabilityInput(args.source)
482
+ : await readSavedLookupInputBounded(args.source
483
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
484
+ : dependencies.stdin || process.stdin, {
485
+ limit: MAX_SOURCE_RELIABILITY_INPUT_BYTES,
486
+ label: 'Source reliability input',
487
+ });
488
+ }
489
+ catch (error) {
490
+ if (error instanceof CliUsageError)
491
+ throw error;
492
+ throw new CliUsageError(`Could not read source reliability input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
493
+ }
494
+ if (!input.trim())
495
+ throw new CliUsageError('source-report requires one JSON file or lookup documents on stdin.');
496
+ const report = buildSourceReliabilityReport(input, dependencies.now ? dependencies.now() : new Date().toISOString());
497
+ if (!args.quiet) {
498
+ write(stdout, args.output === 'json'
499
+ ? formatJsonDocument(report)
500
+ : terminal(formatSourceReliabilityReport(report), args.color));
501
+ }
502
+ return EXIT_CODES.SUCCESS;
503
+ }
504
+ if (args.action === 'compare') {
505
+ failureLabel = 'Registry comparison';
506
+ let input;
507
+ try {
508
+ input = dependencies.readCompareInput
509
+ ? await dependencies.readCompareInput(args.source)
510
+ : await readCompareInputBounded(args.source
511
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
512
+ : dependencies.stdin || process.stdin, MAX_COMPARE_INPUT_BYTES);
513
+ }
514
+ catch (error) {
515
+ if (error instanceof CliUsageError)
516
+ throw error;
517
+ throw new CliUsageError(`Could not read comparison input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
518
+ }
519
+ if (!input.trim())
520
+ throw new CliUsageError('compare requires one lookup JSON file or a lookup document on stdin.');
521
+ const parsed = parseCliLookupDocument(input);
522
+ const loadComparison = dependencies.loadRegistryComparison || (() => import("../lib/registry-comparison.mjs"));
523
+ const comparisonModule = await loadComparison();
524
+ const result = compareLookupDocument(parsed, comparisonModule.compareRegistrySources, comparisonModule.compareRdapPublications);
525
+ const now = dependencies.now ? dependencies.now() : new Date().toISOString();
526
+ const document = buildCliCompareDocument(result, now);
527
+ if (!args.quiet)
528
+ write(stdout, args.output === 'json' ? formatJsonDocument(document) : terminal(formatTerminalCompare(document), args.color));
529
+ return EXIT_CODES.SUCCESS;
530
+ }
531
+ if (args.action === 'page-compare') {
532
+ failureLabel = 'Static page comparison';
533
+ const readDiffInput = dependencies.readDiffInput || (async (source) => (readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), {
534
+ limit: MAX_SAVED_LOOKUP_INPUT_BYTES,
535
+ label: 'Page comparison input',
536
+ })));
537
+ let leftInput;
538
+ let rightInput;
539
+ try {
540
+ [leftInput, rightInput] = await Promise.all([
541
+ readDiffInput(args.leftSource),
542
+ readDiffInput(args.rightSource),
543
+ ]);
544
+ }
545
+ catch (error) {
546
+ if (error instanceof CliUsageError)
547
+ throw error;
548
+ throw new CliUsageError(`Could not read page comparison input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
549
+ }
550
+ const document = buildCliPageComparison(leftInput, rightInput, commandContext.now());
551
+ if (!args.quiet)
552
+ write(stdout, args.output === 'json'
553
+ ? formatJsonDocument(document)
554
+ : terminal(formatCliPageComparison(document), args.color));
555
+ return EXIT_CODES.SUCCESS;
556
+ }
557
+ if (args.action === 'mail-review') {
558
+ failureLabel = 'Passive mail review';
559
+ let input;
560
+ try {
561
+ input = dependencies.readMailReviewInput
562
+ ? await dependencies.readMailReviewInput(args.source)
563
+ : await readSavedLookupInputBounded(args.source
564
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
565
+ : dependencies.stdin || process.stdin, {
566
+ limit: MAX_MAIL_REVIEW_INPUT_BYTES,
567
+ label: 'Mail review input',
568
+ });
569
+ }
570
+ catch (error) {
571
+ if (error instanceof CliUsageError)
572
+ throw error;
573
+ throw new CliUsageError(`Could not read mail review input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
574
+ }
575
+ const document = buildCliMailReview(input, commandContext.now());
576
+ if (!args.quiet)
577
+ write(stdout, args.output === 'json'
578
+ ? formatJsonDocument(document)
579
+ : terminal(formatCliMailReview(document), args.color));
580
+ return EXIT_CODES.SUCCESS;
581
+ }
582
+ if (args.action === 'diff') {
583
+ failureLabel = 'Lookup evidence diff';
584
+ const readDiffInput = dependencies.readDiffInput || (async (source) => (readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), {
585
+ limit: MAX_SAVED_LOOKUP_INPUT_BYTES,
586
+ label: 'Lookup diff input',
587
+ })));
588
+ let leftInput;
589
+ let rightInput;
590
+ try {
591
+ [leftInput, rightInput] = await Promise.all([
592
+ readDiffInput(args.leftSource),
593
+ readDiffInput(args.rightSource),
594
+ ]);
595
+ }
596
+ catch (error) {
597
+ if (error instanceof CliUsageError)
598
+ throw error;
599
+ throw new CliUsageError(`Could not read Lookup diff input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
600
+ }
601
+ const document = buildCliLookupDiff(leftInput, rightInput, dependencies.now ? dependencies.now() : new Date().toISOString());
602
+ if (!args.quiet)
603
+ write(stdout, args.output === 'json'
604
+ ? formatJsonDocument(document)
605
+ : terminal(formatCliLookupDiff(document), args.color));
606
+ return EXIT_CODES.SUCCESS;
607
+ }
608
+ if (args.action === 'export') {
609
+ failureLabel = 'Evidence export';
610
+ let input;
611
+ try {
612
+ input = dependencies.readExportInput
613
+ ? await dependencies.readExportInput(args.source)
614
+ : await readSavedLookupInputBounded(args.source
615
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
616
+ : dependencies.stdin || process.stdin, {
617
+ limit: MAX_SAVED_LOOKUP_INPUT_BYTES,
618
+ label: 'Evidence export input',
619
+ });
620
+ }
621
+ catch (error) {
622
+ if (error instanceof CliUsageError)
623
+ throw error;
624
+ throw new CliUsageError(`Could not read evidence export input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
625
+ }
626
+ if (!input.trim())
627
+ throw new CliUsageError('export requires one lookup JSON file or a lookup document on stdin.');
628
+ const loadEvidence = dependencies.loadEvidenceExport || (() => import("../lib/evidence-export.mjs"));
629
+ const evidenceModule = await loadEvidence();
630
+ const now = dependencies.now ? dependencies.now() : new Date().toISOString();
631
+ const document = buildCliEvidenceExport(input, evidenceModule, now);
632
+ const output = args.format === 'markdown'
633
+ ? formatLookupEvidenceMarkdown(document)
634
+ : args.format === 'html'
635
+ ? formatLookupEvidenceHtml(document)
636
+ : formatCliEvidenceExport(document, args.compact);
637
+ write(stdout, output);
638
+ return EXIT_CODES.SUCCESS;
639
+ }
640
+ if (args.action === 'bulk') {
641
+ failureLabel = 'Bulk lookup';
642
+ return await runBulkCommand(args, dependencies, commandContext);
643
+ }
644
+ if (args.action === 'discover') {
645
+ failureLabel = 'Candidate generation';
646
+ return await runDiscoveryCommand(args, dependencies, commandContext);
647
+ }
648
+ if (args.action === 'discover-scan') {
649
+ failureLabel = 'Candidate scan';
650
+ return await runDiscoveryScanCommand(args, dependencies, commandContext);
651
+ }
652
+ if (args.action === 'ct-search'
653
+ || args.action === 'posture'
654
+ || args.action === 'http'
655
+ || args.action === 'tls') {
656
+ failureLabel = args.action === 'ct-search'
657
+ ? 'Certificate Transparency search'
658
+ : args.action === 'posture'
659
+ ? 'Domain posture audit'
660
+ : args.action === 'http'
661
+ ? 'HTTP probe'
662
+ : 'TLS intelligence';
663
+ return await runNetworkCommand(args, dependencies, commandContext);
664
+ }
665
+ return await runLookupCommand(args, dependencies, commandContext);
666
+ }
667
+ catch (error) {
668
+ progress?.stop();
669
+ progress = null;
670
+ if (isCancellation(error, dependencies.signal)) {
671
+ eventProgress.current?.emit({ event: 'cancelled', exitCode: EXIT_CODES.CANCELLED });
672
+ if (!eventProgress.current?.enabled)
673
+ write(stderr, 'Cancelled by analyst.\n');
674
+ return EXIT_CODES.CANCELLED;
675
+ }
676
+ if (error instanceof CliUsageError) {
677
+ eventProgress.current?.emit({
678
+ event: 'failed',
679
+ state: 'usage',
680
+ reason: usageEventReason(error),
681
+ exitCode: EXIT_CODES.USAGE,
682
+ });
683
+ if (!eventProgress.current?.enabled)
684
+ write(stderr, `Usage error: ${boundedCliErrorMessage(error, 'Invalid command')}\n`);
685
+ return EXIT_CODES.USAGE;
686
+ }
687
+ eventProgress.current?.emit({ event: 'failed', state: 'operational', exitCode: EXIT_CODES.LOOKUP_FAILED });
688
+ if (!eventProgress.current?.enabled)
689
+ write(stderr, `${failureLabel} failed: ${boundedCliErrorMessage(error, 'Unexpected command failure')}\n`);
690
+ return EXIT_CODES.LOOKUP_FAILED;
691
+ }
692
+ }
693
+ async function runCli(argv, dependencies = {}) {
694
+ const stderr = dependencies.stderr || process.stderr;
695
+ let args;
696
+ try {
697
+ args = parseCliArguments(argv);
698
+ }
699
+ catch (error) {
700
+ if (error instanceof CliUsageError) {
701
+ write(stderr, `Usage error: ${boundedCliErrorMessage(error, 'Invalid command')}\n`);
702
+ return EXIT_CODES.USAGE;
703
+ }
704
+ write(stderr, `CLI startup failed: ${boundedCliErrorMessage(error, 'Unexpected command failure')}\n`);
705
+ return EXIT_CODES.INTERNAL_ERROR;
706
+ }
707
+ if (!args.destination)
708
+ return runParsedCli(args, dependencies);
709
+ const buffered = createBufferedOutput();
710
+ const code = await runParsedCli(args, { ...dependencies, stdout: buffered.stream });
711
+ if (code !== EXIT_CODES.SUCCESS && code !== EXIT_CODES.PARTIAL_FAILURE)
712
+ return code;
713
+ try {
714
+ await writePrivateFile(args.destination, buffered.value(), { force: args.force === true });
715
+ return code;
716
+ }
717
+ catch (error) {
718
+ if (error instanceof CliUsageError) {
719
+ write(stderr, `Usage error: ${boundedCliErrorMessage(error, 'Output file could not be written')}\n`);
720
+ return EXIT_CODES.USAGE;
721
+ }
722
+ write(stderr, `Output failed: ${boundedCliErrorMessage(error, 'Output file could not be written')}\n`);
723
+ return EXIT_CODES.LOOKUP_FAILED;
724
+ }
725
+ }
726
+ export { HELP, MAX_STDIN_BYTES, VERSION, readStdinBounded, runCli };