@slicedearth/whoisleuth-cli 1.32.2 → 1.36.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 (73) hide show
  1. package/README.md +43 -5
  2. package/SECURITY.md +35 -0
  3. package/cli/arguments.mjs +180 -8
  4. package/cli/artifact-verify.mjs +32 -0
  5. package/cli/bulk-command-runner.mjs +3 -1
  6. package/cli/bulk-output.mjs +6 -1
  7. package/cli/bulk.mjs +1 -2
  8. package/cli/command-catalogue.mjs +33 -0
  9. package/cli/completion.mjs +70 -7
  10. package/cli/doctor.mjs +78 -43
  11. package/cli/export-evidence.mjs +9 -2
  12. package/cli/formatters/html.mjs +1 -1
  13. package/cli/formatters/terminal.mjs +13 -7
  14. package/cli/investigation-plan.mjs +112 -0
  15. package/cli/lookup-command-runner.mjs +8 -0
  16. package/cli/lookup-diff.mjs +16 -5
  17. package/cli/lookup-plan.mjs +99 -0
  18. package/cli/lookup-timeline.mjs +123 -0
  19. package/cli/mail-review.mjs +47 -2
  20. package/cli/manual.mjs +2 -1
  21. package/cli/offline-evidence-review.mjs +141 -0
  22. package/cli/registry-support.mjs +1 -4
  23. package/cli/runner.mjs +214 -11
  24. package/docs/cli.md +200 -45
  25. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -6
  26. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  27. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  28. package/frontend/src/lib/analysis/http-summary.js +2 -1
  29. package/frontend/src/lib/analysis/utils.js +7 -40
  30. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  31. package/lib/availability.mjs +30 -10
  32. package/lib/bulk-limits.mjs +13 -0
  33. package/lib/csv.mjs +1 -1
  34. package/lib/ct-response-bounds.mjs +9 -0
  35. package/lib/ct-search.mjs +66 -12
  36. package/lib/dns-delegation-health.mjs +56 -5
  37. package/lib/dnssec-evidence-validation.mjs +235 -0
  38. package/lib/domain-control-manifest.mjs +339 -0
  39. package/lib/domain-posture.mjs +2 -1
  40. package/lib/encrypted-dns-contract.mjs +81 -0
  41. package/lib/evidence-export.mjs +17 -3
  42. package/lib/evidence-report-markdown.mjs +3 -3
  43. package/lib/evidence-report.mjs +3 -0
  44. package/lib/favicon.mjs +3 -8
  45. package/lib/html-signals.mjs +4 -0
  46. package/lib/http-evidence-bounds.mjs +4 -0
  47. package/lib/http-intelligence.mjs +2 -1
  48. package/lib/ip-prefix.mjs +54 -0
  49. package/lib/local-geoip-evidence.mjs +89 -0
  50. package/lib/outbound-identity.mjs +9 -0
  51. package/lib/outbound-request-bounds.mjs +9 -0
  52. package/lib/perceptual-hash-comparison.mjs +34 -0
  53. package/lib/perceptual-hash.mjs +3 -31
  54. package/lib/project-metadata.mjs +8 -0
  55. package/lib/provider-policy-admission.mjs +49 -0
  56. package/lib/rdap-search-workbench.mjs +157 -0
  57. package/lib/rdap.mjs +7 -2
  58. package/lib/registry-capabilities.mjs +31 -1
  59. package/lib/registry-capability-catalogue.mjs +11 -10
  60. package/lib/response-policy.mjs +81 -6
  61. package/lib/rpki-evidence.mjs +105 -0
  62. package/lib/safe-fetch.mjs +2 -1
  63. package/lib/semantic-version.mjs +54 -0
  64. package/lib/static-html-analysis.mjs +27 -0
  65. package/lib/threatfox-intelligence.mjs +10 -5
  66. package/lib/tlsa-evidence.mjs +194 -0
  67. package/lib/urlhaus-intelligence.mjs +10 -5
  68. package/lib/urlscan-intelligence.mjs +10 -5
  69. package/lib/website-security-posture.mjs +11 -4
  70. package/lib/whois-chain.mjs +14 -152
  71. package/lib/whois.mjs +2 -2
  72. package/package.json +7 -5
  73. package/third-party-notices.txt +2 -2
