@slicedearth/whoisleuth-cli 1.34.0 → 1.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +19 -2
  2. package/bin/whoisleuth.mjs +41 -24
  3. package/cli/arguments.mjs +326 -3
  4. package/cli/artifact-verify.mjs +60 -14
  5. package/cli/bulk.mjs +1 -2
  6. package/cli/compare.mjs +1 -1
  7. package/cli/completion.mjs +36 -2
  8. package/cli/doctor.mjs +11 -5
  9. package/cli/evidence-command-arguments.mjs +1 -1
  10. package/cli/evidence-command-runner.mjs +2 -2
  11. package/cli/evidence-signing.mjs +4 -4
  12. package/cli/export-evidence.mjs +9 -2
  13. package/cli/formatters/html.mjs +1 -1
  14. package/cli/formatters/json.mjs +13 -1
  15. package/cli/formatters/terminal.mjs +14 -4
  16. package/cli/investigation-plan.mjs +112 -0
  17. package/cli/local-mmdb-review.mjs +76 -0
  18. package/cli/lookalike-calibration.mjs +121 -0
  19. package/cli/lookup-command-runner.mjs +4 -1
  20. package/cli/lookup-plan.mjs +3 -3
  21. package/cli/lookup-reconcile.mjs +141 -0
  22. package/cli/lookup-timeline.mjs +1 -1
  23. package/cli/mail-review.mjs +48 -3
  24. package/cli/offline-evidence-review.mjs +159 -0
  25. package/cli/registry-doctor.mjs +125 -0
  26. package/cli/registry-support.mjs +1 -4
  27. package/cli/risk-calibration.mjs +117 -9
  28. package/cli/runner.mjs +342 -15
  29. package/cli/saved-lookup.mjs +2 -2
  30. package/cli/sharing-review.mjs +202 -0
  31. package/cli/source-reliability.mjs +1 -1
  32. package/docs/cli-reference.md +1110 -0
  33. package/docs/cli.md +224 -847
  34. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  35. package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
  36. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  37. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  38. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  39. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  40. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  41. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  42. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  43. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  44. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  45. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  46. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  47. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  48. package/frontend/src/lib/analysis/http-summary.js +2 -1
  49. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  50. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  51. package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
  52. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  53. package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
  54. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  55. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  56. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  57. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  58. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  59. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  60. package/frontend/src/lib/analysis/scoring.js +8 -88
  61. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  62. package/frontend/src/lib/analysis/utils.js +7 -40
  63. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  64. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  65. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  66. package/frontend/src/lib/analysis/visualization-models.js +370 -0
  67. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  68. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  69. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  70. package/frontend/src/lib/analysis/workspace-archive.js +2 -2
  71. package/lib/analyst-taxonomy.mjs +40 -0
  72. package/lib/availability.mjs +30 -10
  73. package/lib/browser-library-profile.mjs +15 -2
  74. package/lib/bulk-limits.mjs +13 -0
  75. package/lib/certificate-extension-profile.mjs +71 -0
  76. package/lib/credential-surface-profile.mjs +1 -1
  77. package/lib/ct-response-bounds.mjs +9 -0
  78. package/lib/ct-search.mjs +66 -12
  79. package/lib/dns-delegation-health.mjs +56 -5
  80. package/lib/dnssec-evidence-validation.mjs +235 -0
  81. package/lib/domain-assurance.mjs +368 -0
  82. package/lib/domain-control-manifest.mjs +382 -0
  83. package/lib/domain-posture.mjs +11 -10
  84. package/lib/encrypted-dns-contract.mjs +81 -0
  85. package/lib/evidence-export.mjs +17 -3
  86. package/lib/evidence-report-markdown.mjs +1 -1
  87. package/lib/evidence-report.mjs +4 -1
  88. package/lib/favicon.mjs +3 -8
  89. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  90. package/lib/html-signals.mjs +4 -0
  91. package/lib/http-evidence-bounds.mjs +4 -0
  92. package/lib/http-intelligence.mjs +2 -1
  93. package/lib/ip-prefix.mjs +87 -0
  94. package/lib/local-geoip-evidence.mjs +89 -0
  95. package/lib/lookup-response-contract.mjs +346 -0
  96. package/lib/observed-network-context.mjs +1 -1
  97. package/lib/opportunity-scoring.mjs +107 -0
  98. package/lib/outbound-identity.mjs +9 -0
  99. package/lib/outbound-request-bounds.mjs +9 -0
  100. package/lib/page-fingerprints.mjs +1 -1
  101. package/lib/perceptual-hash-comparison.mjs +34 -0
  102. package/lib/perceptual-hash.mjs +3 -31
  103. package/lib/project-metadata.mjs +8 -0
  104. package/lib/provider-policy-admission.mjs +49 -0
  105. package/lib/rdap-capabilities.mjs +6 -6
  106. package/lib/rdap-search-workbench.mjs +247 -0
  107. package/lib/rdap.mjs +7 -2
  108. package/lib/registry-access.mjs +1 -1
  109. package/lib/registry-capabilities.mjs +31 -1
  110. package/lib/registry-capability-catalogue.mjs +15 -14
  111. package/lib/registry-insights.mjs +6 -6
  112. package/lib/response-policy.mjs +81 -6
  113. package/lib/risk-scoring.mjs +206 -83
  114. package/lib/rpki-evidence.mjs +105 -0
  115. package/lib/safe-fetch.mjs +2 -1
  116. package/lib/scoring-evidence-quality.mjs +107 -0
  117. package/lib/security-txt.mjs +3 -3
  118. package/lib/semantic-version.mjs +54 -0
  119. package/lib/static-html-analysis.mjs +27 -0
  120. package/lib/threat-intelligence-manifests.mjs +1 -1
  121. package/lib/threatfox-intelligence.mjs +11 -6
  122. package/lib/tls-intelligence.mjs +13 -3
  123. package/lib/tlsa-evidence.mjs +194 -0
  124. package/lib/urlhaus-intelligence.mjs +11 -6
  125. package/lib/urlscan-intelligence.mjs +10 -5
  126. package/lib/website-security-posture.mjs +16 -9
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-chain.mjs +14 -152
  129. package/lib/whois.mjs +2 -2
  130. package/package.json +6 -2
  131. package/third-party-notices.txt +911 -19
