@slicedearth/whoisleuth-cli 1.32.2 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,6 +23,7 @@ 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";
27
29
  import { runLookupCommand } from "./lookup-command-runner.mjs";
@@ -65,9 +67,10 @@ Review saved evidence:
65
67
  page-compare Compare saved static page and TLS evidence.
66
68
  mail-review Review saved passive mail exposure evidence.
67
69
  diff Compare two saved domain observations.
70
+ timeline Compare a sequence of observations for one domain.
68
71
  export Convert a saved lookup into an evidence report.
69
72
  inspect-archive Inspect a workspace archive, redacted by default.
70
- verify-artifact Validate an archive, packet, or manifest offline.
73
+ verify-artifact Validate saved evidence or an integrity envelope offline.
71
74
 
72
75
  Integrity and calibration:
73
76
  sign-artifact Sign one reviewed packet or manifest locally.
@@ -76,7 +79,8 @@ Integrity and calibration:
76
79
 
77
80
  Terminal:
78
81
  doctor Check the local runtime; network tests require --network.
79
- completion Print a completion script for bash, zsh, or fish.
82
+ commands List command contracts for people or local tooling.
83
+ completion Print completion for bash, zsh, fish, or PowerShell.
80
84
  manual Print the generated manual page.
81
85
 
82
86
  Run "whoisleuth <command> --help" for focused usage and an example.
@@ -89,11 +93,12 @@ Copyright 2026 slicedearth. Licensed under AGPL-3.0-only.
89
93
  Source and licence: https://github.com/slicedearth/whoisleuth