package/cli/runner.mjs CHANGED
@@ -2,12 +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 } from "../lib/safe-fetch.mjs";
5
+ import { resolvePublicAddresses, safeFetch } from "../lib/safe-fetch.mjs";
6
6
  import { whoisQuery } from "../lib/whois-transport.mjs";
7
7
  import { REGISTRY_CAPABILITIES_VERSION, registryCapabilityFor } from "../lib/registry-capabilities.mjs";
8
8
  import { explainRiskScore, RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD } from "../lib/risk-scoring.mjs";
9
9
  import { CLI_COMMANDS, parseCliArguments } from "./arguments.mjs";
10
10
  import { runBulkCommand } from "./bulk-command-runner.mjs";
11
+ import { buildCliCommandCatalogue, formatCliCommandCatalogue } from "./command-catalogue.mjs";
11
12
  import { buildShellCompletion } from "./completion.mjs";
12
13
  import { buildDoctorReport, formatDoctorReport } from "./doctor.mjs";
13
14
  import { MAX_COMPARE_INPUT_BYTES, compareLookupDocument, parseCliLookupDocument, readCompareInputBounded, } from "./compare.mjs";
@@ -22,10 +23,15 @@ import { buildCliCompareDocument, formatJsonDocument, } from "./formatters/json.
22
23
  import { formatLookupEvidenceMarkdown } from "./formatters/markdown.mjs";
23
24
  import { formatTerminalCompare, formatTerminalRegistrySupport, formatTerminalRiskCalibration, } from "./formatters/terminal.mjs";
24
25
  import { buildCliLookupDiff, formatCliLookupDiff } from "./lookup-diff.mjs";
26
+ import { MAX_LOOKUP_TIMELINE_INPUT_BYTES, buildCliLookupTimeline, formatCliLookupTimeline, } from "./lookup-timeline.mjs";
25
27
  import { buildCliPageComparison, formatCliPageComparison } from "./page-compare.mjs";
26
28
  import { MAX_MAIL_REVIEW_INPUT_BYTES, buildCliMailReview, formatCliMailReview, } from "./mail-review.mjs";