package/cli/runner.mjs CHANGED
@@ -2,18 +2,13 @@ import { Buffer } from 'node:buffer';
2
2
  import { createReadStream } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import { abortable } from "../lib/abort.mjs";
5
- import { resolvePublicAddresses, safeFetch } from "../lib/safe-fetch.mjs";
6
- import { whoisQuery } from "../lib/whois-transport.mjs";
7
5
  import { REGISTRY_CAPABILITIES_VERSION, registryCapabilityFor } from "../lib/registry-capabilities.mjs";
8
- import { explainRiskScore, RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD } from "../lib/risk-scoring.mjs";
6
+ import { explainRiskScore, explainRiskScoreV6, RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD } from "../lib/risk-scoring.mjs";
9
7
  import { CLI_COMMANDS, parseCliArguments } from "./arguments.mjs";
10
- import { runBulkCommand } from "./bulk-command-runner.mjs";
11
8
  import { buildCliCommandCatalogue, formatCliCommandCatalogue } from "./command-catalogue.mjs";
12
9
  import { buildShellCompletion } from "./completion.mjs";
13
10
  import { buildDoctorReport, formatDoctorReport } from "./doctor.mjs";
14
11
  import { MAX_COMPARE_INPUT_BYTES, compareLookupDocument, parseCliLookupDocument, readCompareInputBounded, } from "./compare.mjs";
15
- import { runDiscoveryCommand } from "./discovery-command-runner.mjs";
16
- import { runDiscoveryScanCommand } from "./discovery-scan-command-runner.mjs";
17
12
  import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
18
13
  import { evidenceCommandFailureLabel, isEvidenceCommand, runEvidenceCommand, } from "./evidence-command-runner.mjs";
19
14
  import { buildCliEvidenceExport, formatCliEvidenceExport } from "./export-evidence.mjs";
@@ -23,20 +18,27 @@ import { buildCliCompareDocument, formatJsonDocument, } from "./formatters/json.
23
18
  import { formatLookupEvidenceMarkdown } from "./formatters/markdown.mjs";
24
19
  import { formatTerminalCompare, formatTerminalRegistrySupport, formatTerminalRiskCalibration, } from "./formatters/terminal.mjs";
25
20
  import { buildCliLookupDiff, formatCliLookupDiff } from "./lookup-diff.mjs";
21
+ import { MAX_LOOKUP_RECONCILIATION_INPUT_BYTES, buildCliLookupReconciliation, formatCliLookupReconciliation, } from "./lookup-reconcile.mjs";
22
+ import { MAX_SHARING_REVIEW_BYTES, buildSharingReview, formatSharingReview, } from "./sharing-review.mjs";
26
23
  import { MAX_LOOKUP_TIMELINE_INPUT_BYTES, buildCliLookupTimeline, formatCliLookupTimeline, } from "./lookup-timeline.mjs";
27
24
  import { buildCliPageComparison, formatCliPageComparison } from "./page-compare.mjs";
28
25
  import { MAX_MAIL_REVIEW_INPUT_BYTES, buildCliMailReview, formatCliMailReview, } from "./mail-review.mjs";