90
94
  `;
91
95
  const COMMAND_USAGE = Object.freeze({
92
- completion: 'whoisleuth completion <bash|zsh|fish>',
96
+ completion: 'whoisleuth completion <bash|zsh|fish|powershell>',
93
97
  doctor: 'whoisleuth doctor [--network] [--json] [--quiet] [--no-color]',
98
+ commands: 'whoisleuth commands [--json] [--quiet] [--no-color]',
94
99
  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]',
100
+ lookup: 'whoisleuth lookup <domain|IP|ASN> [--json|--markdown|--html] [--fast|--deep] [--plan] [--summary|--verbose] [--strict-exit] [--events] [--quiet] [--no-color]',
101
+ 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
102
  'ct-search': 'whoisleuth ct-search <keyword> [--json] [--quiet] [--no-color]',
98
103
  discover: 'whoisleuth discover <brand|domain> [--tlds <list>] [--preset <name>|--families <ids>] [--keyboard <layout>] [--dictionary <file>] [--snapshot <file>] [--json|--jsonl|--domains]',
99
104
  '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]',
@@ -111,6 +116,7 @@ const COMMAND_USAGE = Object.freeze({
111
116
  'page-compare': 'whoisleuth page-compare <left.json> <right.json> [--json] [--quiet] [--no-color]',
112
117
  'mail-review': 'whoisleuth mail-review [bulk.json|bulk.jsonl] [--json] [--quiet] [--no-color]',
113
118
  diff: 'whoisleuth diff <left.json> <right.json> [--json] [--quiet] [--no-color]',
119
+ timeline: 'whoisleuth timeline <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
114
120
  export: 'whoisleuth export [lookup.json] [--markdown|--html|--compact]',
115
121
  });
116
122
  const COMMAND_DETAILS = Object.freeze({
@@ -124,6 +130,11 @@ const COMMAND_DETAILS = Object.freeze({
124
130
  example: 'whoisleuth doctor --json',
125
131
  boundary: 'The default check is offline. Public DNS and port 43 checks run only when --network is explicitly supplied.',
126
132
  },
133
+ commands: {
134
+ description: 'List the installed command contracts in terminal or versioned JSON form.',
135
+ example: 'whoisleuth commands --json',
136
+ boundary: 'Catalogue generation is offline. It reports declared command modes and limits without executing collection or inspecting local evidence.',
137
+ },
127
138
  manual: {
128
139
  description: 'Print a generated roff manual page for local installation.',
129
140
  example: 'whoisleuth manual | man -l -',
@@ -180,7 +191,7 @@ const COMMAND_DETAILS = Object.freeze({
180
191
  boundary: 'Calibration is offline and diagnostic. It never trains, tunes, or changes the scoring model automatically.',
181
192
  },
182
193
  'verify-artifact': {
183
- description: 'Validate a supported archive, packet, or manifest without printing evidence contents.',
194
+ description: 'Validate a supported archive, packet, manifest, or saved Lookup without printing evidence contents.',
184
195
  example: 'whoisleuth verify-artifact workspace.json --json',
185
196
  boundary: 'Verification is offline and redacted. Encrypted archives require an explicitly supplied passphrase file.',
186
197
  },
@@ -224,15 +235,48 @@ const COMMAND_DETAILS = Object.freeze({
224
235
  example: 'whoisleuth diff first.json second.json --json',
225
236
  boundary: 'Comparison is offline. Missing, unavailable, equal, and different evidence remain separate states.',
226
237
  },
238
+ timeline: {
239
+ description: 'Build an ordered same-domain history from saved Lookup observations.',
240
+ example: 'whoisleuth timeline first.json second.json latest.json --json',
241
+ 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.',
242
+ },
227
243
  export: {
228
244
  description: 'Convert one saved lookup into a versioned evidence report.',
229
245
  example: 'whoisleuth export lookup.json --markdown',
230
246
  boundary: 'Exports preserve source attribution and limitations. Compact output intentionally omits raw registry payloads.',
231
247
  },
232
248
  });
249
+ const COMMAND_COLLECTION = Object.freeze({
250
+ completion: { mode: 'offline', scope: 'Prints one static script and changes no shell configuration.' },
251
+ doctor: { mode: 'network', scope: 'Network access is opt-in with --network and is limited to fixed public DNS, HTTPS, and WHOIS diagnostics.' },
252
+ commands: { mode: 'offline', scope: 'Reads the embedded command catalogue and performs no collection.' },
253
+ manual: { mode: 'offline', scope: 'Builds documentation from the embedded command catalogue.' },
254
+ lookup: { mode: 'network', scope: 'Accepts one target. Fast is the default; deep collection must be selected explicitly.' },
255
+ bulk: { mode: 'network', scope: 'Accepts at most 500 fast or 50 deep targets, with concurrency capped at 8 fast or 3 deep.' },
256
+ 'ct-search': { mode: 'network', scope: 'Accepts one bounded search keyword and queries the fixed certificate-transparency source.' },
257
+ discover: { mode: 'offline', scope: 'Generates a bounded candidate set from local rules, dictionaries, and optional saved snapshots.' },
258
+ 'discover-scan': { mode: 'network', scope: 'Scans at most 500 fast or 50 deep candidates, with concurrency capped at 8 fast or 3 deep.' },
259
+ posture: { mode: 'network', scope: 'Accepts one domain and performs bounded DNS queries only.' },
260
+ http: { mode: 'network', scope: 'Accepts one domain and follows only the bounded SSRF-guarded homepage redirect workflow.' },
261
+ tls: { mode: 'network', scope: 'Accepts one public hostname and opens one bounded certificate connection.' },
262
+ 'registry-support': { mode: 'offline', scope: 'Reads the embedded registry capability catalogue for one domain or suffix.' },
263
+ 'risk-calibrate': { mode: 'offline', scope: 'Reads one bounded reviewed-label dataset and changes no model or evidence.' },
264
+ 'verify-artifact': { mode: 'offline', scope: 'Reads one selected bounded archive, packet, manifest, or saved Lookup document.' },
265
+ '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.' },
267
+ 'verify-signature': { mode: 'offline', scope: 'Reads one selected signed package and optional local public key.' },
268
+ 'source-report': { mode: 'offline', scope: 'Reads bounded saved evidence and emits target-free source reliability data.' },
269
+ compare: { mode: 'offline', scope: 'Reads one saved Lookup and compares its separately attributed registry publications.' },
270
+ 'page-compare': { mode: 'offline', scope: 'Reads two saved Lookup documents and executes no page code.' },
271
+ 'mail-review': { mode: 'offline', scope: 'Reads one saved Bulk result and sends no DNS or SMTP traffic.' },
272
+ diff: { mode: 'offline', scope: 'Reads two saved Lookup documents for different domains.' },
273
+ timeline: { mode: 'offline', scope: 'Reads 2 to 20 saved observations for one domain, capped at 32 MiB in total.' },
274
+ export: { mode: 'offline', scope: 'Reads one saved Lookup and writes one bounded report.' },
275
+ });
233
276
  function commandHelp(command) {
234
277
  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`;
278
+ const collection = COMMAND_COLLECTION[command];
279
+ 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
280
  }