29
+ import { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, buildOfflineEvidenceReview, formatOfflineEvidenceReview, } from "./offline-evidence-review.mjs";
30
+ import { DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, buildDomainControlManifest, formatDomainControlResult, reviewDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
27
31
  import { runLookupCommand } from "./lookup-command-runner.mjs";
28
32
  import { buildCliManual } from "./manual.mjs";
33
+ import { buildInvestigationPlan, formatInvestigationPlan } from "./investigation-plan.mjs";
34
+ import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../lib/project-metadata.mjs";
29
35
  import { createBufferedOutput, writePrivateFile } from "./output-file.mjs";
30
36
  import { createTerminalProgress } from "./progress.mjs";
31
37
  import { buildRegistrySupportDocument } from "./registry-support.mjs";
@@ -64,10 +70,14 @@ Review saved evidence:
64
70
  compare Compare saved registry publications.
65
71
  page-compare Compare saved static page and TLS evidence.
66
72
  mail-review Review saved passive mail exposure evidence.
73
+ review-evidence Review supplied DNS, routing, GeoIP, or RDAP evidence offline.
74
+ domain-control Build or review an integrity-protected desired-state manifest.
75
+ workflow-plan Plan a fixed investigation recipe without executing it.
67
76
  diff Compare two saved domain observations.
77
+ timeline Compare a sequence of observations for one domain.
68
78
  export Convert a saved lookup into an evidence report.
69
79
  inspect-archive Inspect a workspace archive, redacted by default.
70
- verify-artifact Validate an archive, packet, or manifest offline.
80
+ verify-artifact Validate saved evidence or an integrity envelope offline.
71
81
 
72
82
  Integrity and calibration:
73
83
  sign-artifact Sign one reviewed packet or manifest locally.
@@ -76,7 +86,8 @@ Integrity and calibration:
76
86
 
77
87
  Terminal:
78
88
  doctor Check the local runtime; network tests require --network.
79
- completion Print a completion script for bash, zsh, or fish.
89
+ commands List command contracts for people or local tooling.
90
+ completion Print completion for bash, zsh, fish, or PowerShell.
80
91
  manual Print the generated manual page.
81
92
 
82
93
  Run "whoisleuth <command> --help" for focused usage and an example.
@@ -86,14 +97,15 @@ Diagnostics are written to stderr. Fast lookup is the default; deep collection
86
97
  must be requested explicitly and can disclose a target to additional sources.
87
98
 
88
99
  Copyright 2026 slicedearth. Licensed under AGPL-3.0-only.
89
- Source and licence: https://github.com/slicedearth/whoisleuth
100
+ Source and licence: ${WHOISLEUTH_SOURCE_REPOSITORY_URL}
90
101
  `;
91
102
  const COMMAND_USAGE = Object.freeze({
92
- completion: 'whoisleuth completion <bash|zsh|fish>',
103
+ completion: 'whoisleuth completion <bash|zsh|fish|powershell>',
93
104
  doctor: 'whoisleuth doctor [--network] [--json] [--quiet] [--no-color]',
105
+ commands: 'whoisleuth commands [--json] [--quiet] [--no-color]',
94
106
  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]',
107
+ lookup: 'whoisleuth lookup <domain|IP|ASN> [--json|--markdown|--html] [--fast|--deep] [--plan] [--summary|--verbose] [--strict-exit] [--events] [--quiet] [--no-color]',
108
+ bulk: 'whoisleuth bulk [file] [--json|--jsonl|--csv|--domains|--queries] [--registered-only|--inconclusive-only|--errors-only] [--fast|--deep] [--concurrency <1-8>] [--checkpoint <file> [--resume]] [--events]',
97
109
  'ct-search': 'whoisleuth ct-search <keyword> [--json] [--quiet] [--no-color]',
98
110
  discover: 'whoisleuth discover <brand|domain> [--tlds <list>] [--preset <name>|--families <ids>] [--keyboard <layout>] [--dictionary <file>] [--snapshot <file>] [--json|--jsonl|--domains]',
99
111
  '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]',
@@ -110,7 +122,11 @@ const COMMAND_USAGE = Object.freeze({
110
122
  compare: 'whoisleuth compare [lookup.json] [--json] [--quiet] [--no-color]',
111
123
  'page-compare': 'whoisleuth page-compare <left.json> <right.json> [--json] [--quiet] [--no-color]',
112
124
  'mail-review': 'whoisleuth mail-review [bulk.json|bulk.jsonl] [--json] [--quiet] [--no-color]',
125
+ 'review-evidence': 'whoisleuth review-evidence [evidence.json] [--json] [--quiet] [--no-color]',
126
+ 'domain-control': 'whoisleuth domain-control [manifest-input.json|review-input.json] [--json] [--quiet] [--no-color]',
127
+ 'workflow-plan': 'whoisleuth workflow-plan <recipe> <domain|brand> [--json] [--quiet] [--no-color]',
113
128
  diff: 'whoisleuth diff <left.json> <right.json> [--json] [--quiet] [--no-color]',
129
+ timeline: 'whoisleuth timeline <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
114
130
  export: 'whoisleuth export [lookup.json] [--markdown|--html|--compact]',
115
131
  });
116
132
  const COMMAND_DETAILS = Object.freeze({
@@ -124,6 +140,11 @@ const COMMAND_DETAILS = Object.freeze({
124
140
  example: 'whoisleuth doctor --json',
125
141
  boundary: 'The default check is offline. Public DNS and port 43 checks run only when --network is explicitly supplied.',
126
142
  },
143
+ commands: {
144
+ description: 'List the installed command contracts in terminal or versioned JSON form.',
145
+ example: 'whoisleuth commands --json',
146
+ boundary: 'Catalogue generation is offline. It reports declared command modes and limits without executing collection or inspecting local evidence.',
147
+ },
127
148
  manual: {
128
149
  description: 'Print a generated roff manual page for local installation.',
129
150
  example: 'whoisleuth manual | man -l -',
@@ -180,7 +201,7 @@ const COMMAND_DETAILS = Object.freeze({
180
201
  boundary: 'Calibration is offline and diagnostic. It never trains, tunes, or changes the scoring model automatically.',
181
202
  },
182
203
  'verify-artifact': {
183
- description: 'Validate a supported archive, packet, or manifest without printing evidence contents.',
204
+ description: 'Validate a supported archive, packet, manifest, or saved Lookup without printing evidence contents.',
184
205
  example: 'whoisleuth verify-artifact workspace.json --json',
185
206
  boundary: 'Verification is offline and redacted. Encrypted archives require an explicitly supplied passphrase file.',
186
207
  },
@@ -219,20 +240,71 @@ const COMMAND_DETAILS = Object.freeze({
219
240
  example: 'whoisleuth mail-review candidates.json --json',
220
241
  boundary: 'Review is offline and sends no SMTP traffic. Missing or partial DNS evidence remains inconclusive.',
221
242
  },
243
+ 'review-evidence': {
244
+ description: 'Review one versioned DNSSEC, TLSA, RPKI, GeoIP, encrypted-DNS, or RDAP planning document offline.',
245
+ example: 'whoisleuth review-evidence dnssec-evidence.json --json',
246
+ boundary: 'The command reads only the supplied document. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, or SMTP request.',
247
+ },
248
+ 'domain-control': {
249
+ description: 'Build an integrity-protected desired-state manifest or compare one with supplied observations.',
250
+ example: 'whoisleuth domain-control domain-control-input.json --json',
251
+ boundary: 'The command is offline and changes no registrar, DNS, mail, or certificate configuration. Only complete supplied observations can produce drift.',
252
+ },
253
+ 'workflow-plan': {
254
+ description: 'Build a fixed domain-investigation plan from existing bounded CLI commands.',
255
+ example: 'whoisleuth workflow-plan domain-triage example.test --json',
256
+ boundary: 'Planning is offline and plan-only. It does not execute commands, expand placeholders, read files, make requests, or submit evidence.',
257
+ },
222
258
  diff: {
223
259
  description: 'Compare bounded evidence retained in two saved domain lookups.',
224
260
  example: 'whoisleuth diff first.json second.json --json',
225
261
  boundary: 'Comparison is offline. Missing, unavailable, equal, and different evidence remain separate states.',
226
262
  },
263
+ timeline: {
264
+ description: 'Build an ordered same-domain history from saved Lookup observations.',
265
+ example: 'whoisleuth timeline first.json second.json latest.json --json',
266
+ boundary: 'The command is offline, accepts 2 to 20 bounded inputs for one domain, retains no filenames or raw registry payloads, and does not treat changed collection conditions as a domain change.',
267
+ },
227
268
  export: {
228
269
  description: 'Convert one saved lookup into a versioned evidence report.',
229
270
  example: 'whoisleuth export lookup.json --markdown',
230
271
  boundary: 'Exports preserve source attribution and limitations. Compact output intentionally omits raw registry payloads.',
231
272
  },
232
273
  });
274
+ const COMMAND_COLLECTION = Object.freeze({
275
+ completion: { mode: 'offline', scope: 'Prints one static script and changes no shell configuration.' },
276
+ doctor: { mode: 'network', scope: 'Network access is opt-in with --network and is limited to fixed public DNS, HTTPS, and WHOIS diagnostics.' },
277
+ commands: { mode: 'offline', scope: 'Reads the embedded command catalogue and performs no collection.' },
278
+ manual: { mode: 'offline', scope: 'Builds documentation from the embedded command catalogue.' },
279
+ lookup: { mode: 'network', scope: 'Accepts one target. Fast is the default; deep collection must be selected explicitly.' },
280
+ bulk: { mode: 'network', scope: 'Accepts at most 500 fast or 50 deep targets, with concurrency capped at 8 fast or 3 deep.' },
281
+ 'ct-search': { mode: 'network', scope: 'Accepts one bounded search keyword and queries the fixed certificate-transparency source.' },
282
+ discover: { mode: 'offline', scope: 'Generates a bounded candidate set from local rules, dictionaries, and optional saved snapshots.' },
283
+ 'discover-scan': { mode: 'network', scope: 'Scans at most 500 fast or 50 deep candidates, with concurrency capped at 8 fast or 3 deep.' },
284
+ posture: { mode: 'network', scope: 'Accepts one domain and performs bounded DNS queries only.' },
285
+ http: { mode: 'network', scope: 'Accepts one domain and follows only the bounded SSRF-guarded homepage redirect workflow.' },
286
+ tls: { mode: 'network', scope: 'Accepts one public hostname and opens one bounded certificate connection.' },
287
+ 'registry-support': { mode: 'offline', scope: 'Reads the embedded registry capability catalogue for one domain or suffix.' },
288
+ 'risk-calibrate': { mode: 'offline', scope: 'Reads one bounded reviewed-label dataset and changes no model or evidence.' },
289
+ 'verify-artifact': { mode: 'offline', scope: 'Reads one selected bounded archive, packet, manifest, or saved Lookup document.' },
290
+ 'inspect-archive': { mode: 'offline', scope: 'Reads one selected bounded workspace archive with redacted output by default.' },
291
+ 'sign-artifact': { mode: 'offline', scope: 'Reads one selected artifact and one local private key without transmitting either.' },
292
+ 'verify-signature': { mode: 'offline', scope: 'Reads one selected signed package and optional local public key.' },
293
+ 'source-report': { mode: 'offline', scope: 'Reads bounded saved evidence and emits target-free source reliability data.' },
294
+ compare: { mode: 'offline', scope: 'Reads one saved Lookup and compares its separately attributed registry publications.' },
295
+ 'page-compare': { mode: 'offline', scope: 'Reads two saved Lookup documents and executes no page code.' },
296
+ 'mail-review': { mode: 'offline', scope: 'Reads one saved Bulk result and sends no DNS or SMTP traffic.' },
297
+ 'review-evidence': { mode: 'offline', scope: 'Reads one bounded versioned evidence or request-planning document and performs no collection.' },
298
+ 'domain-control': { mode: 'offline', scope: 'Reads one bounded desired-state or review document and performs no collection or configuration change.' },
299
+ 'workflow-plan': { mode: 'offline', scope: 'Builds a fixed typed recipe and executes none of its network or file steps.' },
300
+ diff: { mode: 'offline', scope: 'Reads two saved Lookup documents for different domains.' },
301
+ timeline: { mode: 'offline', scope: 'Reads 2 to 20 saved observations for one domain, capped at 32 MiB in total.' },
302
+ export: { mode: 'offline', scope: 'Reads one saved Lookup and writes one bounded report.' },
303
+ });
233
304
  function commandHelp(command) {
234
305
  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`;
306
+ const collection = COMMAND_COLLECTION[command];
307
+ return `WHOISleuth ${command}\n${detail.description}\n\nUsage:\n ${COMMAND_USAGE[command]}\n\nExample:\n ${detail.example}\n\nCollection:\n ${collection.mode === 'offline' ? 'Offline' : 'Network'}: ${collection.scope}\n\nBoundary:\n ${detail.boundary}\n\nRun "whoisleuth --help" to see the grouped command list.\n`;
236
308
  }
