@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/README.md CHANGED
@@ -11,19 +11,35 @@ WHOISleuth requires Node.js 24 or later. Run the reviewed public package without
11
11
  a global installation:
12
12
 
13
13
  ```bash
14
- npm exec --yes --package=@slicedearth/whoisleuth-cli -- whoisleuth --help
14
+ npm exec --yes --ignore-scripts --package=@slicedearth/whoisleuth-cli -- whoisleuth --help
15
15
  ```
16
16
 
17
17
  Or install the `whoisleuth` command globally:
18
18
 
19
19
  ```bash
20
- npm install --global @slicedearth/whoisleuth-cli
20
+ npm install --global --ignore-scripts @slicedearth/whoisleuth-cli
21
21
  whoisleuth doctor
22
22
  ```
23
23
 
24
+ Update an existing global installation and confirm the installed release with:
25
+
26
+ ```bash
27
+ npm install --global --ignore-scripts @slicedearth/whoisleuth-cli@latest
28
+ whoisleuth --version
29
+ ```
30
+
24
31
  The package version follows the application version. Each release is assembled
25
32
  from the executable's bounded dependency closure and retains the project
26
33
  licence, notices, trademark terms, source location, and provenance metadata.
34
+ The generated package manifest pins each direct runtime dependency to the exact
35
+ version exercised by the reviewed repository lockfile.
36
+
37
+ The package has no install, postinstall, or other lifecycle scripts. Network
38
+ access begins only when an analyst runs a networked command. Offline commands
39
+ may read deliberately selected local evidence or configuration files, but do
40
+ not upload them or invoke a hosted WHOISleuth deployment. The installation
41
+ examples disable lifecycle scripts for the package and its dependency tree;
42
+ WHOISleuth does not require them.
27
43
 
28
44
  ## Repository checkout
29
45
 
@@ -33,17 +49,24 @@ Contributors can run the same source command without installing the package:
33
49
  node bin/whoisleuth.mts --help
34
50
  node bin/whoisleuth.mts lookup example.test
35
51
  node bin/whoisleuth.mts lookup example.test --deep --summary
52
+ node bin/whoisleuth.mts lookup example.test --deep --plan --json
36
53
  node bin/whoisleuth.mts lookup example.test --deep --markdown --output lookup.md
37
54
  node bin/whoisleuth.mts bulk domains.txt --deep --checkpoint bulk-checkpoint.json
38
55
  node bin/whoisleuth.mts bulk domains.txt --csv --registered-only
56
+ node bin/whoisleuth.mts bulk domains.txt --queries --errors-only
39
57
  node bin/whoisleuth.mts discover example.test --dictionary private-terms.txt --snapshot discovery-state.json --json
40
58
  node bin/whoisleuth.mts discover-scan example.test --scan-limit 50 --checkpoint candidate-scan.json --json
41
59
  node bin/whoisleuth.mts page-compare official.json candidate.json --json
42
60
  node bin/whoisleuth.mts mail-review bulk.json --json
61
+ node bin/whoisleuth.mts domain-control domain-control-input.json --json
62
+ node bin/whoisleuth.mts workflow-plan domain-triage example.test --json
43
63
  node bin/whoisleuth.mts diff first-lookup.json second-lookup.json --json
64
+ node bin/whoisleuth.mts timeline first-observation.json second-observation.json latest-observation.json --json
44
65
  node bin/whoisleuth.mts registry-support example.test --json
45
66
  node bin/whoisleuth.mts doctor
67
+ node bin/whoisleuth.mts commands --json
46
68
  node bin/whoisleuth.mts completion zsh
69
+ node bin/whoisleuth.mts completion powershell
47
70
  node bin/whoisleuth.mts manual | man -l -