29
- import { runLookupCommand } from "./lookup-command-runner.mjs";
26
+ import { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, } from "./offline-evidence-review.mjs";
27
+ import { DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, buildDomainControlManifest, formatDomainControlResult, reviewDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
28
+ import { MAX_ASSURANCE_INPUT_BYTES, buildDomainAssurance, formatDomainAssurance, } from "../lib/domain-assurance.mjs";
30
29
  import { buildCliManual } from "./manual.mjs";
30
+ import { buildInvestigationPlan, formatInvestigationPlan } from "./investigation-plan.mjs";
31
+ import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../lib/project-metadata.mjs";
31
32
  import { createBufferedOutput, writePrivateFile } from "./output-file.mjs";
32
33
  import { createTerminalProgress } from "./progress.mjs";
33
34
  import { buildRegistrySupportDocument } from "./registry-support.mjs";
35
+ import { buildRegistryDoctorReport, formatRegistryDoctorReport } from "./registry-doctor.mjs";
34
36
  import { MAX_OFFLINE_ARTIFACT_BYTES, MAX_OFFLINE_PASSPHRASE_FILE_BYTES, formatOfflineArtifactVerification, verifyOfflineArtifact, } from "./artifact-verify.mjs";
35
37
  import { MAX_SOURCE_RELIABILITY_INPUT_BYTES, buildSourceReliabilityReport, formatSourceReliabilityReport, } from "./source-reliability.mjs";
36
38
  import { MAX_RISK_CALIBRATION_INPUT_BYTES, buildRiskCalibrationReport, parseRiskCalibrationDataset, readRiskCalibrationInputBounded, } from "./risk-calibration.mjs";
39
+ import { MAX_LOOKALIKE_CALIBRATION_BYTES, buildLookalikeCalibration, formatLookalikeCalibration, } from "./lookalike-calibration.mjs";
37
40
  import { MAX_SAVED_LOOKUP_INPUT_BYTES, readSavedLookupInputBounded } from "./saved-lookup.mjs";
38
41
  import { presentTerminalOutput, terminalPresentation, } from "./terminal-presentation.mjs";
39
- import { runNetworkCommand } from "./network-command-runner.mjs";
40
42
  const require = createRequire(import.meta.url);
41
43
  const { version: VERSION } = require('../package.json');
42
44
  const MAX_STDIN_BYTES = 4096;
@@ -60,13 +62,20 @@ Discover:
60
62
  discover Generate offline lookalike candidates.
61
63
  discover-scan Collect a bounded candidate review queue.
62
64
  registry-support Explain local registry coverage without a request.
65
+ registry-doctor Diagnose a saved registry collection against local policy.
63
66
 
64
67
  Review saved evidence:
65
68
  source-report Summarise source reliability without retaining targets.
66
69
  compare Compare saved registry publications.
67
70
  page-compare Compare saved static page and TLS evidence.
68
71
  mail-review Review saved passive mail exposure evidence.
72
+ review-evidence Review supplied DNS, routing, GeoIP, or RDAP evidence offline.
73
+ domain-control Build or review an integrity-protected desired-state manifest.
74
+ assurance Review change, recovery, concentration, or retirement plans.
75
+ sharing-review Lint a reviewed artifact before deliberate sharing.
76
+ workflow-plan Plan a fixed investigation recipe without executing it.
69
77
  diff Compare two saved domain observations.
78
+ reconcile Reconcile independently labelled observations.
70
79
  timeline Compare a sequence of observations for one domain.
71
80
  export Convert a saved lookup into an evidence report.
72
81
  inspect-archive Inspect a workspace archive, redacted by default.
@@ -76,6 +85,7 @@ Integrity and calibration:
76
85
  sign-artifact Sign one reviewed packet or manifest locally.
77
86
  verify-signature Verify one signed evidence package locally.
78
87
  risk-calibrate Replay reviewed labels without changing the model.
88
+ lookalike-calibrate Summarise reviewed mutation-family yield without tuning generation.
79
89
 
80
90
  Terminal:
81
91
  doctor Check the local runtime; network tests require --network.
@@ -90,14 +100,14 @@ Diagnostics are written to stderr. Fast lookup is the default; deep collection
90
100
  must be requested explicitly and can disclose a target to additional sources.
91
101
 
92
102
  Copyright 2026 slicedearth. Licensed under AGPL-3.0-only.
93
- Source and licence: https://github.com/slicedearth/whoisleuth
103
+ Source and licence: ${WHOISLEUTH_SOURCE_REPOSITORY_URL}
94
104
  `;
95
105
  const COMMAND_USAGE = Object.freeze({
96
106
  completion: 'whoisleuth completion <bash|zsh|fish|powershell>',
97
107
  doctor: 'whoisleuth doctor [--network] [--json] [--quiet] [--no-color]',
98
108
  commands: 'whoisleuth commands [--json] [--quiet] [--no-color]',
99
109
  manual: 'whoisleuth manual',
100
- lookup: 'whoisleuth lookup <domain|IP|ASN> [--json|--markdown|--html] [--fast|--deep] [--plan] [--summary|--verbose] [--strict-exit] [--events] [--quiet] [--no-color]',
110
+ lookup: 'whoisleuth lookup <domain|IP|ASN> [--json|--markdown|--html] [--fast|--deep] [--observer <label>] [--vantage <label>] [--plan] [--summary|--verbose] [--strict-exit] [--events] [--quiet] [--no-color]',
101
111
  bulk: 'whoisleuth bulk [file] [--json|--jsonl|--csv|--domains|--queries] [--registered-only|--inconclusive-only|--errors-only] [--fast|--deep] [--concurrency <1-8>] [--checkpoint <file> [--resume]] [--events]',
102
112
  'ct-search': 'whoisleuth ct-search <keyword> [--json] [--quiet] [--no-color]',
103
113
  discover: 'whoisleuth discover <brand|domain> [--tlds <list>] [--preset <name>|--families <ids>] [--keyboard <layout>] [--dictionary <file>] [--snapshot <file>] [--json|--jsonl|--domains]',
@@ -106,7 +116,9 @@ const COMMAND_USAGE = Object.freeze({
106
116
  http: 'whoisleuth http <domain> [--json] [--quiet] [--no-color]',
107
117
  tls: 'whoisleuth tls <hostname> [--json] [--quiet] [--no-color]',
108
118
  'registry-support': 'whoisleuth registry-support <domain|suffix> [--json] [--quiet] [--no-color]',
119
+ 'registry-doctor': 'whoisleuth registry-doctor [lookup.json] [--json] [--quiet] [--no-color]',
109
120
  'risk-calibrate': 'whoisleuth risk-calibrate [dataset.json] [--json] [--quiet] [--no-color]',
121
+ 'lookalike-calibrate': 'whoisleuth lookalike-calibrate [dataset.json] [--json] [--quiet] [--no-color]',
110
122
  'verify-artifact': 'whoisleuth verify-artifact [artifact.json] [--passphrase-file <file>] [--json] [--quiet] [--no-color]',
111
123
  'inspect-archive': 'whoisleuth inspect-archive [archive.json] [--passphrase-file <file>] [--search <value>] [--require-match] [--reveal] [--json]',
112
124
  'sign-artifact': 'whoisleuth sign-artifact [artifact.json] --private-key-file <file>',
@@ -115,7 +127,13 @@ const COMMAND_USAGE = Object.freeze({
115
127
  compare: 'whoisleuth compare [lookup.json] [--json] [--quiet] [--no-color]',
116
128
  'page-compare': 'whoisleuth page-compare <left.json> <right.json> [--json] [--quiet] [--no-color]',
117
129
  'mail-review': 'whoisleuth mail-review [bulk.json|bulk.jsonl] [--json] [--quiet] [--no-color]',
130
+ 'review-evidence': 'whoisleuth review-evidence [evidence.json] [--mmdb <database-file>] [--json] [--quiet] [--no-color]',
131
+ 'domain-control': 'whoisleuth domain-control [manifest-input.json|review-input.json] [--json] [--quiet] [--no-color]',
132
+ assurance: 'whoisleuth assurance [assurance-input.json] [--json] [--quiet] [--no-color]',
133
+ 'sharing-review': 'whoisleuth sharing-review [artifact.json] --marking <level> --recipient-scope <scope> --purpose <text> [--human-reviewed] [--personal-data-reviewed] [--redactions-confirmed] [--json]',
134
+ 'workflow-plan': 'whoisleuth workflow-plan <recipe> <domain|brand> [--json] [--quiet] [--no-color]',
118
135
  diff: 'whoisleuth diff <left.json> <right.json> [--json] [--quiet] [--no-color]',
136
+ reconcile: 'whoisleuth reconcile <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
119
137
  timeline: 'whoisleuth timeline <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
120
138
  export: 'whoisleuth export [lookup.json] [--markdown|--html|--compact]',
121
139
  });
@@ -185,11 +203,21 @@ const COMMAND_DETAILS = Object.freeze({
185
203
  example: 'whoisleuth registry-support example.test --json',
186
204
  boundary: 'This command is offline. Catalogue coverage does not test live reachability or decide registration or availability.',
187
205
  },
206
+ 'registry-doctor': {
207
+ description: 'Compare a saved Lookup registry result with the reviewed local capability profile.',
208
+ example: 'whoisleuth registry-doctor lookup.json --json',
209
+ boundary: 'The command is offline. It distinguishes expected access constraints from collection results and does not contact a live registry.',
210
+ },
188
211
  'risk-calibrate': {
189
212
  description: 'Replay reviewed labels against the current explainable Risk model.',
190
213
  example: 'whoisleuth risk-calibrate calibration.json --json',
191
214
  boundary: 'Calibration is offline and diagnostic. It never trains, tunes, or changes the scoring model automatically.',
192
215
  },
216
+ 'lookalike-calibrate': {
217
+ description: 'Summarise reviewed candidate dispositions by mutation family without retaining domains.',
218
+ example: 'whoisleuth lookalike-calibrate reviewed-candidates.json --json',
219
+ boundary: 'Calibration is offline and diagnostic. It omits candidate identifiers, domains, notes, and evidence and never tunes generation or filtering automatically.',
220
+ },
193
221
  'verify-artifact': {
194
222
  description: 'Validate a supported archive, packet, manifest, or saved Lookup without printing evidence contents.',
195
223
  example: 'whoisleuth verify-artifact workspace.json --json',
@@ -230,11 +258,41 @@ const COMMAND_DETAILS = Object.freeze({
230
258
  example: 'whoisleuth mail-review candidates.json --json',
231
259
  boundary: 'Review is offline and sends no SMTP traffic. Missing or partial DNS evidence remains inconclusive.',
232
260
  },
261
+ 'review-evidence': {
262
+ description: 'Review one versioned DNSSEC, TLSA, RPKI, GeoIP, encrypted-DNS, or RDAP planning document offline.',
263
+ example: 'whoisleuth review-evidence dnssec-evidence.json --json',
264
+ boundary: 'The command reads only the supplied document. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, or SMTP request.',
265
+ },
266
+ 'domain-control': {
267
+ description: 'Build an integrity-protected desired-state manifest or compare one with supplied observations.',
268
+ example: 'whoisleuth domain-control domain-control-input.json --json',
269
+ boundary: 'The command is offline and changes no registrar, DNS, mail, or certificate configuration. Only complete supplied observations can produce drift.',
270
+ },
271
+ assurance: {
272
+ description: 'Review a versioned domain change, recovery-dependency, or retirement plan.',
273
+ example: 'whoisleuth assurance domain-assurance.json --json',
274
+ boundary: 'The command is offline and treats every provider label, readiness state, and evidence reference as analyst-authored input. It changes no configuration.',
275
+ },
276
+ 'sharing-review': {
277
+ description: 'Lint one reviewed artefact against local integrity, marking, recipient, personal-data, and redaction controls.',
278
+ example: 'whoisleuth sharing-review packet.json --marking amber --recipient-scope organization --purpose "Reviewed incident handoff" --human-reviewed --personal-data-reviewed --redactions-confirmed --json',
279
+ boundary: 'The command is offline and emits only bounded schema/version metadata, no content values, and no raw evidence. Its result is a review aid, not legal advice or recipient authorisation.',
280
+ },
281
+ 'workflow-plan': {
282
+ description: 'Build a fixed domain-investigation plan from existing bounded CLI commands.',
283
+ example: 'whoisleuth workflow-plan domain-triage example.test --json',
284
+ boundary: 'Planning is offline and plan-only. It does not execute commands, expand placeholders, read files, make requests, or submit evidence.',
285
+ },
233
286
  diff: {
234
287
  description: 'Compare bounded evidence retained in two saved domain lookups.',
235
288
  example: 'whoisleuth diff first.json second.json --json',
236
289
  boundary: 'Comparison is offline. Missing, unavailable, equal, and different evidence remain separate states.',
237
290
  },
291
+ reconcile: {
292
+ description: 'Reconcile bounded values across independently labelled observations of one domain.',
293
+ example: 'whoisleuth reconcile office.json mobile.json external.json --json',
294
+ boundary: 'The command is offline, accepts 2 to 5 saved observations for one domain, and never treats labels as proof of network independence or majority agreement as truth.',
295
+ },
238
296
  timeline: {
239
297
  description: 'Build an ordered same-domain history from saved Lookup observations.',
240
298
  example: 'whoisleuth timeline first.json second.json latest.json --json',
@@ -260,16 +318,24 @@ const COMMAND_COLLECTION = Object.freeze({
260
318
  http: { mode: 'network', scope: 'Accepts one domain and follows only the bounded SSRF-guarded homepage redirect workflow.' },
261
319
  tls: { mode: 'network', scope: 'Accepts one public hostname and opens one bounded certificate connection.' },
262
320
  'registry-support': { mode: 'offline', scope: 'Reads the embedded registry capability catalogue for one domain or suffix.' },
321
+ 'registry-doctor': { mode: 'offline', scope: 'Reads one saved Lookup and the embedded registry capability catalogue.' },
263
322
  'risk-calibrate': { mode: 'offline', scope: 'Reads one bounded reviewed-label dataset and changes no model or evidence.' },
323
+ 'lookalike-calibrate': { mode: 'offline', scope: 'Reads at most 5,000 reviewed candidate labels from one dataset capped at 2 MiB.' },
264
324
  'verify-artifact': { mode: 'offline', scope: 'Reads one selected bounded archive, packet, manifest, or saved Lookup document.' },
265
325
  'inspect-archive': { mode: 'offline', scope: 'Reads one selected bounded workspace archive with redacted output by default.' },
266
- 'sign-artifact': { mode: 'offline', scope: 'Reads one selected artifact and one local private key without transmitting either.' },
326
+ 'sign-artifact': { mode: 'offline', scope: 'Reads one selected artefact and one local private key without transmitting either.' },
267
327
  'verify-signature': { mode: 'offline', scope: 'Reads one selected signed package and optional local public key.' },
268
328
  'source-report': { mode: 'offline', scope: 'Reads bounded saved evidence and emits target-free source reliability data.' },
269
329
  compare: { mode: 'offline', scope: 'Reads one saved Lookup and compares its separately attributed registry publications.' },
270
330
  'page-compare': { mode: 'offline', scope: 'Reads two saved Lookup documents and executes no page code.' },
271
331
  'mail-review': { mode: 'offline', scope: 'Reads one saved Bulk result and sends no DNS or SMTP traffic.' },
332
+ 'review-evidence': { mode: 'offline', scope: 'Reads one bounded versioned evidence or request-planning document and performs no collection.' },
333
+ 'domain-control': { mode: 'offline', scope: 'Reads one bounded desired-state or review document and performs no collection or configuration change.' },
334
+ assurance: { mode: 'offline', scope: 'Reads one versioned plan capped at 2 MiB and makes no request or configuration change.' },
335
+ 'sharing-review': { mode: 'offline', scope: 'Reads one artefact capped at 15 MiB, emits only bounded schema/version metadata and no content values, and performs no transmission.' },
336
+ 'workflow-plan': { mode: 'offline', scope: 'Builds a fixed typed recipe and executes none of its network or file steps.' },
272
337
  diff: { mode: 'offline', scope: 'Reads two saved Lookup documents for different domains.' },
338
+ reconcile: { mode: 'offline', scope: 'Reads 2 to 5 saved observations for one domain, capped at 32 MiB in total.' },
273
339
  timeline: { mode: 'offline', scope: 'Reads 2 to 20 saved observations for one domain, capped at 32 MiB in total.' },
274
340
  export: { mode: 'offline', scope: 'Reads one saved Lookup and writes one bounded report.' },
275
341
  });
@@ -417,7 +483,7 @@ async function runParsedCli(args, dependencies = {}) {
417
483
  network: args.network,
418
484
  presentation: terminalPresentation(stdout, args.color, environment),
419
485
  ...(dependencies.resolvePublicAddresses ? { resolveAddresses: dependencies.resolvePublicAddresses } : {}),
420
- ...(dependencies.safeFetch ? { fetchHttps: dependencies.safeFetch } : { fetchHttps: safeFetch }),
486
+ ...(dependencies.safeFetch ? { fetchHttps: dependencies.safeFetch } : {}),
421
487
  ...(dependencies.whoisQuery ? { queryWhois: dependencies.whoisQuery } : {}),
422
488
  });
423
489
  const report = args.network
@@ -447,6 +513,33 @@ async function runParsedCli(args, dependencies = {}) {
447
513
  write(stdout, args.output === 'json' ? formatJsonDocument(document) : terminal(formatTerminalRegistrySupport(document), args.color));
448
514
  return EXIT_CODES.SUCCESS;
449
515
  }
516
+ if (args.action === 'registry-doctor') {
517
+ failureLabel = 'Registry compatibility diagnostic';
518
+ let input;
519
+ try {
520
+ input = dependencies.readCompareInput
521
+ ? await dependencies.readCompareInput(args.source)
522
+ : await readSavedLookupInputBounded(args.source
523
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
524
+ : dependencies.stdin || process.stdin, {
525
+ limit: MAX_SAVED_LOOKUP_INPUT_BYTES,
526
+ label: 'Registry doctor input',
527
+ });
528
+ }
529
+ catch (error) {
530
+ if (error instanceof CliUsageError)
531
+ throw error;
532
+ throw new CliUsageError(`Could not read registry doctor input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
533
+ }
534
+ if (!input.trim())
535
+ throw new CliUsageError('registry-doctor requires one saved Lookup JSON file or a document on stdin.');
536
+ const report = buildRegistryDoctorReport(input, commandContext.now());
537
+ if (!args.quiet)
538
+ write(stdout, args.output === 'json'
539
+ ? formatJsonDocument(report)
540
+ : terminal(formatRegistryDoctorReport(report), args.color));
541
+ return report.summary.investigate ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
542
+ }
450
543
  if (args.action === 'risk-calibrate') {
451
544
  failureLabel = 'Risk calibration';
452
545
  let input;
@@ -469,13 +562,50 @@ async function runParsedCli(args, dependencies = {}) {
469
562
  generatedAt: dependencies.now ? dependencies.now() : new Date().toISOString(),
470
563
  modelVersion: dependencies.riskModelVersion || RISK_MODEL_VERSION,
471
564
  reviewThreshold: dependencies.riskReviewThreshold || RISK_REVIEW_THRESHOLD,
565
+ ...(!dependencies.explainRiskScore ? {
566
+ previousModelVersion: 6,
567
+ explainPreviousRiskScore: explainRiskScoreV6,
568
+ } : {}),
472
569
  });