237
309
  async function readStdinBounded(stream, limit = MAX_STDIN_BYTES) {
238
310
  if (!stream || stream.isTTY)
@@ -341,8 +413,28 @@ async function runParsedCli(args, dependencies = {}) {
341
413
  write(stdout, buildShellCompletion(args.shell));
342
414
  return EXIT_CODES.SUCCESS;
343
415
  }
416
+ if (args.action === 'commands') {
417
+ const catalogue = buildCliCommandCatalogue({
418
+ commands: CLI_COMMANDS,
419
+ collections: COMMAND_COLLECTION,
420
+ details: COMMAND_DETAILS,
421
+ usage: COMMAND_USAGE,
422
+ packageVersion: VERSION,
423
+ });
424
+ if (!args.quiet)
425
+ write(stdout, args.output === 'json'
426
+ ? formatJsonDocument(catalogue)
427
+ : terminal(formatCliCommandCatalogue(catalogue), args.color));
428
+ return EXIT_CODES.SUCCESS;
429
+ }
344
430
  if (args.action === 'manual') {
345
- write(stdout, buildCliManual({ commands: CLI_COMMANDS, details: COMMAND_DETAILS, usage: COMMAND_USAGE, version: VERSION }));
431
+ write(stdout, buildCliManual({
432
+ commands: CLI_COMMANDS,
433
+ collections: COMMAND_COLLECTION,
434
+ details: COMMAND_DETAILS,
435
+ usage: COMMAND_USAGE,
436
+ version: VERSION,
437
+ }));
346
438
  return EXIT_CODES.SUCCESS;
347
439
  }
348
440
  if (args.action === 'doctor') {
@@ -353,10 +445,11 @@ async function runParsedCli(args, dependencies = {}) {
353
445
  network: args.network,
354
446
  presentation: terminalPresentation(stdout, args.color, environment),
355
447
  ...(dependencies.resolvePublicAddresses ? { resolveAddresses: dependencies.resolvePublicAddresses } : {}),
448
+ ...(dependencies.safeFetch ? { fetchHttps: dependencies.safeFetch } : { fetchHttps: safeFetch }),
356
449
  ...(dependencies.whoisQuery ? { queryWhois: dependencies.whoisQuery } : {}),
357
450
  });
358
451
  const report = args.network
359
- ? await withProgress('Checking public DNS and WHOIS connectivity', buildReport)
452
+ ? await withProgress('Checking public DNS, HTTPS, and WHOIS connectivity', buildReport)
360
453
  : await buildReport();
361
454
  if (!args.quiet) {
362
455
  write(stdout, args.output === 'json'
@@ -579,6 +672,86 @@ async function runParsedCli(args, dependencies = {}) {
579
672
  : terminal(formatCliMailReview(document), args.color));
580
673
  return EXIT_CODES.SUCCESS;
581
674
  }
675
+ if (args.action === 'review-evidence') {
676
+ failureLabel = 'Offline evidence review';
677
+ let input;
678
+ try {
679
+ input = dependencies.readArtifactInput
680
+ ? await dependencies.readArtifactInput(args.source)
681
+ : await readSavedLookupInputBounded(args.source
682
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
683
+ : dependencies.stdin || process.stdin, {
684
+ limit: MAX_OFFLINE_EVIDENCE_INPUT_BYTES,
685
+ label: 'Offline evidence input',
686
+ });
687
+ }
688
+ catch (error) {
689
+ if (error instanceof CliUsageError)
690
+ throw error;
691
+ throw new CliUsageError(`Could not read offline evidence input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
692
+ }
693
+ if (!input.trim())
694
+ throw new CliUsageError('review-evidence requires one JSON file or a document on stdin.');
695
+ const document = buildOfflineEvidenceReview(input, commandContext.now());
696
+ if (!args.quiet)
697
+ write(stdout, args.output === 'json'
698
+ ? formatJsonDocument(document)
699
+ : terminal(formatOfflineEvidenceReview(document), args.color));
700
+ return EXIT_CODES.SUCCESS;
701
+ }
702
+ if (args.action === 'domain-control') {
703
+ failureLabel = 'Domain control review';
704
+ let input;
705
+ try {
706
+ input = dependencies.readArtifactInput
707
+ ? await dependencies.readArtifactInput(args.source)
708
+ : await readSavedLookupInputBounded(args.source
709
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
710
+ : dependencies.stdin || process.stdin, {
711
+ limit: MAX_OFFLINE_EVIDENCE_INPUT_BYTES,
712
+ label: 'Domain control input',
713
+ });
714
+ }
715
+ catch (error) {
716
+ if (error instanceof CliUsageError)
717
+ throw error;
718
+ throw new CliUsageError(`Could not read domain control input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
719
+ }
720
+ if (!input.trim())
721
+ throw new CliUsageError('domain-control requires one JSON file or a document on stdin.');
722
+ let parsed;
723
+ try {
724
+ parsed = JSON.parse(input);
725
+ }
726
+ catch {
727
+ throw new CliUsageError('Domain control input is not valid JSON.');
728
+ }
729
+ const schema = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
730
+ ? parsed.schema
731
+ : null;
732
+ const document = schema === DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA
733
+ ? buildDomainControlManifest(parsed, commandContext.now())
734
+ : schema === DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA
735
+ ? reviewDomainControlManifest(parsed, commandContext.now())
736
+ : null;
737
+ if (!document) {
738
+ throw new CliUsageError(`Domain control input must use ${DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA} or ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA}.`);
739
+ }
740
+ if (!args.quiet)
741
+ write(stdout, args.output === 'json'
742
+ ? formatJsonDocument(document)
743
+ : terminal(formatDomainControlResult(document), args.color));
744
+ return EXIT_CODES.SUCCESS;
745
+ }
746
+ if (args.action === 'workflow-plan') {
747
+ failureLabel = 'Investigation plan';
748
+ const document = buildInvestigationPlan(args.recipe, args.subject, commandContext.now());
749
+ if (!args.quiet)
750
+ write(stdout, args.output === 'json'
751
+ ? formatJsonDocument(document)
752
+ : terminal(formatInvestigationPlan(document), args.color));
753
+ return EXIT_CODES.SUCCESS;
754
+ }
582
755
  if (args.action === 'diff') {
583
756
  failureLabel = 'Lookup evidence diff';
584
757
  const readDiffInput = dependencies.readDiffInput || (async (source) => (readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), {
@@ -605,6 +778,36 @@ async function runParsedCli(args, dependencies = {}) {
605
778
  : terminal(formatCliLookupDiff(document), args.color));
606
779
  return EXIT_CODES.SUCCESS;
607
780
  }
781
+ if (args.action === 'timeline') {
782
+ failureLabel = 'Lookup observation timeline';
783
+ const readTimelineInput = dependencies.readDiffInput || (async (source) => (readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), {
784
+ limit: MAX_SAVED_LOOKUP_INPUT_BYTES,
785
+ label: 'Lookup timeline input',
786
+ })));
787
+ const inputs = [];
788
+ let totalBytes = 0;
789
+ try {
790
+ for (const source of args.sources) {
791
+ const input = await readTimelineInput(source);
792
+ totalBytes += Buffer.byteLength(input, 'utf8');
793
+ if (totalBytes > MAX_LOOKUP_TIMELINE_INPUT_BYTES) {
794
+ throw new CliUsageError(`Lookup timeline input is limited to ${MAX_LOOKUP_TIMELINE_INPUT_BYTES} bytes in total.`);
795
+ }
796
+ inputs.push(input);
797
+ }
798
+ }
799
+ catch (error) {
800
+ if (error instanceof CliUsageError)
801
+ throw error;
802
+ throw new CliUsageError(`Could not read Lookup timeline input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
803
+ }
804
+ const document = buildCliLookupTimeline(inputs, commandContext.now());
805
+ if (!args.quiet)
806
+ write(stdout, args.output === 'json'
807
+ ? formatJsonDocument(document)
808
+ : terminal(formatCliLookupTimeline(document), args.color));
809
+ return EXIT_CODES.SUCCESS;
810
+ }
608
811
  if (args.action === 'export') {
609
812
  failureLabel = 'Evidence export';
610
813
  let input;