48
71
  ```
49
72
 
@@ -51,6 +74,13 @@ Fast lookup is the default. Deep collection must be requested explicitly and
51
74
  can disclose the target to additional authoritative or first-party network
52
75
  sources. Missing, partial, rate-limited, and unsupported sources remain
53
76
  explicit and are never interpreted as evidence of safety or absence.
77
+ `lookup --plan` classifies a target and explains the selected source families
78
+ and disclosures without making a network request. `commands --json` exposes the
79
+ installed command contracts for local wrappers without running collection.
80
+ Focused command help and the generated manual label every operation as offline
81
+ or networked and state its target, input, and concurrency boundaries. The
82
+ packaged [dual-use disclosure](DISCLOSURE) defines the supported defensive use
83
+ of those capabilities.
54
84
 
55
85
  Interactive output uses restrained semantic colour, width-aware wrapping, and
56
86
  stderr-only progress for slower collection. Redirected and machine-readable
@@ -65,8 +95,15 @@ the partial-result exit code. Ctrl-C suppresses partial final output and
65
95
  returns exit code 130; a second interrupt performs best-effort temporary-file
66
96
  cleanup before exiting immediately.
67
97
 
68
- Bulk also supports fixed-column CSV, domain-only output, registered and
69
- inconclusive output filters, and bounded A, AAAA, NS, and MX summaries. Discover
98
+ The offline `timeline` command orders 2 to 20 saved Lookup documents for one
99
+ domain and compares each adjacent observation. It retains observation times and
100
+ normalized field states but no input filenames or raw registry payloads.
101
+ Observed differences remain separate from missing, unavailable, and changed
102
+ collection conditions.
103
+
104
+ Bulk also supports fixed-column CSV, domain-only or exact-query output,
105
+ registered, inconclusive, and hard-failure output filters, and bounded A, AAAA,
106
+ NS, and MX summaries. Discover
70
107
  supports local custom dictionaries and private snapshot comparison suitable
71
108
  for an operator-managed scheduler. The separate networked `discover-scan`
72
109
  command can collect a deterministic bounded subset in chunks, retain mutation
@@ -79,6 +116,7 @@ favicon, technology, and TLS evidence, while saved Bulk output can be reviewed
79
116
  for passive MX, null MX, SPF, DMARC, and shared-provider context.
80
117
 
81
118
  See `docs/cli.md` in the package for the complete command, privacy, output, and
82
- evidence-contract reference.
119
+ evidence-contract reference. Report suspected vulnerabilities through the
120
+ private channel described in `SECURITY.md`.
83
121
 
84
122
  Copyright 2026 slicedearth. Licensed under AGPL-3.0-only.
package/SECURITY.md ADDED
@@ -0,0 +1,35 @@
1
+ # Security policy
2
+
3
+ ## Supported versions
4
+
5
+ Security fixes are applied to the latest released WHOISleuth application and
6
+ CLI version. Older releases are not maintained as separate security branches.
7
+ Upgrade to the latest release before reporting a problem that may already have
8
+ been corrected.
9
+
10
+ ## Report a vulnerability privately
11
+
12
+ Use [GitHub private vulnerability reporting](https://github.com/slicedearth/whoisleuth/security/advisories/new)
13
+ for suspected vulnerabilities in the application, CLI, release process, or
14
+ deployment defaults. Do not open a public issue for an undisclosed security
15
+ problem.
16
+
17
+ Include the affected version, the smallest reproducible example, expected and
18
+ observed behaviour, and the likely impact. Use reserved or synthetic domains
19
+ and remove credentials, session material, personal information, private case
20
+ data, and raw registration records before submitting evidence.
21
+
22
+ Ordinary defects, feature requests, and questions that do not expose a security
23
+ weakness can use the public issue tracker.
24
+
25
+ ## Scope and response
26
+
27
+ Reports are assessed against WHOISleuth's documented trust boundaries,
28
+ including its local-first workspace, bounded network collection, shared
29
+ deployment password, and optional hosted monitoring. A source outage, partial
30
+ registry response, or heuristic disagreement is not by itself a security
31
+ vulnerability, but unsafe handling of those states may be.
32
+
33
+ Confirmed reports will be handled through a private advisory until a fix and
34
+ coordinated disclosure are ready. No bounty or guaranteed response time is
35
+ offered.
package/cli/arguments.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  import { parseInspectArchiveArguments, parseSignArtifactArguments, parseVerifySignatureArguments, } from "./evidence-command-arguments.mjs";
2
2
  import { CliUsageError } from "./errors.mjs";
3
+ import { INVESTIGATION_PLAN_RECIPES, } from "./investigation-plan.mjs";
3
4
  const MAX_CLI_ARGUMENTS = 32;
4
5
  const MAX_CLI_ARGUMENT_LENGTH = 1024;
5
6
  const CLI_COMMANDS = [
6
7
  'completion',
7
8
  'doctor',
9
+ 'commands',
8
10
  'manual',
9
11
  'lookup',
10
12
  'bulk',
@@ -24,7 +26,11 @@ const CLI_COMMANDS = [
24
26
  'compare',
25
27
  'page-compare',
26
28
  'mail-review',
29
+ 'review-evidence',
30
+ 'domain-control',
31
+ 'workflow-plan',
27
32
  'diff',
33
+ 'timeline',
28
34
  'export',
29
35
  ];
30
36
  function boundedArgument(value) {
@@ -108,6 +114,8 @@ function parseCliArgumentsCore(argv) {
108
114
  return parseCompletionArguments(argv.slice(1));
109
115
  if (command === 'doctor')
110
116
  return parseDoctorArguments(argv.slice(1));
117
+ if (command === 'commands')
118
+ return parseCommandsArguments(argv.slice(1));
111
119
  if (command === 'manual')
112
120
  return parseManualArguments(argv.slice(1));
113
121
  if (command === 'ct-search')
@@ -142,8 +150,16 @@ function parseCliArgumentsCore(argv) {
142
150
  return parsePageCompareArguments(argv.slice(1));
143
151
  if (command === 'mail-review')
144
152
  return parseMailReviewArguments(argv.slice(1));
153
+ if (command === 'review-evidence')
154
+ return parseReviewEvidenceArguments(argv.slice(1));
155
+ if (command === 'domain-control')
156
+ return parseDomainControlArguments(argv.slice(1));
157
+ if (command === 'workflow-plan')
158
+ return parseWorkflowPlanArguments(argv.slice(1));
145
159
  if (command === 'diff')
146
160
  return parseDiffArguments(argv.slice(1));
161
+ if (command === 'timeline')
162
+ return parseTimelineArguments(argv.slice(1));
147
163
  if (command === 'export')
148
164
  return parseExportArguments(argv.slice(1));
149
165
  let query = null;
@@ -156,6 +172,7 @@ function parseCliArgumentsCore(argv) {
156
172
  let detailSet = false;
157
173
  let strictExit = false;
158
174
  let events = false;
175
+ let plan = false;
159
176
  for (const argument of argv.slice(1)) {
160
177
  if (argument === '--json') {
161
178
  if (output !== 'terminal')
@@ -195,6 +212,11 @@ function parseCliArgumentsCore(argv) {
195
212
  throw new CliUsageError('--events may be supplied only once.');
196
213
  events = true;
197
214
  }
215
+ else if (argument === '--plan') {
216
+ if (plan)
217
+ throw new CliUsageError('--plan may be supplied only once.');
218
+ plan = true;
219
+ }
198
220
  else if (argument === '--quiet')
199
221
  quiet = true;
200
222
  else if (argument === '--no-color')
@@ -210,11 +232,16 @@ function parseCliArgumentsCore(argv) {
210
232
  throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
211
233
  if (detailSet && output !== 'terminal')
212
234
  throw new CliUsageError('--summary and --verbose apply only to terminal output.');
213
- return { action: 'lookup', query, output, deep, detail, strictExit, events, quiet, color };
235
+ if (plan && (output === 'markdown' || output === 'html'))
236
+ throw new CliUsageError('--plan supports terminal or JSON output only.');
237
+ if (plan && (detailSet || strictExit || events || quiet)) {
238
+ throw new CliUsageError('--plan cannot be combined with detail, strict-exit, event, or quiet options.');
239
+ }
240
+ return { action: 'lookup', query, output, deep, detail, strictExit, events, plan, quiet, color };
214
241
  }
215
242
  function parseCompletionArguments(argv) {
216
- if (argv.length !== 1 || !['bash', 'zsh', 'fish'].includes(argv[0] || '')) {
217
- throw new CliUsageError('completion requires exactly one shell: bash, zsh, or fish.');
243
+ if (argv.length !== 1 || !['bash', 'zsh', 'fish', 'powershell'].includes(argv[0] || '')) {
244
+ throw new CliUsageError('completion requires exactly one shell: bash, zsh, fish, or powershell.');
218
245
  }
219
246
  return { action: 'completion', shell: argv[0] };
220
247
  }
@@ -223,6 +250,27 @@ function parseManualArguments(argv) {
223
250
  throw new CliUsageError('manual does not accept command arguments.');
224
251
  return { action: 'manual' };
225
252
  }
253
+ function parseCommandsArguments(argv) {
254
+ let output = 'terminal';
255
+ let quiet = false;
256
+ let color = true;
257
+ for (const argument of argv) {
258
+ if (argument === '--json') {
259
+ if (output !== 'terminal')
260
+ throw new CliUsageError('--json may be supplied only once.');
261
+ output = 'json';
262
+ }
263
+ else if (argument === '--quiet')
264
+ quiet = true;
265
+ else if (argument === '--no-color')
266
+ color = false;
267
+ else
268
+ throw new CliUsageError(`Unknown option "${argument}".`);
269
+ }
270
+ if (quiet && output !== 'terminal')
271
+ throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
272
+ return { action: 'commands', output, quiet, color };
273
+ }
226
274
  function parseDoctorArguments(argv) {
227
275
  let network = false;
228
276
  let output = 'terminal';
@@ -266,10 +314,18 @@ function parseBulkArguments(argv) {
266
314
  const argument = argv[index];
267
315
  if (argument === undefined)
268
316
  break;
269
- if (argument === '--json' || argument === '--jsonl' || argument === '--csv' || argument === '--domains') {
317
+ if (argument === '--json' || argument === '--jsonl' || argument === '--csv' || argument === '--domains' || argument === '--queries') {
270
318
  if (output !== 'terminal')
271
319
  throw new CliUsageError('Choose only one output format.');
272
- output = argument === '--json' ? 'json' : argument === '--jsonl' ? 'jsonl' : argument === '--csv' ? 'csv' : 'domains';
320
+ output = argument === '--json'
321
+ ? 'json'
322
+ : argument === '--jsonl'
323
+ ? 'jsonl'
324
+ : argument === '--csv'
325
+ ? 'csv'
326
+ : argument === '--domains'
327
+ ? 'domains'
328
+ : 'queries';
273
329
  }
274
330
  else if (argument === '--deep' || argument === '--fast') {
275
331
  if (scanMode)
@@ -305,10 +361,14 @@ function parseBulkArguments(argv) {
305
361
  throw new CliUsageError('--events may be supplied only once.');
306
362
  events = true;
307
363
  }
308
- else if (argument === '--registered-only' || argument === '--inconclusive-only') {
364
+ else if (argument === '--registered-only' || argument === '--inconclusive-only' || argument === '--errors-only') {
309
365
  if (filter !== 'all')
310
- throw new CliUsageError('--registered-only and --inconclusive-only are mutually exclusive and may be supplied only once.');
311
- filter = argument === '--registered-only' ? 'registered' : 'inconclusive';
366
+ throw new CliUsageError('Bulk output filters are mutually exclusive and may be supplied only once.');
367
+ filter = argument === '--registered-only'
368
+ ? 'registered'
369
+ : argument === '--inconclusive-only'
370
+ ? 'inconclusive'
371
+ : 'errors';
312
372
  }
313
373
  else if (argument === '--quiet')
314
374
  quiet = true;
@@ -843,6 +903,88 @@ function parseMailReviewArguments(argv) {
843
903
  throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
844
904
  return { action: 'mail-review', source, output, quiet, color };
845
905
  }
906
+ function parseReviewEvidenceArguments(argv) {
907
+ let source = null;
908
+ let output = 'terminal';
909
+ let quiet = false;
910
+ let color = true;
911
+ for (const argument of argv) {
912
+ if (argument === '--json') {
913
+ if (output !== 'terminal')
914
+ throw new CliUsageError('--json may be supplied only once.');
915
+ output = 'json';
916
+ }
917
+ else if (argument === '--quiet')
918
+ quiet = true;
919
+ else if (argument === '--no-color')
920
+ color = false;
921
+ else if (argument.startsWith('-'))
922
+ throw new CliUsageError(`Unknown option "${argument}".`);
923
+ else if (source === null)
924
+ source = argument;
925
+ else
926
+ throw new CliUsageError('review-evidence accepts one optional versioned JSON input file.');
927
+ }
928
+ if (quiet && output !== 'terminal')
929
+ throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
930
+ return { action: 'review-evidence', source, output, quiet, color };
931
+ }
932
+ function parseDomainControlArguments(argv) {
933
+ let source = null;
934
+ let output = 'terminal';
935
+ let quiet = false;
936
+ let color = true;
937
+ for (const argument of argv) {
938
+ if (argument === '--json') {
939
+ if (output !== 'terminal')
940
+ throw new CliUsageError('--json may be supplied only once.');
941
+ output = 'json';
942
+ }
943
+ else if (argument === '--quiet')
944
+ quiet = true;
945
+ else if (argument === '--no-color')
946
+ color = false;
947
+ else if (argument.startsWith('-'))
948
+ throw new CliUsageError(`Unknown option "${argument}".`);
949
+ else if (source === null)
950
+ source = argument;
951
+ else
952
+ throw new CliUsageError('domain-control accepts one optional versioned JSON input file.');
953
+ }
954
+ if (quiet && output !== 'terminal')
955
+ throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
956
+ return { action: 'domain-control', source, output, quiet, color };
957
+ }
958
+ function parseWorkflowPlanArguments(argv) {
959
+ const positional = [];
960
+ let output = 'terminal';
961
+ let quiet = false;
962
+ let color = true;
963
+ for (const argument of argv) {
964
+ if (argument === '--json') {
965
+ if (output !== 'terminal')
966
+ throw new CliUsageError('--json may be supplied only once.');
967
+ output = 'json';
968
+ }
969
+ else if (argument === '--quiet')
970
+ quiet = true;
971
+ else if (argument === '--no-color')
972
+ color = false;
973
+ else if (argument.startsWith('-'))
974
+ throw new CliUsageError(`Unknown option "${argument}".`);
975
+ else
976
+ positional.push(argument);
977
+ }
978
+ if (positional.length !== 2)
979
+ throw new CliUsageError('workflow-plan requires one fixed recipe and one subject.');
980
+ const recipe = positional[0];
981
+ if (!INVESTIGATION_PLAN_RECIPES.includes(recipe)) {
982
+ throw new CliUsageError(`workflow-plan recipe must be one of: ${INVESTIGATION_PLAN_RECIPES.join(', ')}.`);
983
+ }
984
+ if (quiet && output !== 'terminal')
985
+ throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
986
+ return { action: 'workflow-plan', recipe: recipe, subject: positional[1], output, quiet, color };
987
+ }
846
988
  function parseDiffArguments(argv) {
847
989
  let leftSource = null;
848
990
  let rightSource = null;
@@ -876,6 +1018,36 @@ function parseDiffArguments(argv) {
876
1018
  throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
877
1019
  return { action: 'diff', leftSource, rightSource, output, quiet, color };
878
1020
  }
1021
+ function parseTimelineArguments(argv) {
1022
+ const sources = [];
1023
+ let output = 'terminal';
1024
+ let quiet = false;
1025
+ let color = true;
1026
+ for (const argument of argv) {
1027
+ if (argument === '--json') {
1028
+ if (output !== 'terminal')
1029
+ throw new CliUsageError('--json may be supplied only once.');
1030
+ output = 'json';
1031
+ }
1032
+ else if (argument === '--quiet')
1033
+ quiet = true;
1034
+ else if (argument === '--no-color')
1035
+ color = false;
1036
+ else if (argument.startsWith('-'))
1037
+ throw new CliUsageError(`Unknown option "${argument}".`);
1038
+ else
1039
+ sources.push(argument);
1040
+ }
1041
+ if (sources.length < 2 || sources.length > 20) {
1042
+ throw new CliUsageError('timeline requires from 2 to 20 saved lookup JSON files.');
1043
+ }
1044
+ if (new Set(sources).size !== sources.length) {
1045
+ throw new CliUsageError('timeline input files must be different.');
1046
+ }
1047
+ if (quiet && output !== 'terminal')
1048
+ throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
1049
+ return { action: 'timeline', sources, output, quiet, color };
1050
+ }
879
1051
  function parseRegistrySupportArguments(argv) {
880
1052
  let target = null;
881
1053
  let output = 'terminal';
@@ -7,6 +7,8 @@ import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, } from "../f
7
7
  import { decryptWorkspaceArchive, inspectEncryptedWorkspaceArchive, isEncryptedWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive-crypto.js";
8
8
  import { WORKSPACE_ARCHIVE_SCHEMA, readWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive.js";
9
9
  import { sha256ArtifactDigest } from "../frontend/src/lib/analysis/artifact-integrity.js";
10
+ import { SAVED_LOOKUP_SCHEMA, SAVED_LOOKUP_SCHEMA_VERSION, parseSavedLookupDocument, } from "./saved-lookup.mjs";
11
+ import { DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
10
12
  export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
11
13
  export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 1;
12
14
  export const MAX_OFFLINE_ARTIFACT_BYTES = 15 * 1024 * 1024;
@@ -16,6 +18,7 @@ const SIGNED_ARTIFACT_VERSIONS = Object.freeze({
16
18
  [BULK_DOMAIN_COMPARISON_SCHEMA]: new Set([BULK_DOMAIN_COMPARISON_VERSION]),
17
19
  [BULK_MAIL_EXPOSURE_SCHEMA]: new Set([BULK_MAIL_EXPOSURE_VERSION]),
18
20
  [BULK_REVIEW_MANIFEST_SCHEMA]: new Set([BULK_REVIEW_MANIFEST_VERSION]),
21
+ [DOMAIN_CONTROL_MANIFEST_SCHEMA]: new Set([DOMAIN_CONTROL_MANIFEST_VERSION]),
19
22
  });
20
23
  function record(value) {
21
24
  return value && typeof value === 'object' && !Array.isArray(value)
@@ -93,6 +96,8 @@ async function verifySignedArtifact(raw, value, schema, version) {
93
96
  if (await sha256ArtifactDigest(unsigned) !== integrity.digestSha256) {
94
97
  throw new TypeError('The signed review artifact failed its SHA-256 integrity check.');
95
98
  }
99
+ if (schema === DOMAIN_CONTROL_MANIFEST_SCHEMA)
100
+ verifyDomainControlManifest(value);
96
101
  return Object.freeze({
97
102
  schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
98
103
  version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
@@ -186,6 +191,33 @@ export async function verifyOfflineArtifact(raw, options = {}) {
186
191
  ]),
187
192
  });
188
193
  }
194
+ if (schema === SAVED_LOOKUP_SCHEMA) {
195
+ if (version !== SAVED_LOOKUP_SCHEMA_VERSION) {
196
+ throw new TypeError('This saved Lookup document version is not supported.');
197
+ }
198
+ const document = parseSavedLookupDocument(raw, { label: 'Saved Lookup artifact' });
199
+ return Object.freeze({
200
+ schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
201
+ version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
202
+ artifact: Object.freeze({ kind: 'saved_lookup', schema, version }),
203
+ state: 'structure_valid',
204
+ valid: true,
205
+ checks: Object.freeze({
206
+ structure: 'verified',
207
+ contentIntegrity: 'not_checked',
208
+ authenticatedEncryption: 'not_applicable',
209
+ }),
210
+ summary: Object.freeze({
211
+ inputBytes: inputBytes(raw),
212
+ sectionCount: null,
213
+ recordCount: null,
214
+ ciphertextBytes: null,
215
+ }),
216
+ limitations: Object.freeze([
217
+ `The saved ${document.mode} Lookup matches its versioned structural contract, but this document format has no embedded checksum or signature. Structural validity does not prove that its evidence is accurate, current, or unchanged since collection.`,
218
+ ]),
219
+ });
220
+ }
189
221
  return verifySignedArtifact(raw, value, schema, version);
190
222
  }
191
223
  export function formatOfflineArtifactVerification(report) {
@@ -3,7 +3,7 @@ import { classifyQuery } from "../lib/classify.mjs";
3
3
  import { runUnifiedLookup } from "../lib/lookup.mjs";
4
4
  import { createBulkCheckpointWriter } from "./bulk-checkpoint.mjs";
5
5
  import { MAX_BULK_INPUT_BYTES, parseBulkQueries, readTextStreamBounded, runBulkLookups, } from "./bulk.mjs";
6
- import { formatBulkCsv, formatBulkDomainList, selectBulkItems } from "./bulk-output.mjs";
6
+ import { formatBulkCsv, formatBulkDomainList, formatBulkQueryList, selectBulkItems } from "./bulk-output.mjs";
7
7
  import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
8
8
  import EXIT_CODES from "./exit-codes.mjs";
9
9
  import { buildCliBulkDocument, formatJsonDocument, formatJsonLines } from "./formatters/json.mjs";
@@ -91,6 +91,8 @@ async function runBulkCommand(args, dependencies, context) {
91
91
  context.writeStdout(formatBulkCsv(selectedItems));
92
92
  else if (args.output === 'domains')
93
93
  context.writeStdout(formatBulkDomainList(selectedItems));
94
+ else if (args.output === 'queries')
95
+ context.writeStdout(formatBulkQueryList(selectedItems));
94
96
  else
95
97
  context.writeStdout(context.terminal(formatTerminalBulk(selectedItems, metadata), args.color));
96
98
  }
@@ -18,6 +18,8 @@ function selectBulkItems(items, filter) {
18
18
  if (filter === 'all')
19
19
  return [...items];
20
20
  return items.filter((item) => {
21
+ if (filter === 'errors')
22
+ return !item.ok;
21
23
  if (!item.ok)
22
24
  return filter === 'inconclusive';
23
25
  const state = availabilityState(item);
@@ -109,4 +111,7 @@ function formatBulkDomainList(items) {
109
111
  : []);
110
112
  return values.length ? `${[...new Set(values)].join('\n')}\n` : '';
111
113
  }
112
- export { REGISTERED_STATES, availabilityState, bulkDnsSummary, formatBulkCsv, formatBulkDomainList, selectBulkItems, };
114
+ function formatBulkQueryList(items) {
115
+ return items.length ? `${items.map((item) => item.query).join('\n')}\n` : '';
116
+ }
117
+ export { REGISTERED_STATES, availabilityState, bulkDnsSummary, formatBulkCsv, formatBulkDomainList, formatBulkQueryList, selectBulkItems, };
package/cli/bulk.mjs CHANGED
@@ -1,9 +1,8 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { abortable } from "../lib/abort.mjs";
3
3
  import { CliUsageError } from "./arguments.mjs";
4
+ import { MAX_DEEP_BULK_QUERIES, MAX_FAST_BULK_QUERIES, } from "../lib/bulk-limits.mjs";
4
5
  const MAX_BULK_INPUT_BYTES = 1024 * 1024;
5
- const MAX_FAST_BULK_QUERIES = 500;
6
- const MAX_DEEP_BULK_QUERIES = 50;
7
6
  async function readTextStreamBounded(stream, limit = MAX_BULK_INPUT_BYTES) {
8
7
  if (!stream || stream.isTTY)
9
8
  return '';
@@ -0,0 +1,33 @@
1
+ export const CLI_COMMAND_CATALOGUE_SCHEMA = 'whoisleuth.cli.command-catalogue';
2
+ export const CLI_COMMAND_CATALOGUE_VERSION = 1;
3
+ function buildCliCommandCatalogue(options) {
4
+ return Object.freeze({
5
+ schema: CLI_COMMAND_CATALOGUE_SCHEMA,
6
+ version: CLI_COMMAND_CATALOGUE_VERSION,
7
+ packageVersion: options.packageVersion,
8
+ commands: Object.freeze(options.commands.map((command) => {
9
+ const detail = options.details[command];
10
+ return Object.freeze({
11
+ command,
12
+ description: detail.description,
13
+ usage: options.usage[command],
14
+ example: detail.example,
15
+ collection: options.collections[command],
16
+ boundary: detail.boundary,
17
+ });
18
+ })),
19
+ });
20
+ }
21
+ function formatCliCommandCatalogue(catalogue) {
22
+ const lines = [
23
+ `WHOISleuth CLI ${catalogue.packageVersion}`,
24
+ 'Command catalogue',
25
+ '',
26
+ ];
27
+ for (const entry of catalogue.commands) {
28
+ lines.push(`${entry.command} [${entry.collection.mode}]`, ` ${entry.description}`, ` ${entry.usage}`);
29
+ }
30
+ lines.push('', 'Run "whoisleuth <command> --help" for examples and collection boundaries.');
31
+ return `${lines.join('\n')}\n`;
32
+ }
33
+ export { buildCliCommandCatalogue, formatCliCommandCatalogue };