473
570
  if (!args.quiet)
474
571
  write(stdout, args.output === 'json' ? formatJsonDocument(report) : terminal(formatTerminalRiskCalibration(report), args.color));
475
572
  return EXIT_CODES.SUCCESS;
476
573
  }
574
+ if (args.action === 'lookalike-calibrate') {
575
+ failureLabel = 'Lookalike review-yield calibration';
576
+ let input;
577
+ try {
578
+ input = dependencies.readRiskCalibrationInput
579
+ ? await dependencies.readRiskCalibrationInput(args.source)
580
+ : await readSavedLookupInputBounded(args.source
581
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
582
+ : dependencies.stdin || process.stdin, {
583
+ limit: MAX_LOOKALIKE_CALIBRATION_BYTES,
584
+ label: 'Lookalike calibration input',
585
+ });
586
+ }
587
+ catch (error) {
588
+ if (error instanceof CliUsageError)
589
+ throw error;
590
+ throw new CliUsageError(`Could not read lookalike calibration input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
591
+ }
592
+ if (!input.trim())
593
+ throw new CliUsageError('lookalike-calibrate requires one dataset JSON file or a document on stdin.');
594
+ let report;
595
+ try {
596
+ report = buildLookalikeCalibration(input, commandContext.now());
597
+ }
598
+ catch (error) {
599
+ throw new CliUsageError(boundedCliErrorMessage(error, 'Lookalike calibration input is invalid'));
600
+ }
601
+ if (!args.quiet)
602
+ write(stdout, args.output === 'json'
603
+ ? formatJsonDocument(report)
604
+ : terminal(formatLookalikeCalibration(report), args.color));
605
+ return EXIT_CODES.SUCCESS;
606
+ }
477
607
  if (args.action === 'verify-artifact') {
478
- failureLabel = 'Artifact verification';
608
+ failureLabel = 'Artefact verification';
479
609
  let input;
480
610
  try {
481
611
  input = dependencies.readArtifactInput
@@ -484,7 +614,7 @@ async function runParsedCli(args, dependencies = {}) {
484
614
  ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
485
615
  : dependencies.stdin || process.stdin, {
486
616
  limit: MAX_OFFLINE_ARTIFACT_BYTES,
487
- label: 'Artifact input',
617
+ label: 'Artefact input',
488
618
  });
489
619
  }
490
620
  catch (error) {
@@ -493,7 +623,7 @@ async function runParsedCli(args, dependencies = {}) {
493
623
  throw new CliUsageError(`Could not read artifact input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
494
624
  }
495
625
  if (!input.trim())
496
- throw new CliUsageError('verify-artifact requires one JSON file or an artifact on stdin.');
626
+ throw new CliUsageError('verify-artifact requires one JSON file or an artefact on stdin.');
497
627
  let passphrase = null;
498
628
  if (args.passphraseSource) {
499
629
  let passphraseText;
@@ -644,6 +774,168 @@ async function runParsedCli(args, dependencies = {}) {
644
774
  : terminal(formatCliMailReview(document), args.color));
645
775
  return EXIT_CODES.SUCCESS;
646
776
  }
777
+ if (args.action === 'review-evidence') {
778
+ failureLabel = 'Offline evidence review';
779
+ let input;
780
+ try {
781
+ input = dependencies.readArtifactInput
782
+ ? await dependencies.readArtifactInput(args.source)
783
+ : await readSavedLookupInputBounded(args.source
784
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
785
+ : dependencies.stdin || process.stdin, {
786
+ limit: MAX_OFFLINE_EVIDENCE_INPUT_BYTES,
787
+ label: 'Offline evidence input',
788
+ });
789
+ }
790
+ catch (error) {
791
+ if (error instanceof CliUsageError)
792
+ throw error;
793
+ throw new CliUsageError(`Could not read offline evidence input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
794
+ }
795
+ if (!input.trim())
796
+ throw new CliUsageError('review-evidence requires one JSON file or a document on stdin.');
797
+ const document = args.mmdbSource
798
+ ? await buildOfflineEvidenceReviewWithLocalResources(input, commandContext.now(), { mmdbPath: args.mmdbSource })
799
+ : buildOfflineEvidenceReview(input, commandContext.now());
800
+ if (!args.quiet)
801
+ write(stdout, args.output === 'json'
802
+ ? formatJsonDocument(document)
803
+ : terminal(formatOfflineEvidenceReview(document), args.color));
804
+ return EXIT_CODES.SUCCESS;
805
+ }
806
+ if (args.action === 'domain-control') {
807
+ failureLabel = 'Domain control review';
808
+ let input;
809
+ try {
810
+ input = dependencies.readArtifactInput
811
+ ? await dependencies.readArtifactInput(args.source)
812
+ : await readSavedLookupInputBounded(args.source
813
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
814
+ : dependencies.stdin || process.stdin, {
815
+ limit: MAX_OFFLINE_EVIDENCE_INPUT_BYTES,
816
+ label: 'Domain control input',
817
+ });
818
+ }
819
+ catch (error) {
820
+ if (error instanceof CliUsageError)
821
+ throw error;
822
+ throw new CliUsageError(`Could not read domain control input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
823
+ }
824
+ if (!input.trim())
825
+ throw new CliUsageError('domain-control requires one JSON file or a document on stdin.');
826
+ let parsed;
827
+ try {
828
+ parsed = JSON.parse(input);
829
+ }
830
+ catch {
831
+ throw new CliUsageError('Domain control input is not valid JSON.');
832
+ }
833
+ const schema = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
834
+ ? parsed.schema
835
+ : null;
836
+ const document = schema === DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA
837
+ ? buildDomainControlManifest(parsed, commandContext.now())
838
+ : schema === DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA
839
+ ? reviewDomainControlManifest(parsed, commandContext.now())
840
+ : null;
841
+ if (!document) {
842
+ throw new CliUsageError(`Domain control input must use ${DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA} or ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA}.`);
843
+ }
844
+ if (!args.quiet)
845
+ write(stdout, args.output === 'json'
846
+ ? formatJsonDocument(document)
847
+ : terminal(formatDomainControlResult(document), args.color));
848
+ return EXIT_CODES.SUCCESS;
849
+ }
850
+ if (args.action === 'assurance') {
851
+ failureLabel = 'Domain assurance review';
852
+ let input;
853
+ try {
854
+ input = dependencies.readArtifactInput
855
+ ? await dependencies.readArtifactInput(args.source)
856
+ : await readSavedLookupInputBounded(args.source
857
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
858
+ : dependencies.stdin || process.stdin, {
859
+ limit: MAX_ASSURANCE_INPUT_BYTES,
860
+ label: 'Domain assurance input',
861
+ });
862
+ }
863
+ catch (error) {
864
+ if (error instanceof CliUsageError)
865
+ throw error;
866
+ throw new CliUsageError(`Could not read domain assurance input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
867
+ }
868
+ if (!input.trim())
869
+ throw new CliUsageError('assurance requires one versioned JSON file or a document on stdin.');
870
+ let parsed;
871
+ try {
872
+ parsed = JSON.parse(input);
873
+ }
874
+ catch {
875
+ throw new CliUsageError('Domain assurance input is not valid JSON.');
876
+ }
877
+ let document;
878
+ try {
879
+ document = buildDomainAssurance(parsed, commandContext.now());
880
+ }
881
+ catch (error) {
882
+ throw new CliUsageError(boundedCliErrorMessage(error, 'Domain assurance input is invalid'));
883
+ }
884
+ if (!args.quiet)
885
+ write(stdout, args.output === 'json'
886
+ ? formatJsonDocument(document)
887
+ : terminal(formatDomainAssurance(document), args.color));
888
+ return EXIT_CODES.SUCCESS;
889
+ }
890
+ if (args.action === 'sharing-review') {
891
+ failureLabel = 'Evidence sharing review';
892
+ let input;
893
+ try {
894
+ input = dependencies.readArtifactInput
895
+ ? await dependencies.readArtifactInput(args.source)
896
+ : await readSavedLookupInputBounded(args.source
897
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
898
+ : dependencies.stdin || process.stdin, {
899
+ limit: MAX_SHARING_REVIEW_BYTES,
900
+ label: 'Sharing review input',
901
+ });
902
+ }
903
+ catch (error) {
904
+ if (error instanceof CliUsageError)
905
+ throw error;
906
+ throw new CliUsageError(`Could not read sharing review input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
907
+ }
908
+ if (!input.trim())
909
+ throw new CliUsageError('sharing-review requires one artefact JSON file or a document on stdin.');
910
+ let document;
911
+ try {
912
+ document = await buildSharingReview(input, {
913
+ marking: args.marking,
914
+ recipientScope: args.recipientScope,
915
+ purpose: args.purpose,
916
+ humanReviewed: args.humanReviewed,
917
+ personalDataReviewed: args.personalDataReviewed,
918
+ redactionsConfirmed: args.redactionsConfirmed,
919
+ }, commandContext.now());
920
+ }
921
+ catch (error) {
922
+ throw new CliUsageError(boundedCliErrorMessage(error, 'Sharing review input is invalid'));
923
+ }
924
+ if (!args.quiet)
925
+ write(stdout, args.output === 'json'
926
+ ? formatJsonDocument(document)
927
+ : terminal(formatSharingReview(document), args.color));
928
+ return document.summary.status === 'blocked' ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
929
+ }
930
+ if (args.action === 'workflow-plan') {
931
+ failureLabel = 'Investigation plan';
932
+ const document = buildInvestigationPlan(args.recipe, args.subject, commandContext.now());
933
+ if (!args.quiet)
934
+ write(stdout, args.output === 'json'
935
+ ? formatJsonDocument(document)
936
+ : terminal(formatInvestigationPlan(document), args.color));
937
+ return EXIT_CODES.SUCCESS;
938
+ }
647
939
  if (args.action === 'diff') {
648
940
  failureLabel = 'Lookup evidence diff';
649
941
  const readDiffInput = dependencies.readDiffInput || (async (source) => (readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), {
@@ -670,6 +962,36 @@ async function runParsedCli(args, dependencies = {}) {
670
962
  : terminal(formatCliLookupDiff(document), args.color));
671
963
  return EXIT_CODES.SUCCESS;
672
964
  }
965
+ if (args.action === 'reconcile') {
966
+ failureLabel = 'Lookup observation reconciliation';
967
+ const readReconciliationInput = dependencies.readDiffInput || (async (source) => (readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), {
968
+ limit: MAX_SAVED_LOOKUP_INPUT_BYTES,
969
+ label: 'Lookup reconciliation input',
970
+ })));
971
+ const inputs = [];
972
+ let totalBytes = 0;
973
+ try {
974
+ for (const source of args.sources) {
975
+ const input = await readReconciliationInput(source);
976
+ totalBytes += Buffer.byteLength(input, 'utf8');
977
+ if (totalBytes > MAX_LOOKUP_RECONCILIATION_INPUT_BYTES) {
978
+ throw new CliUsageError(`Lookup reconciliation input is limited to ${MAX_LOOKUP_RECONCILIATION_INPUT_BYTES} bytes in total.`);
979
+ }
980
+ inputs.push(input);
981
+ }
982
+ }
983
+ catch (error) {
984
+ if (error instanceof CliUsageError)
985
+ throw error;
986
+ throw new CliUsageError(`Could not read Lookup reconciliation input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
987
+ }
988
+ const document = buildCliLookupReconciliation(inputs, commandContext.now());
989
+ if (!args.quiet)
990
+ write(stdout, args.output === 'json'
991
+ ? formatJsonDocument(document)
992
+ : terminal(formatCliLookupReconciliation(document), args.color));
993
+ return EXIT_CODES.SUCCESS;
994
+ }
673
995
  if (args.action === 'timeline') {
674
996
  failureLabel = 'Lookup observation timeline';
675
997
  const readTimelineInput = dependencies.readDiffInput || (async (source) => (readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), {
@@ -734,14 +1056,17 @@ async function runParsedCli(args, dependencies = {}) {
734
1056
  }
735
1057
  if (args.action === 'bulk') {
736
1058
  failureLabel = 'Bulk lookup';
1059
+ const { runBulkCommand } = await import("./bulk-command-runner.mjs");
737
1060
  return await runBulkCommand(args, dependencies, commandContext);
738
1061
  }
739
1062
  if (args.action === 'discover') {
740
1063
  failureLabel = 'Candidate generation';
1064
+ const { runDiscoveryCommand } = await import("./discovery-command-runner.mjs");
741
1065
  return await runDiscoveryCommand(args, dependencies, commandContext);
742
1066
  }
743
1067
  if (args.action === 'discover-scan') {
744
1068
  failureLabel = 'Candidate scan';
1069
+ const { runDiscoveryScanCommand } = await import("./discovery-scan-command-runner.mjs");
745
1070
  return await runDiscoveryScanCommand(args, dependencies, commandContext);
746
1071
  }
747
1072
  if (args.action === 'ct-search'
@@ -755,8 +1080,10 @@ async function runParsedCli(args, dependencies = {}) {
755
1080
  : args.action === 'http'
756
1081
  ? 'HTTP probe'
757
1082
  : 'TLS intelligence';
1083
+ const { runNetworkCommand } = await import("./network-command-runner.mjs");
758
1084
  return await runNetworkCommand(args, dependencies, commandContext);
759
1085
  }
1086
+ const { runLookupCommand } = await import("./lookup-command-runner.mjs");
760
1087
  return await runLookupCommand(args, dependencies, commandContext);
761
1088
  }
762
1089
  catch (error) {
@@ -77,10 +77,10 @@ function parseSavedLookupDocument(text, options = {}) {
77
77
  const rdapParsed = objectOrNull(rdap?.parsed);
78
78
  const whoisParsed = objectOrNull(whois?.parsed);
79
79
  if (rdapStatus === 'success' && !rdapParsed) {
80
- throw new CliUsageError('Successful RDAP input is missing normalized parsed data.');
80
+ throw new CliUsageError('Successful RDAP input is missing normalised parsed data.');
81
81
  }
82
82
  if ((whoisStatus === 'complete' || whoisStatus === 'partial') && !whoisParsed) {
83
- throw new CliUsageError('Successful WHOIS input is missing normalized parsed data.');
83
+ throw new CliUsageError('Successful WHOIS input is missing normalised parsed data.');
84
84
  }
85
85
  return document;
86
86
  }