237
281
  async function readStdinBounded(stream, limit = MAX_STDIN_BYTES) {
238
282
  if (!stream || stream.isTTY)
@@ -341,8 +385,28 @@ async function runParsedCli(args, dependencies = {}) {
341
385
  write(stdout, buildShellCompletion(args.shell));
342
386
  return EXIT_CODES.SUCCESS;
343
387
  }
388
+ if (args.action === 'commands') {
389
+ const catalogue = buildCliCommandCatalogue({
390
+ commands: CLI_COMMANDS,
391
+ collections: COMMAND_COLLECTION,
392
+ details: COMMAND_DETAILS,
393
+ usage: COMMAND_USAGE,
394
+ packageVersion: VERSION,
395
+ });
396
+ if (!args.quiet)
397
+ write(stdout, args.output === 'json'
398
+ ? formatJsonDocument(catalogue)
399
+ : terminal(formatCliCommandCatalogue(catalogue), args.color));
400
+ return EXIT_CODES.SUCCESS;
401
+ }
344
402
  if (args.action === 'manual') {
345
- write(stdout, buildCliManual({ commands: CLI_COMMANDS, details: COMMAND_DETAILS, usage: COMMAND_USAGE, version: VERSION }));
403
+ write(stdout, buildCliManual({
404
+ commands: CLI_COMMANDS,
405
+ collections: COMMAND_COLLECTION,
406
+ details: COMMAND_DETAILS,
407
+ usage: COMMAND_USAGE,
408
+ version: VERSION,
409
+ }));
346
410
  return EXIT_CODES.SUCCESS;
347
411
  }
348
412
  if (args.action === 'doctor') {
@@ -353,10 +417,11 @@ async function runParsedCli(args, dependencies = {}) {
353
417
  network: args.network,
354
418
  presentation: terminalPresentation(stdout, args.color, environment),
355
419
  ...(dependencies.resolvePublicAddresses ? { resolveAddresses: dependencies.resolvePublicAddresses } : {}),
420
+ ...(dependencies.safeFetch ? { fetchHttps: dependencies.safeFetch } : { fetchHttps: safeFetch }),
356
421
  ...(dependencies.whoisQuery ? { queryWhois: dependencies.whoisQuery } : {}),
357
422
  });
358
423
  const report = args.network
359
- ? await withProgress('Checking public DNS and WHOIS connectivity', buildReport)
424
+ ? await withProgress('Checking public DNS, HTTPS, and WHOIS connectivity', buildReport)
360
425
  : await buildReport();
361
426
  if (!args.quiet) {
362
427
  write(stdout, args.output === 'json'
@@ -605,6 +670,36 @@ async function runParsedCli(args, dependencies = {}) {
605
670
  : terminal(formatCliLookupDiff(document), args.color));
606
671
  return EXIT_CODES.SUCCESS;
607
672
  }
673
+ if (args.action === 'timeline') {
674
+ failureLabel = 'Lookup observation timeline';
675
+ const readTimelineInput = dependencies.readDiffInput || (async (source) => (readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), {
676
+ limit: MAX_SAVED_LOOKUP_INPUT_BYTES,
677
+ label: 'Lookup timeline input',
678
+ })));
679
+ const inputs = [];
680
+ let totalBytes = 0;
681
+ try {
682
+ for (const source of args.sources) {
683
+ const input = await readTimelineInput(source);
684
+ totalBytes += Buffer.byteLength(input, 'utf8');
685
+ if (totalBytes > MAX_LOOKUP_TIMELINE_INPUT_BYTES) {
686
+ throw new CliUsageError(`Lookup timeline input is limited to ${MAX_LOOKUP_TIMELINE_INPUT_BYTES} bytes in total.`);
687
+ }
688
+ inputs.push(input);
689
+ }
690
+ }
691
+ catch (error) {
692
+ if (error instanceof CliUsageError)
693
+ throw error;
694
+ throw new CliUsageError(`Could not read Lookup timeline input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
695
+ }
696
+ const document = buildCliLookupTimeline(inputs, commandContext.now());
697
+ if (!args.quiet)
698
+ write(stdout, args.output === 'json'
699
+ ? formatJsonDocument(document)
700
+ : terminal(formatCliLookupTimeline(document), args.color));
701
+ return EXIT_CODES.SUCCESS;
702
+ }
608
703
  if (args.action === 'export') {
609
704
  failureLabel = 'Evidence export';
610
705
  let input;
package/docs/cli.md CHANGED
@@ -8,7 +8,9 @@ hosted WHOISleuth deployment.
8
8
  source location. It groups commands by investigation, discovery, saved-evidence,
9
9
  integrity, and calibration workflows. `whoisleuth <command> --help` displays the
10
10
  command's purpose, focused invocation, example, and collection boundary without
11
- printing the full command list. Packaged copies include `LICENSE`, `NOTICE`, and
11
+ printing the full command list. `whoisleuth commands --json` exposes those same
12
+ contracts as a stable local catalogue for wrappers and terminal tooling.
13
+ Packaged copies include `LICENSE`, `NOTICE`, and
12
14
  `TRADEMARKS.md` alongside the CLI documentation.
13
15
 
14
16
  ## Installation
@@ -17,11 +19,18 @@ Public releases require Node.js 24 or later and install the `whoisleuth`
17
19
  command:
18
20
 
19
21
  ```bash
20
- npm exec --yes --package=@slicedearth/whoisleuth-cli -- whoisleuth --help
21
- npm install --global @slicedearth/whoisleuth-cli
22
+ npm exec --yes --ignore-scripts --package=@slicedearth/whoisleuth-cli -- whoisleuth --help
23
+ npm install --global --ignore-scripts @slicedearth/whoisleuth-cli
22
24
  whoisleuth doctor
23
25
  ```
24
26
 
27
+ Update and verify an existing global installation with:
28
+
29
+ ```bash
30
+ npm install --global --ignore-scripts @slicedearth/whoisleuth-cli@latest
31
+ whoisleuth --version
32
+ ```
33
+
25
34
  The scoped package and application share one semantic version. The package runs
26
35
  locally and does not call the hosted WHOISleuth deployment. Networked commands
27
36
  contact only the sources described by their collection profile; offline review,
@@ -34,6 +43,7 @@ node bin/whoisleuth.mts lookup example.com
34
43
  node bin/whoisleuth.mts lookup AS13335 --json
35
44
  printf 'example.com\n' | node bin/whoisleuth.mts lookup --json
36
45
  node bin/whoisleuth.mts lookup example.com --deep
46
+ node bin/whoisleuth.mts lookup example.com --deep --plan --json
37
47
  node bin/whoisleuth.mts lookup example.com --deep --summary
38
48
  node bin/whoisleuth.mts lookup example.com --deep --verbose
39
49
  node bin/whoisleuth.mts lookup example.com --deep --markdown --output lookup.md
@@ -41,6 +51,7 @@ node bin/whoisleuth.mts lookup example.com --deep --json --strict-exit --events
41
51
  cat domains.txt | node bin/whoisleuth.mts bulk --jsonl
42
52
  node bin/whoisleuth.mts bulk domains.txt --csv --registered-only
43
53
  node bin/whoisleuth.mts bulk domains.txt --domains --inconclusive-only
54
+ node bin/whoisleuth.mts bulk domains.txt --queries --errors-only
44
55
  node bin/whoisleuth.mts bulk domains.txt --concurrency 4
45
56
  node bin/whoisleuth.mts bulk domains.txt --deep --checkpoint bulk-checkpoint.json
46
57
  node bin/whoisleuth.mts bulk domains.txt --deep --checkpoint bulk-checkpoint.json --resume
@@ -55,6 +66,7 @@ node bin/whoisleuth.mts tls example.com --json
55
66
  node bin/whoisleuth.mts registry-support example.uk --json
56
67
  node bin/whoisleuth.mts risk-calibrate calibration.json --json
57
68
  node bin/whoisleuth.mts verify-artifact workspace.json --json
69
+ node bin/whoisleuth.mts verify-artifact lookup.json --json
58
70
  node bin/whoisleuth.mts verify-artifact workspace-encrypted.json --passphrase-file passphrase.txt --json
59
71
  node bin/whoisleuth.mts source-report lookup.json --json
60
72
  node bin/whoisleuth.mts inspect-archive workspace.json --json
@@ -66,12 +78,15 @@ node bin/whoisleuth.mts compare lookup.json --json
66
78
  node bin/whoisleuth.mts page-compare official.json candidate.json --json
67
79
  node bin/whoisleuth.mts mail-review bulk.json --json
68
80
  node bin/whoisleuth.mts diff first-lookup.json second-lookup.json --json
81
+ node bin/whoisleuth.mts timeline first-observation.json second-observation.json latest-observation.json --json
69
82
  node bin/whoisleuth.mts export lookup.json > evidence.json
70
83
  node bin/whoisleuth.mts export lookup.json --markdown > evidence.md
71
84
  node bin/whoisleuth.mts export lookup.json --html > evidence.html
72
85
  node bin/whoisleuth.mts doctor
73
86
  node bin/whoisleuth.mts doctor --network --json
87
+ node bin/whoisleuth.mts commands --json
74
88
  node bin/whoisleuth.mts completion zsh
89
+ node bin/whoisleuth.mts completion powershell
75
90
  node bin/whoisleuth.mts manual | man -l -
76
91
  ```
77
92
 
@@ -87,10 +102,13 @@ npm run cli:package:check
87
102
 
88
103
  The check includes only the executable, reachable TypeScript modules, CLI
89
104
  guide, licence, notices, and trademark terms. It excludes application routes,
90
- deployment adapters, tests, and development tools. Its generated manifest stays
91
- private. Release-candidate assembly is a separate explicit operation and never
92
- publishes from a developer checkout. Use the scoped package name; an unqualified
93
- `npx whoisleuth` is not this project.
105
+ deployment adapters, tests, and development tools. Direct runtime dependencies
106
+ are pinned to the exact versions exercised by the reviewed repository lockfile.
107
+ The package and its dependencies require no lifecycle scripts, so the install
108
+ examples disable them explicitly.
109
+ Its generated manifest stays private. Release-candidate assembly is a separate
110
+ explicit operation and never publishes from a developer checkout. Use the
111
+ scoped package name; an unqualified `npx whoisleuth` is not this project.
94
112
 
95
113
  Lookup defaults to the conservative fast profile. `--deep` must be requested
96
114
  explicitly and can add bounded WHOIS, DNS, website, TLS, registrar RDAP, and
@@ -104,6 +122,14 @@ up to five normalized PTR names; JSON retains up to eight. PTR names are
104
122
  operator-published routing context, not proof of hosting control or ownership.
105
123
  Fast lookups do not run this query.
106
124
 
125
+ `lookup --plan` performs classification and prints a versioned preflight without
126
+ starting collection. It lists the normalized target, selected fast or deep
127
+ mode, planned source families, conditional operations, and the data each family
128
+ may receive. The plan cannot predict live feature configuration, cache state,
129
+ redirects, referrals, source availability, or an exact request count. It can be
130
+ combined with `--json`, but not with report, detail, strict-exit, progress-event,
131
+ or quiet options.
132
+
107
133
  Only one query is accepted by `lookup`. Multiple-input processing belongs to
108
134
  the explicit `bulk` command rather than being silently inferred by `lookup`.
109
135
  Standard input is capped at 4 KiB and must contain one non-empty line.
@@ -114,32 +140,49 @@ request. Its output is described below.
114
140
 
115
141
  `doctor` is offline by default. It checks the Node runtime, platform, terminal
116
142
  capabilities, and availability of offline command families. `doctor --network`
117
- adds bounded public-DNS and WHOIS port 43 checks against fixed diagnostic
118
- infrastructure. It retains only state and a bounded explanation, not resolved
119
- addresses or response content. A failed optional network check returns the
120
- partial-failure exit code rather than claiming that all CLI collection is
121
- unavailable.
122
-
123
- `completion bash`, `completion zsh`, and `completion fish` print static shell
124
- completion scripts to stdout. The command does not edit a shell profile or make
125
- a network request. Review the generated script before sourcing it or placing it
126
- in the relevant shell completion directory. `manual` prints a generated roff
127
- manual page and likewise changes no local configuration.
143
+ adds independent bounded public-DNS, HTTPS/RDAP bootstrap, and WHOIS port 43
144
+ checks against fixed IANA diagnostic infrastructure. The three probes run
145
+ within the same per-probe ceiling so one stalled transport does not block the
146
+ others. The report retains only state, HTTP status, and a bounded explanation,
147
+ not resolved addresses or response content. A failed optional network check
148
+ returns the partial-failure exit code rather than claiming that all CLI
149
+ collection is unavailable.
150
+
151
+ `completion bash`, `completion zsh`, `completion fish`, and `completion
152
+ powershell` print static shell completion scripts to stdout. The command does
153
+ not edit a shell profile or make a network request. Review the generated script
154
+ before sourcing it or placing it in the relevant shell completion directory.
155
+ `manual` prints a generated roff manual page and likewise changes no local
156
+ configuration.
157
+
158
+ `commands` prints the installed command catalogue without running collection or
159
+ reading evidence. `commands --json` emits version 1 of the
160
+ `whoisleuth.cli.command-catalogue` schema, including each command's usage,
161
+ example, declared collection mode, collection scope, and boundary. This is the
162
+ supported discovery contract for local wrappers; it is not a permission grant
163
+ or a substitute for focused help.
164
+
165
+ Focused command help and the generated manual classify every operation as
166
+ offline or networked and state the relevant target, input, and concurrency
167
+ ceiling. These labels describe observable behavior rather than granting
168
+ authorization. The packaged `DISCLOSURE` defines the intended defensive scope;
169
+ operators remain responsible for law, provider terms, and authorization.
128
170
 
129
171
  ## Deployment boundary
130
172
 
131
- The CLI is a local repository tool. The serverless deployment publishes only
132
- `frontend/build` and packages functions only from `netlify/functions`, so
133
- `bin/` and `cli/` are not part of the hosted static site or function bundle.
134
- Both the application root and the assembled CLI package remain private and are
135
- not published to the public npm registry.
173
+ The CLI runs locally. The serverless deployment publishes only `frontend/build`
174
+ and packages functions only from `netlify/functions`, so `bin/` and `cli/` are
175
+ not part of the hosted static site or function bundle. The application root and
176
+ ordinary package-check candidate remain private. Explicitly reviewed CLI
177
+ release candidates can be published under the scoped public package described
178
+ in the installation section.
136
179
 
137
180
  Commands that query RDAP, WHOIS, DNS, HTTP, TLS, or Certificate Transparency do
138
181
  so directly from the machine running the CLI. They do not use the hosted login,
139
182
  hosted session, or deployment usage controls; upstream providers can see and
140
183
  rate-limit the local machine's network address. Offline `discover`, `compare`,
141
- `page-compare`, `mail-review`, `diff`, `risk-calibrate`, `verify-artifact`, `source-report`, `export`,
142
- `completion`, and `manual` operations make no network requests. Commands write
184
+ `page-compare`, `mail-review`, `diff`, `timeline`, `risk-calibrate`, `verify-artifact`, `source-report`, `export`,
185
+ `commands`, `completion`, and `manual` operations make no network requests. Commands write
143
186
  to stdout unless the analyst deliberately selects a local output file.
144
187
 
145
188
  ## Output
@@ -266,6 +309,15 @@ registration, DNS, page identity, mail, certificate, and relationship fields,
266
309
  keeps missing and unavailable evidence distinct, and makes no ownership or
267
310
  maliciousness inference. The output records both original observation times.
268
311
 
312
+ `timeline <observation.json> <observation.json> [...]` accepts from 2 to 20
313
+ saved Lookup documents for one domain, orders them by their validated
314
+ observation times, and compares each adjacent pair offline. The 32 MiB aggregate
315
+ input ceiling is enforced while files are read and again by the timeline
316
+ builder. Output retains normalized field states and observation times but no
317
+ input filenames or raw registry payloads. A difference can reflect a domain
318
+ change or changed collection conditions, so the command makes no current-state,
319
+ ownership, intent, safety, or maliciousness conclusion.
320
+
269
321
  When diagnostics version 5, 6, 7, or 8 reports a documented registry collection
270
322
  constraint, terminal output also shows the suffix, WHOIS and RDAP access
271
323
  profiles, and the bounded limitation. This is static access-policy context: it
@@ -286,7 +338,7 @@ machine access is not evidence that a domain is unregistered or safe.
286
338
  This release supports `lookup`, `bulk`, `ct-search`, `discover`, `discover-scan`, `posture`,
287
339
  `http`, `tls`, `registry-support`, `risk-calibrate`, `verify-artifact`,
288
340
  `inspect-archive`, `sign-artifact`, `verify-signature`, `source-report`,
289
- `compare`, `page-compare`, `mail-review`, `diff`, `export`, `doctor`, `completion`, and `manual`. Additional command families
341
+ `compare`, `page-compare`, `mail-review`, `diff`, `timeline`, `export`, `doctor`, `commands`, `completion`, and `manual`. Additional command families
290
342
  are added as separate bounded increments rather than exposing incomplete
291
343
  aliases.
292
344
 
@@ -385,7 +437,11 @@ Risk model.
385
437
  evidence contents. Input is capped at 15 MiB. It currently recognises ordinary
386
438
  and encrypted workspace archives, case-response packets, acquisition-decision
387
439
  exports, domain-comparison exports, Bulk mail-exposure exports, and Bulk review
388
- manifests.
440
+ manifests. It also validates the bounded versioned structure of saved CLI
441
+ Lookup JSON. Because saved Lookup documents do not embed a checksum or
442
+ signature, that result is reported as `structure_valid` with content integrity
443
+ explicitly unchecked. Saved Lookup validation retains its stricter 8 MiB
444
+ document ceiling.
389
445
 
390
446
  For an ordinary workspace archive, the command validates the versioned
391
447
  structure, section byte counts, record counts, and per-section checksums. For a
@@ -469,19 +525,25 @@ deep mode.
469
525
 
470
526
  Bulk uses the shared compact lookup response, so it does not retain raw RDAP
471
527
  objects or WHOIS response bodies. `--json` returns one bounded collection;
472
- `--jsonl` emits one self-contained versioned item per line. Version 2 adds a
528
+ `--jsonl` emits one self-contained versioned item per line. `--domains` emits
529
+ only successfully normalized domain names, while `--queries` preserves the
530
+ selected bounded input queries and is suitable for an exact retry queue.
531
+ Version 2 adds a
473
532
  bounded `dnsSummary` projection for observed A, AAAA, NS, and MX records plus
474
533
  null MX, SPF, and DMARC state. `--csv` writes fixed columns for automation,
475
- including the DNS summaries and explicit outcome; `--domains` writes only the
476
- normalized retained domain names for a subsequent command.
477
-
478
- `--registered-only` and `--inconclusive-only` are mutually exclusive output
479
- filters. The first retains registered, for-sale, and expiring authority-aware
480
- states. The second retains unknown authority states and failed rows. Filtering
481
- does not change collection and never converts an unavailable or failed source
482
- into an unregistered result. Machine documents record collected and emitted
483
- counts. A mixture of successful and failed queries exits with code 4 while
484
- preserving every collected result before output filtering.
534
+ including the DNS summaries and explicit outcome.
535
+
536
+ `--registered-only`, `--inconclusive-only`, and `--errors-only` are mutually
537
+ exclusive output filters. The first retains registered, for-sale, and expiring
538
+ authority-aware states. The second retains unknown authority states and failed
539
+ rows. The third retains only rows whose lookup operation failed, which is useful
540
+ for producing an exact retry queue without treating an inconclusive successful
541
+ lookup as an operational error. Filtering does not change collection and never
542
+ converts an unavailable or failed source into an unregistered result. Machine
543
+ documents record collected and emitted counts. A mixture of successful and
544
+ failed queries exits with code 4 while preserving every collected result before
545
+ output filtering. For example, `bulk domains.txt --queries --errors-only`
546
+ produces only the exact failed inputs while retaining the partial-failure exit.
485
547
 
486
548
  ## Certificate Transparency search
487
549
 
@@ -151,7 +151,7 @@ function freshness(observedAt, now) {
151
151
  export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = null, options = {}) {
152
152
  const left = normalizeBulkSessionResult(leftRaw);
153
153
  const right = normalizeBulkSessionResult(rightRaw);
154
- if (!left || !right || left.domain === right.domain)
154
+ if (!left || !right || (left.domain === right.domain && !options.allowSameDomain))
155
155
  return null;
156
156
  const observedAt = timestamp(observedAtRaw);
157
157
  const registry = context(left, right, 'Registry and registrar evidence', ['rdap', 'availability'], observedAt, options);
@@ -214,11 +214,17 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
214
214
  freshness: freshness(observedAt, options.now ?? Date.now()),
215
215
  rows,
216
216
  counts,
217
- limitations: [
218
- 'This compares compact settled evidence already present in Bulk and makes no new request.',
219
- 'Missing, unavailable, and differently collected evidence remain distinct from an observed difference.',
220
- 'Equality does not establish common ownership, infrastructure control, intent, safety, or maliciousness.',
221
- ],
217
+ limitations: options.allowSameDomain
218
+ ? [
219
+ 'This compares normalized evidence already present in two saved observations for the same domain and makes no new request.',
220
+ 'Missing, unavailable, and differently collected evidence remain distinct from an observed difference.',
221
+ 'An observed difference can reflect changed collection conditions and does not by itself establish current state, ownership, intent, safety, or maliciousness.',
222
+ ]
223
+ : [
224
+ 'This compares compact settled evidence already present in Bulk and makes no new request.',
225
+ 'Missing, unavailable, and differently collected evidence remain distinct from an observed difference.',
226
+ 'Equality does not establish common ownership, infrastructure control, intent, safety, or maliciousness.',
227
+ ],
222
228
  };
223
229
  }
224
230
  export async function buildBulkDomainComparisonExport(comparisonRaw, generatedAtRaw = new Date().toISOString()) {
package/lib/csv.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // Shared bounded CSV serialization for browser and CLI exports. Values can
2
2
  // contain upstream evidence, so normalize controls and prevent spreadsheet
3
3
  // formula execution before applying ordinary CSV quoting.
4
- const CSV_FORMULA_TRIGGER_RE = /^(?:[\t\r\n ]*[=+\-@]|[\t\r\n])/u;
4
+ const CSV_FORMULA_TRIGGER_RE = /^[=+\-@]/u;
5
5
  export const MAX_CSV_CELL_LENGTH = 32_768;
6
6
  function csvText(value) {
7
7
  const raw = Array.isArray(value)
@@ -21,7 +21,7 @@ function appendGroups(lines, groups) {
21
21
  function formatLookupEvidenceMarkdown(document, options = {}) {
22
22
  const report = buildLookupEvidenceReport(document, options);
23
23
  const lines = [
24
- `# Lookup evidence report — ${escapeMarkdownValue(report.title)}`,
24
+ `# Lookup evidence report: ${escapeMarkdownValue(report.title)}`,
25
25
  '', `> ${report.notice}`, '',
26
26
  ];
27
27
  appendFields(lines, report.metadata);
@@ -42,7 +42,7 @@ function formatLookupEvidenceMarkdown(document, options = {}) {
42
42
  }
43
43
  else {
44
44
  for (const field of report.comparison.fields) {
45
- lines.push(`- **${escapeMarkdownValue(field.label)} — ${escapeMarkdownValue(field.status)}:** RDAP ${escapeMarkdownValue(field.rdap)}; WHOIS ${escapeMarkdownValue(field.whois)}`);
45
+ lines.push(`- **${escapeMarkdownValue(field.label)} (${escapeMarkdownValue(field.status)}):** RDAP ${escapeMarkdownValue(field.rdap)}; WHOIS ${escapeMarkdownValue(field.whois)}`);
46
46
  }
47
47
  if (report.comparison.omitted)
48
48
  lines.push(`- ${report.comparison.omitted} additional comparison fields omitted.`);
@@ -51,7 +51,7 @@ function formatLookupEvidenceMarkdown(document, options = {}) {
51
51
  lines.push('', '## Registry / registrar RDAP comparison');
52
52
  appendFields(lines, report.registrarComparison.health);
53
53
  for (const field of report.registrarComparison.fields) {
54
- lines.push(`- **${escapeMarkdownValue(field.label)} — ${escapeMarkdownValue(field.status)}:** Registry RDAP ${escapeMarkdownValue(field.registry)}; registrar RDAP ${escapeMarkdownValue(field.registrar)}`);
54
+ lines.push(`- **${escapeMarkdownValue(field.label)} (${escapeMarkdownValue(field.status)}):** Registry RDAP ${escapeMarkdownValue(field.registry)}; registrar RDAP ${escapeMarkdownValue(field.registrar)}`);
55
55
  }
56
56
  if (report.registrarComparison.omitted)
57
57
  lines.push(`- ${report.registrarComparison.omitted} additional comparison fields omitted.`);
package/package.json CHANGED
@@ -2,6 +2,7 @@
2
2
  "name": "@slicedearth/whoisleuth-cli",
3
3
  "type": "module",
4
4
  "license": "AGPL-3.0-only",
5
+ "author": "slicedearth",
5
6
  "contentPolicy": {
6
7
  "class": "dual-use"
7
8
  },
@@ -32,11 +33,11 @@
32
33
  "bugs": {
33
34
  "url": "https://github.com/slicedearth/whoisleuth/issues"
34
35
  },
35
- "version": "1.32.2",
36
+ "version": "1.34.0",
36
37
  "dependencies": {
37
- "parse5": "^8.0.1",
38
- "tldts": "^7.4.9",
39
- "undici": "^8.7.0"
38
+ "parse5": "8.0.1",
39
+ "tldts": "7.4.10",
40
+ "undici": "8.9.0"
40
41
  },
41
42
  "files": [
42
43
  "bin/**/*.mjs",
@@ -49,6 +50,7 @@
49
50
  "LICENSES/*.txt",
50
51
  "NOTICE",
51
52
  "README.md",
53
+ "SECURITY.md",
52
54
  "TRADEMARKS.md",
53
55
  "third-party-notices.txt"
54
56
  ],
@@ -5130,7 +5130,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
5130
5130
  FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
5131
5131
  DEALINGS IN THE SOFTWARE.
5132
5132
  ================================================================================
5133
- tldts@7.4.9
5133
+ tldts@7.4.10
5134
5134
  Relationship: direct production dependency
5135
5135
  Declared licence: MIT
5136
5136
  Licence source: LICENSE
@@ -5149,7 +5149,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
5149
5149
  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
5150
5150
  WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
5151
5151
  ================================================================================
5152
- tldts-core@7.4.9
5152
+ tldts-core@7.4.10
5153
5153
  Relationship: transitive production dependency
5154
5154
  Declared licence: MIT
5155
5155
  Licence source: LICENSE