@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
@@ -1,8 +1,9 @@
1
1
  import { CLI_COMMANDS } from "./arguments.mjs";
2
+ import { INVESTIGATION_PLAN_RECIPES } from "./investigation-plan.mjs";
2
3
  const COMMON_OPTIONS = Object.freeze(['--help', '--output', '--force']);
3
4
  const OPTIONS_BY_COMMAND = Object.freeze({
4
- lookup: ['--json', '--markdown', '--html', '--fast', '--deep', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--no-color'],
5
- bulk: ['--json', '--jsonl', '--csv', '--domains', '--registered-only', '--inconclusive-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--quiet', '--no-color'],
5
+ lookup: ['--json', '--markdown', '--html', '--fast', '--deep', '--plan', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--no-color'],
6
+ bulk: ['--json', '--jsonl', '--csv', '--domains', '--queries', '--registered-only', '--inconclusive-only', '--errors-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--quiet', '--no-color'],
6
7
  'ct-search': ['--json', '--quiet', '--no-color'],
7
8
  discover: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--snapshot', '--json', '--jsonl', '--domains', '--quiet', '--no-color'],
8
9
  'discover-scan': ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--fast', '--deep', '--scan-limit', '--chunk-size', '--concurrency', '--resolver', '--allowlist', '--checkpoint', '--resume', '--observation-snapshot', '--registered-only', '--inconclusive-only', '--acquisition-only', '--suppressed-only', '--events', '--json', '--jsonl', '--csv', '--domains', '--quiet', '--no-color'],
@@ -19,9 +20,14 @@ const OPTIONS_BY_COMMAND = Object.freeze({
19
20
  compare: ['--json', '--quiet', '--no-color'],
20
21
  'page-compare': ['--json', '--quiet', '--no-color'],
21
22
  'mail-review': ['--json', '--quiet', '--no-color'],
23
+ 'review-evidence': ['--json', '--quiet', '--no-color'],
24
+ 'domain-control': ['--json', '--quiet', '--no-color'],
25
+ 'workflow-plan': ['--json', '--quiet', '--no-color'],
22
26
  diff: ['--json', '--quiet', '--no-color'],
27
+ timeline: ['--json', '--quiet', '--no-color'],
23
28
  export: ['--markdown', '--html', '--compact'],
24
29
  completion: [],
30
+ commands: ['--json', '--quiet', '--no-color'],
25
31
  doctor: ['--network', '--json', '--quiet', '--no-color'],
26
32
  manual: [],
27
33
  });
@@ -36,7 +42,7 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
36
42
  tls: 'Inspect one TLS connection',
37
43
  'registry-support': 'Explain local registry coverage',
38
44
  'risk-calibrate': 'Replay reviewed Risk labels offline',
39
- 'verify-artifact': 'Validate an evidence artifact offline',
45
+ 'verify-artifact': 'Validate saved evidence offline',
40
46
  'inspect-archive': 'Inspect an archive locally',
41
47
  'sign-artifact': 'Sign a reviewed artifact locally',
42
48
  'verify-signature': 'Verify a signed evidence package',
@@ -44,9 +50,14 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
44
50
  compare: 'Compare registry publications in one lookup',
45
51
  'page-compare': 'Compare saved static page evidence',
46
52
  'mail-review': 'Review saved passive mail evidence',
53
+ 'review-evidence': 'Review supplied evidence offline',
54
+ 'domain-control': 'Build or review a domain control manifest',
55
+ 'workflow-plan': 'Plan a fixed investigation recipe',
47
56
  diff: 'Compare two saved domain lookups',
57
+ timeline: 'Build same-domain history from saved lookups',
48
58
  export: 'Convert a lookup to an evidence report',
49
59
  completion: 'Print shell completion',
60
+ commands: 'List installed command contracts',
50
61
  doctor: 'Check the local CLI runtime',
51
62
  manual: 'Print the generated manual page',
52
63
  });
@@ -92,7 +103,11 @@ _whoisleuth_completion() {
92
103
  return
93
104
  fi
94
105
  if [[ "\${command}" == "completion" && \${COMP_CWORD} -eq 2 ]]; then
95
- COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${current}") )
106
+ COMPREPLY=( $(compgen -W "bash zsh fish powershell" -- "\${current}") )
107
+ return
108
+ fi
109
+ if [[ "\${command}" == "workflow-plan" && \${COMP_CWORD} -eq 2 ]]; then
110
+ COMPREPLY=( $(compgen -W "${INVESTIGATION_PLAN_RECIPES.join(' ')}" -- "\${current}") )
96
111
  return
97
112
  fi
98
113
  case "\${previous}" in
@@ -125,7 +140,11 @@ _whoisleuth() {
125
140
  command="\${words[2]}"
126
141
  previous="\${words[CURRENT-1]}"
127
142
  if [[ "\${command}" == "completion" && CURRENT -eq 3 ]]; then
128
- compadd -- bash zsh fish
143
+ compadd -- bash zsh fish powershell
144
+ return
145
+ fi
146
+ if [[ "\${command}" == "workflow-plan" && CURRENT -eq 3 ]]; then
147
+ compadd -- ${INVESTIGATION_PLAN_RECIPES.join(' ')}
129
148
  return
130
149
  fi
131
150
  case "\${previous}" in
@@ -160,16 +179,60 @@ complete -c whoisleuth -f
160
179
  complete -c whoisleuth -n '__fish_use_subcommand' -l help
161
180
  complete -c whoisleuth -n '__fish_use_subcommand' -l version
162
181
  ${commandLines.join('\n')}
163
- complete -c whoisleuth -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
182
+ complete -c whoisleuth -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish powershell'
183
+ complete -c whoisleuth -n '__fish_seen_subcommand_from workflow-plan' -a '${INVESTIGATION_PLAN_RECIPES.join(' ')}'
164
184
  ${optionLines.join('\n')}
165
185
  ${valueLines.join('\n')}
166
186
  `;
167
187
  }
188
+ function powershellCompletion() {
189
+ const commandOptions = Object.fromEntries(CLI_COMMANDS.map((command) => [
190
+ command,
191
+ [...COMMON_OPTIONS, ...(OPTIONS_BY_COMMAND[command] || [])],
192
+ ]));
193
+ return `# WHOISleuth PowerShell completion
194
+ Register-ArgumentCompleter -Native -CommandName whoisleuth -ScriptBlock {
195
+ param($wordToComplete, $commandAst, $cursorPosition)
196
+ $commands = @(${CLI_COMMANDS.map((command) => `'${command}'`).join(', ')})
197
+ $options = @{
198
+ ${Object.entries(commandOptions).map(([command, options]) => ` '${command}' = @(${options.map((option) => `'${option}'`).join(', ')})`).join('\n')}
199
+ }
200
+ $values = @{
201
+ ${Object.entries(VALUE_OPTIONS).map(([option, values]) => ` '${option}' = @(${values.map((value) => `'${value}'`).join(', ')})`).join('\n')}
202
+ 'completion' = @('bash', 'zsh', 'fish', 'powershell')
203
+ 'workflow-plan' = @(${INVESTIGATION_PLAN_RECIPES.map((value) => `'${value}'`).join(', ')})
204
+ }
205
+ $elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
206
+ $command = if ($elements.Count -gt 1) { $elements[1] } else { '' }
207
+ $previous = if ($elements.Count -gt 1) { $elements[$elements.Count - 1] } else { '' }
208
+ $candidates = if ($elements.Count -le 2) {
209
+ @($commands) + @('--help', '--version')
210
+ } elseif ($command -eq 'completion' -and $elements.Count -le 3) {
211
+ $values['completion']
212
+ } elseif ($command -eq 'workflow-plan' -and $elements.Count -le 3) {
213
+ $values['workflow-plan']
214
+ } elseif ($values.ContainsKey($previous)) {
215
+ $values[$previous]
216
+ } elseif ($options.ContainsKey($command)) {
217
+ $options[$command]
218
+ } else {
219
+ @('--help')
220
+ }
221
+ foreach ($candidate in $candidates) {
222
+ if ($candidate.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase)) {
223
+ [System.Management.Automation.CompletionResult]::new($candidate, $candidate, 'ParameterValue', $candidate)
224
+ }
225
+ }
226
+ }
227
+ `;
228
+ }
168
229
  function buildShellCompletion(shell) {
169
230
  if (shell === 'bash')
170
231
  return bashCompletion();
171
232
  if (shell === 'zsh')
172
233
  return zshCompletion();
173
- return fishCompletion();
234
+ if (shell === 'fish')
235
+ return fishCompletion();
236
+ return powershellCompletion();
174
237
  }
175
238
  export { buildShellCompletion, };
package/cli/doctor.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { resolvePublicAddresses } from "../lib/safe-fetch.mjs";
1
+ import { resolvePublicAddresses, safeFetch } from "../lib/safe-fetch.mjs";
2
2
  import { withTimeout } from "../lib/abort.mjs";
3
3
  import { whoisQuery } from "../lib/whois-transport.mjs";
4
4
  const DOCTOR_SCHEMA = 'whoisleuth.cli.doctor';
@@ -6,6 +6,7 @@ const DOCTOR_VERSION = 1;
6
6
  const MINIMUM_NODE_MAJOR = 24;
7
7
  const NETWORK_HOST = 'whois.iana.org';
8
8
  const NETWORK_QUERY = 'example.com';
9
+ const NETWORK_HTTPS_URL = 'https://data.iana.org/rdap/dns.json';
9
10
  const NETWORK_TIMEOUT_MS = 6_000;
10
11
  const MAX_DOCTOR_DETAIL_LENGTH = 180;
11
12
  function boundedDetail(value, fallback) {
@@ -57,58 +58,92 @@ async function buildDoctorReport(options) {
57
58
  id: 'network',
58
59
  label: 'Network checks',
59
60
  state: 'skipped',
60
- detail: 'Not requested. Run doctor --network to test bounded public DNS and WHOIS connectivity.',
61
+ detail: 'Not requested. Run doctor --network to test bounded public DNS, HTTPS, and WHOIS connectivity.',
61
62
  }));
62
63
  }
63
64
  else {
64
65
  const resolve = options.resolveAddresses || resolvePublicAddresses;
66
+ const fetchHttps = options.fetchHttps || safeFetch;
65
67
  const query = options.queryWhois || whoisQuery;
66
68
  const networkTimeoutMs = Number.isSafeInteger(options.networkTimeoutMs)
67
69
  && Number(options.networkTimeoutMs) >= 1
68
70
  && Number(options.networkTimeoutMs) <= NETWORK_TIMEOUT_MS
69
71
  ? Number(options.networkTimeoutMs)
70
72
  : NETWORK_TIMEOUT_MS;
71
- try {
72
- const addresses = await withTimeout(() => resolve(NETWORK_HOST), networkTimeoutMs, `Public DNS check timed out after ${networkTimeoutMs} ms.`);
73
- checks.push(Object.freeze({
74
- id: 'public_dns',
75
- label: 'Public DNS',
76
- state: addresses.length > 0 ? 'pass' : 'partial',
77
- detail: addresses.length > 0
78
- ? `${addresses.length} public address${addresses.length === 1 ? '' : 'es'} validated; addresses were not retained.`
79
- : 'The fixed diagnostic host returned no validated public addresses.',
80
- }));
81
- }
82
- catch (error) {
83
- checks.push(Object.freeze({
84
- id: 'public_dns',
85
- label: 'Public DNS',
86
- state: 'partial',
87
- detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'Public DNS check failed.'),
88
- }));
89
- }
90
- try {
91
- const response = await query(NETWORK_HOST, NETWORK_QUERY, {
92
- timeoutMs: Math.max(1, networkTimeoutMs - 1_000),
93
- totalDeadlineMs: networkTimeoutMs,
94
- });
95
- checks.push(Object.freeze({
96
- id: 'whois_transport',
97
- label: 'WHOIS transport',
98
- state: response.trim() ? 'pass' : 'partial',
99
- detail: response.trim()
100
- ? 'A bounded port 43 response was received; response content was not retained.'
101
- : 'The fixed diagnostic query returned an empty response.',
102
- }));
103
- }
104
- catch (error) {
105
- checks.push(Object.freeze({
106
- id: 'whois_transport',
107
- label: 'WHOIS transport',
108
- state: 'partial',
109
- detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'WHOIS transport check failed.'),
110
- }));
111
- }
73
+ const networkChecks = await Promise.all([
74
+ (async () => {
75
+ try {
76
+ const addresses = await withTimeout(() => resolve(NETWORK_HOST), networkTimeoutMs, `Public DNS check timed out after ${networkTimeoutMs} ms.`);
77
+ return Object.freeze({
78
+ id: 'public_dns',
79
+ label: 'Public DNS',
80
+ state: addresses.length > 0 ? 'pass' : 'partial',
81
+ detail: addresses.length > 0
82
+ ? `${addresses.length} public address${addresses.length === 1 ? '' : 'es'} validated; addresses were not retained.`
83
+ : 'The fixed diagnostic host returned no validated public addresses.',
84
+ });
85
+ }
86
+ catch (error) {
87
+ return Object.freeze({
88
+ id: 'public_dns',
89
+ label: 'Public DNS',
90
+ state: 'partial',
91
+ detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'Public DNS check failed.'),
92
+ });
93
+ }
94
+ })(),
95
+ (async () => {
96
+ try {
97
+ const response = await withTimeout(() => fetchHttps(NETWORK_HTTPS_URL, {
98
+ headers: { accept: 'application/json' },
99
+ signal: AbortSignal.timeout(networkTimeoutMs),
100
+ }), networkTimeoutMs, `HTTPS check timed out after ${networkTimeoutMs} ms.`);
101
+ const status = response.status;
102
+ await response.body?.cancel().catch(() => { });
103
+ return Object.freeze({
104
+ id: 'https_transport',
105
+ label: 'HTTPS transport',
106
+ state: response.ok ? 'pass' : 'partial',
107
+ detail: response.ok
108
+ ? `The fixed IANA RDAP bootstrap endpoint returned HTTP ${status}; response content was not retained.`
109
+ : `The fixed IANA RDAP bootstrap endpoint returned HTTP ${status}.`,
110
+ });
111
+ }
112
+ catch (error) {
113
+ return Object.freeze({
114
+ id: 'https_transport',
115
+ label: 'HTTPS transport',
116
+ state: 'partial',
117
+ detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'HTTPS transport check failed.'),
118
+ });
119
+ }
120
+ })(),
121
+ (async () => {
122
+ try {
123
+ const response = await withTimeout(() => query(NETWORK_HOST, NETWORK_QUERY, {
124
+ timeoutMs: Math.max(1, networkTimeoutMs - 1_000),
125
+ totalDeadlineMs: networkTimeoutMs,
126
+ }), networkTimeoutMs, `WHOIS check timed out after ${networkTimeoutMs} ms.`);
127
+ return Object.freeze({
128
+ id: 'whois_transport',
129
+ label: 'WHOIS transport',
130
+ state: response.trim() ? 'pass' : 'partial',
131
+ detail: response.trim()
132
+ ? 'A bounded port 43 response was received; response content was not retained.'
133
+ : 'The fixed diagnostic query returned an empty response.',
134
+ });
135
+ }
136
+ catch (error) {
137
+ return Object.freeze({
138
+ id: 'whois_transport',
139
+ label: 'WHOIS transport',
140
+ state: 'partial',
141
+ detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'WHOIS transport check failed.'),
142
+ });
143
+ }
144
+ })(),
145
+ ]);
146
+ checks.push(...networkChecks);
112
147
  }
113
148
  const state = checks.some((check) => check.state === 'partial') ? 'partial' : 'pass';
114
149
  return Object.freeze({
@@ -1,5 +1,8 @@
1
+ import { createRequire } from 'node:module';
1
2
  import { projectCliLookupComparisonInput } from "./compare.mjs";
2
3
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
4
+ const require = createRequire(import.meta.url);
5
+ const { version: APPLICATION_VERSION } = require('../package.json');
3
6
  function objectOrNull(value) {
4
7
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
5
8
  }
@@ -14,7 +17,11 @@ function buildCliEvidenceExport(text, evidenceModule, generatedAt = new Date().t
14
17
  throw new TypeError('Lookup evidence export dependency is required.');
15
18
  }
16
19
  const buildLookupEvidence = dependency.buildLookupEvidence;
17
- const result = objectOrNull(buildLookupEvidence(source, { generatedAt, idnAnalysis: null }));
20
+ const result = objectOrNull(buildLookupEvidence(source, {
21
+ generatedAt,
22
+ idnAnalysis: null,
23
+ applicationVersion: APPLICATION_VERSION,
24
+ }));
18
25
  if (!result
19
26
  || result.schema !== dependency.LOOKUP_EVIDENCE_SCHEMA
20
27
  || result.schemaVersion !== dependency.LOOKUP_EVIDENCE_SCHEMA_VERSION) {
@@ -25,4 +32,4 @@ function buildCliEvidenceExport(text, evidenceModule, generatedAt = new Date().t
25
32
  function formatCliEvidenceExport(document, compact = false) {
26
33
  return `${JSON.stringify(document, null, compact ? 0 : 2)}\n`;
27
34
  }
28
- export { buildCliEvidenceExport, formatCliEvidenceExport };
35
+ export { APPLICATION_VERSION, buildCliEvidenceExport, formatCliEvidenceExport };
@@ -28,7 +28,7 @@ function renderRegistrarComparison(comparison) {
28
28
  }
29
29
  function formatLookupEvidenceHtml(document) {
30
30
  const report = buildLookupEvidenceReport(document);
31
- const title = `Lookup evidence report — ${report.title}`;
31
+ const title = `Lookup evidence report: ${report.title}`;
32
32
  return `<!doctype html>
33
33
  <html lang="en">
34
34
  <head>
@@ -271,7 +271,6 @@ function formatTerminalRegistrySupport(document) {
271
271
  `WHOIS scope ${supportLabel(whois.queryScope)}`,
272
272
  `WHOIS encoding ${supportLabel(whois.encodingProfile)}`,
273
273
  `WHOIS parser ${supportLabel(whois.parserProfile)}`,
274
- `Fallback ${profile.fallbackProfile ? supportLabel(profile.fallbackProfile) : 'None'}`,
275
274
  `Fixture states ${fixtures.length ? fixtures.map((value) => supportLabel(value)).join(', ') : 'None documented'}`,
276
275
  ];
277
276
  for (const file of files)
@@ -284,12 +283,12 @@ function formatTerminalRegistrySupport(document) {
284
283
  function formatTerminalBulk(items, metadata) {
285
284
  const lines = items.map((item) => {
286
285
  if (!item.ok)
287
- return `! ${safeTerminalValue(item.query)} — ${safeTerminalValue(item.error, 'Lookup failed')}`;
286
+ return `! ${safeTerminalValue(item.query)}: ${safeTerminalValue(item.error, 'Lookup failed')}`;
288
287
  const result = terminalRecord(item.result);
289
288
  const availability = terminalRecord(result.availability);
290
289
  const state = titleCase(availability.state);
291
290
  const confidence = titleCase(availability.confidence);
292
- return `✓ ${safeTerminalValue(item.query)} — ${state} (${confidence} confidence)`;
291
+ return `✓ ${safeTerminalValue(item.query)}: ${state} (${confidence} confidence)`;
293
292
  });
294
293
  const succeeded = items.filter((item) => item.ok).length;
295
294
  lines.push('');
@@ -302,6 +301,11 @@ function formatTerminalBulk(items, metadata) {
302
301
  }
303
302
  function formatTerminalCtSearch(document) {
304
303
  const matches = Array.isArray(document.matches) ? document.matches : [];
304
+ const certificateGroups = Array.isArray(document.certificateGroups) ? document.certificateGroups : [];
305
+ const crossDomainGroups = certificateGroups.filter((value) => {
306
+ const group = terminalRecord(value);
307
+ return Array.isArray(group.domains) && group.domains.length > 1;
308
+ });
305
309
  const visible = matches.slice(0, MAX_CT_TERMINAL_MATCHES);
306
310
  const observation = terminalRecord(document.observation);
307
311
  const lines = [
@@ -310,7 +314,9 @@ function formatTerminalCtSearch(document) {
310
314
  `Certificates ${safeTerminalValue(document.certCount, '0')}`,
311
315
  `Observed hosts ${safeTerminalValue(Array.isArray(document.domains) ? document.domains.length : 0, '0')}`,
312
316
  `Matches ${safeTerminalValue(matches.length, '0')}`,
317
+ `Issuance groups ${safeTerminalValue(certificateGroups.length, '0')} (${safeTerminalValue(crossDomainGroups.length, '0')} cross-domain)`,
313
318
  `Truncated ${document.truncated ? 'Yes' : 'No'}`,
319
+ `Group cap ${document.certificateGroupsTruncated ? 'Reached' : 'Not reached'}`,
314
320
  '',
315
321
  ];
316
322
  if (!visible.length) {
@@ -367,7 +373,7 @@ function formatTerminalDiscover(document, mutationLabels = {}) {
367
373
  const unicodeDetail = unicodeDomain && unicodeDomain !== candidateDomain
368
374
  ? ` [Unicode: ${safeTerminalValue(unicodeDomain)}]`
369
375
  : '';
370
- lines.push(`${safeTerminalValue(candidateDomain)}${unicodeDetail} — ${labels.join(', ') || 'Generated variant'}`);
376
+ lines.push(`${safeTerminalValue(candidateDomain)}${unicodeDetail}: ${labels.join(', ') || 'Generated variant'}`);
371
377
  }
372
378
  if (!visible.length)
373
379
  lines.push('No candidates were generated.');
@@ -396,7 +402,7 @@ function formatTerminalPosture(document) {
396
402
  ];
397
403
  for (const value of checks) {
398
404
  const item = terminalRecord(value);
399
- lines.push(`[${safeTerminalValue(item.status, 'info').toUpperCase()}] ${safeTerminalValue(item.label)} — ${safeTerminalValue(item.summary)}`);
405
+ lines.push(`[${safeTerminalValue(item.status, 'info').toUpperCase()}] ${safeTerminalValue(item.label)}: ${safeTerminalValue(item.summary)}`);
400
406
  if (item.detail)
401
407
  lines.push(` Detail ${safeTerminalValue(item.detail)}`);
402
408
  if (item.remediation)
@@ -444,7 +450,7 @@ function formatTerminalHttp(document) {
444
450
  for (const value of attempts) {
445
451
  const attempt = terminalRecord(value);
446
452
  const outcome = attempt.httpStatus ? `HTTP ${attempt.httpStatus}` : attempt.error || attempt.outcome;
447
- lines.push(`Attempt ${safeTerminalValue(attempt.url)} — ${safeTerminalValue(outcome)}`);
453
+ lines.push(`Attempt ${safeTerminalValue(attempt.url)}: ${safeTerminalValue(outcome)}`);
448
454
  }
449
455
  for (const limitation of limitations)
450
456
  lines.push(`Limitation ${safeTerminalValue(limitation)}`);
@@ -535,7 +541,7 @@ function formatTerminalTls(document) {
535
541
  lines.push(`Error ${safeTerminalValue(diagnostics.error)}`);
536
542
  for (const value of findings) {
537
543
  const finding = terminalRecord(value);
538
- lines.push(`Finding ${safeTerminalValue(finding.label)} — ${safeTerminalValue(finding.detail)}`);
544
+ lines.push(`Finding ${safeTerminalValue(finding.label)}: ${safeTerminalValue(finding.detail)}`);
539
545
  }
540
546
  for (const limitation of limitations)
541
547
  lines.push(`Limitation ${safeTerminalValue(limitation)}`);
@@ -0,0 +1,112 @@
1
+ import { domainToASCII } from 'node:url';
2
+ export const CLI_INVESTIGATION_PLAN_SCHEMA = 'whoisleuth.cli.investigation-plan';
3
+ export const CLI_INVESTIGATION_PLAN_VERSION = 1;
4
+ export const INVESTIGATION_PLAN_RECIPES = [
5
+ 'domain-triage',
6
+ 'lookalike-review',
7
+ 'owned-domain-review',
8
+ 'historical-comparison',
9
+ ];
10
+ const RECIPES = Object.freeze({
11
+ 'domain-triage': Object.freeze({
12
+ label: 'New domain triage',
13
+ requiresDomain: true,
14
+ objective: 'Collect and preserve a source-aware registration, DNS, HTTP, TLS, page, and network-context review.',
15
+ steps: (domain) => Object.freeze([
16
+ step('collect', 'Collect a Deep lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Review source health and limitations before using missing fields.'),
17
+ step('export', 'Create a portable evidence report', 'export', ['<saved-lookup.json>'], 'offline', 'analyst_selection', 'whoisleuth.lookup-evidence', 'Select the reviewed lookup file; the plan never guesses a path.'),
18
+ step('verify', 'Verify the exported artifact', 'verify-artifact', ['<evidence.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.offline-artifact-verification', 'Keep verification distinct from a claim that the observations are correct or current.'),
19
+ ]),
20
+ }),
21
+ 'lookalike-review': Object.freeze({
22
+ label: 'Lookalike candidate review',
23
+ requiresDomain: false,
24
+ objective: 'Generate a bounded candidate queue, collect only the selected scope, and retain a reviewed candidate lookup.',
25
+ steps: (subject) => Object.freeze([
26
+ step('generate', 'Generate candidates offline', 'discover', [subject, '--preset', 'all', '--json'], 'offline', 'none', 'whoisleuth.cli.discover', 'Review mutation families and suppressions before collection.'),
27
+ step('scan', 'Collect a bounded candidate queue', 'discover-scan', [subject, '--fast', '--scan-limit', '50', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.discovery-scan', 'Fast collection is a triage boundary; partial or inconclusive authority evidence remains explicit.'),
28
+ step('inspect', 'Deep-review one selected candidate', 'lookup', ['<selected-domain>', '--deep', '--json'], 'network', 'analyst_selection', 'whoisleuth.cli.lookup', 'Select a candidate deliberately; generation does not prove registration, control, intent, or maliciousness.'),
29
+ ]),
30
+ }),
31
+ 'owned-domain-review': Object.freeze({
32
+ label: 'Owned domain posture review',
33
+ requiresDomain: true,
34
+ objective: 'Review current passive posture and compare supplied observations with an analyst-authored control manifest.',
35
+ steps: (domain) => Object.freeze([
36
+ step('posture', 'Collect bounded DNS posture', 'posture', [domain, '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.posture', 'Review mail profile and delegation evidence before interpreting missing records.'),
37
+ step('lookup', 'Collect supporting Deep evidence', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Retain separately attributed registration, DNS, TLS, and page observations.'),
38
+ step('manifest', 'Review the domain control manifest', 'domain-control', ['<review-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-control-review', 'Only complete supplied observations may produce drift.'),
39
+ ]),
40
+ }),
41
+ 'historical-comparison': Object.freeze({
42
+ label: 'Historical observation comparison',
43
+ requiresDomain: true,
44
+ objective: 'Collect a current observation and compare it with analyst-selected saved observations without merging source states.',
45
+ steps: (domain) => Object.freeze([
46
+ step('current', 'Collect the current lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'A current request does not refresh or validate older provider-reported history.'),
47
+ step('diff', 'Compare two selected observations', 'diff', ['<previous.json>', '<current.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-diff', 'Equal, different, conflicting, and unavailable evidence remain separate.'),
48
+ step('timeline', 'Build a bounded local timeline', 'timeline', ['<oldest.json>', '<newer.json>', '<current.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-timeline', 'Choose two to twenty same-domain files in chronological scope.'),
49
+ ]),
50
+ }),
51
+ });
52
+ function step(id, label, command, args, mode, approval, produces, completion) {
53
+ return Object.freeze({ id, label, command, arguments: Object.freeze([...args]), mode, approval, produces, completion });
54
+ }
55
+ function boundedSubject(value) {
56
+ if (typeof value !== 'string' || !value.trim() || value.length > 253 || /[\u0000-\u001f\u007f]/u.test(value)) {
57
+ throw new TypeError('Investigation-plan subject must be bounded text without control characters.');
58
+ }
59
+ return value.replace(/\s+/gu, ' ').trim();
60
+ }
61
+ function normalizedDomain(value) {
62
+ const candidate = domainToASCII(value.toLowerCase().replace(/\.$/u, ''));
63
+ if (!candidate || candidate.length > 253 || !candidate.includes('.'))
64
+ return null;
65
+ return candidate.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label))
66
+ ? candidate
67
+ : null;
68
+ }
69
+ export function buildInvestigationPlan(recipeId, subjectValue, generatedAtValue = new Date().toISOString()) {
70
+ const recipe = RECIPES[recipeId];
71
+ if (!recipe)
72
+ throw new TypeError('Investigation-plan recipe is unsupported.');
73
+ const suppliedSubject = boundedSubject(subjectValue);
74
+ const subject = recipe.requiresDomain ? normalizedDomain(suppliedSubject) : suppliedSubject.toLowerCase();
75
+ if (!subject)
76
+ throw new TypeError('This investigation-plan recipe requires one valid domain.');
77
+ const parsedTime = Date.parse(generatedAtValue);
78
+ if (!Number.isFinite(parsedTime))
79
+ throw new TypeError('Investigation-plan generation time is invalid.');
80
+ const steps = recipe.steps(subject);
81
+ return Object.freeze({
82
+ schema: CLI_INVESTIGATION_PLAN_SCHEMA,
83
+ version: CLI_INVESTIGATION_PLAN_VERSION,
84
+ generatedAt: new Date(parsedTime).toISOString(),
85
+ recipe: Object.freeze({ id: recipeId, label: recipe.label, objective: recipe.objective }),
86
+ subject,
87
+ execution: 'plan_only',
88
+ steps,
89
+ limitations: Object.freeze([
90
+ 'This document is a fixed plan. It does not execute commands, expand placeholders, make requests, read files, change cases, or submit reports.',
91
+ 'Network steps require deliberate execution and disclose the selected target to the sources described by that command.',
92
+ 'Analyst-selection steps require reviewed local artifacts; placeholders are never interpreted as file paths by this planner.',
93
+ ]),
94
+ });
95
+ }
96
+ export function formatInvestigationPlan(plan) {
97
+ return [
98
+ `Investigation plan: ${plan.recipe.label}`,
99
+ `Subject ${plan.subject}`,
100
+ `Mode ${plan.execution}`,
101
+ '',
102
+ ...plan.steps.flatMap((item, index) => [
103
+ `${index + 1}. ${item.label}`,
104
+ ` ${item.command} ${item.arguments.join(' ')}`,
105
+ ` ${item.mode}; approval: ${item.approval.replaceAll('_', ' ')}`,
106
+ ` ${item.completion}`,
107
+ ]),
108
+ '',
109
+ ...plan.limitations.map((item) => `Limitation: ${item}`),
110
+ '',
111
+ ].join('\n');
112
+ }
@@ -8,6 +8,7 @@ import { formatLookupEvidenceHtml } from "./formatters/html.mjs";
8
8
  import { buildCliLookupDocument, formatJsonDocument } from "./formatters/json.mjs";
9
9
  import { formatLookupEvidenceMarkdown } from "./formatters/markdown.mjs";
10
10
  import { formatTerminalLookup } from "./formatters/terminal.mjs";
11
+ import { buildCliLookupPlan, formatCliLookupPlan } from "./lookup-plan.mjs";
11
12
  import { createCliProgressEvents } from "./progress-events.mjs";
12
13
  import { lookupStrictExitFindings } from "./strict-exit.mjs";
13
14
  async function runLookupCommand(args, dependencies, context) {
@@ -33,6 +34,13 @@ async function runLookupCommand(args, dependencies, context) {
33
34
  if ((args.output === 'markdown' || args.output === 'html') && classified.type !== 'domain') {
34
35
  throw new CliUsageError('Markdown and HTML reports support domain lookups only.');
35
36
  }
37
+ if (args.plan) {
38
+ const plan = buildCliLookupPlan(query, classified, args.deep);
39
+ context.writeStdout(args.output === 'json'
40
+ ? formatJsonDocument(plan)
41
+ : context.terminal(formatCliLookupPlan(plan), args.color));
42
+ return EXIT_CODES.SUCCESS;
43
+ }
36
44
  const indicator = context.beginProgress(args.deep
37
45
  ? 'Collecting deep Lookup evidence'
38
46
  : 'Collecting registration evidence');
@@ -119,13 +119,20 @@ function lookupComparisonInput(document) {
119
119
  sourceCoverage: sourceCoverage(document, availability),
120
120
  };
121
121
  }
122
- function buildCliLookupDiff(leftText, rightText, generatedAt = new Date().toISOString()) {
122
+ function buildCliLookupDiff(leftText, rightText, generatedAt = new Date().toISOString(), options = {}) {
123
123
  const left = parseSavedLookupDocument(leftText, { label: 'Left lookup input' });
124
124
  const right = parseSavedLookupDocument(rightText, { label: 'Right lookup input' });
125
- if (left.registrableDomain === right.registrableDomain) {
125
+ const domainMode = options.domainMode ?? 'different';
126
+ if (domainMode === 'different' && left.registrableDomain === right.registrableDomain) {
126
127
  throw new CliUsageError('Lookup diff requires documents for two different domains.');
127
128
  }
128
- const comparison = buildBulkDomainComparison(lookupComparisonInput(left), lookupComparisonInput(right), null);
129
+ if (domainMode === 'same' && left.registrableDomain !== right.registrableDomain) {
130
+ throw new CliUsageError('Lookup history requires observations for the same domain.');
131
+ }
132
+ const comparison = buildBulkDomainComparison(lookupComparisonInput(left), lookupComparisonInput(right), domainMode === 'same' ? right.generatedAt : null, {
133
+ allowSameDomain: domainMode === 'same',
134
+ ...(domainMode === 'same' ? { now: Date.parse(right.generatedAt) } : {}),
135
+ });
129
136
  if (!comparison)
130
137
  throw new CliUsageError('Lookup documents could not be compared.');
131
138
  return {
@@ -136,9 +143,13 @@ function buildCliLookupDiff(leftText, rightText, generatedAt = new Date().toISOS
136
143
  right: { domain: right.registrableDomain, generatedAt: right.generatedAt, mode: right.mode },
137
144
  comparison,
138
145
  limitations: [
139
- 'This command compares bounded observations already present in two saved Lookup documents and makes no network request.',
146
+ domainMode === 'same'
147
+ ? 'This comparison uses bounded observations already present in two saved Lookup documents for the same domain and makes no network request.'
148
+ : 'This command compares bounded observations already present in two saved Lookup documents and makes no network request.',
140
149
  'A missing value remains distinct from unavailable collection and from an observed difference.',
141
- 'Shared infrastructure or matching values do not establish common ownership, control, intent, safety, or maliciousness.',
150
+ domainMode === 'same'
151
+ ? 'An observed difference can reflect a domain change or changed collection conditions and does not by itself establish current state, intent, safety, or maliciousness.'
152
+ : 'Shared infrastructure or matching values do not establish common ownership, control, intent, safety, or maliciousness.',
142
153
  ],
143
154
  };
144
155
  }