@slicedearth/whoisleuth-cli 1.34.0 → 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 (58) hide show
  1. package/README.md +2 -0
  2. package/cli/arguments.mjs +92 -0
  3. package/cli/artifact-verify.mjs +4 -0
  4. package/cli/bulk.mjs +1 -2
  5. package/cli/completion.mjs +19 -0
  6. package/cli/export-evidence.mjs +9 -2
  7. package/cli/formatters/terminal.mjs +7 -1
  8. package/cli/investigation-plan.mjs +112 -0
  9. package/cli/mail-review.mjs +47 -2
  10. package/cli/offline-evidence-review.mjs +141 -0
  11. package/cli/registry-support.mjs +1 -4
  12. package/cli/runner.mjs +109 -1
  13. package/docs/cli.md +102 -9
  14. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  15. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  16. package/frontend/src/lib/analysis/http-summary.js +2 -1
  17. package/frontend/src/lib/analysis/utils.js +7 -40
  18. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  19. package/lib/availability.mjs +30 -10
  20. package/lib/bulk-limits.mjs +13 -0
  21. package/lib/ct-response-bounds.mjs +9 -0
  22. package/lib/ct-search.mjs +66 -12
  23. package/lib/dns-delegation-health.mjs +56 -5
  24. package/lib/dnssec-evidence-validation.mjs +235 -0
  25. package/lib/domain-control-manifest.mjs +339 -0
  26. package/lib/domain-posture.mjs +2 -1
  27. package/lib/encrypted-dns-contract.mjs +81 -0
  28. package/lib/evidence-export.mjs +17 -3
  29. package/lib/evidence-report.mjs +3 -0
  30. package/lib/favicon.mjs +3 -8
  31. package/lib/html-signals.mjs +4 -0
  32. package/lib/http-evidence-bounds.mjs +4 -0
  33. package/lib/http-intelligence.mjs +2 -1
  34. package/lib/ip-prefix.mjs +54 -0
  35. package/lib/local-geoip-evidence.mjs +89 -0
  36. package/lib/outbound-identity.mjs +9 -0
  37. package/lib/outbound-request-bounds.mjs +9 -0
  38. package/lib/perceptual-hash-comparison.mjs +34 -0
  39. package/lib/perceptual-hash.mjs +3 -31
  40. package/lib/project-metadata.mjs +8 -0
  41. package/lib/provider-policy-admission.mjs +49 -0
  42. package/lib/rdap-search-workbench.mjs +157 -0
  43. package/lib/rdap.mjs +7 -2
  44. package/lib/registry-capabilities.mjs +31 -1
  45. package/lib/registry-capability-catalogue.mjs +11 -10
  46. package/lib/response-policy.mjs +81 -6
  47. package/lib/rpki-evidence.mjs +105 -0
  48. package/lib/safe-fetch.mjs +2 -1
  49. package/lib/semantic-version.mjs +54 -0
  50. package/lib/static-html-analysis.mjs +27 -0
  51. package/lib/threatfox-intelligence.mjs +10 -5
  52. package/lib/tlsa-evidence.mjs +194 -0
  53. package/lib/urlhaus-intelligence.mjs +10 -5
  54. package/lib/urlscan-intelligence.mjs +10 -5
  55. package/lib/website-security-posture.mjs +11 -4
  56. package/lib/whois-chain.mjs +14 -152
  57. package/lib/whois.mjs +2 -2
  58. package/package.json +2 -2
@@ -0,0 +1,141 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { normalizeEncryptedDnsAdapter, planEncryptedDnsQuery, } from "../lib/encrypted-dns-contract.mjs";
3
+ import { validateDnssecEvidence } from "../lib/dnssec-evidence-validation.mjs";
4
+ import { buildLocalGeoIpDatabase, lookupLocalGeoIp, } from "../lib/local-geoip-evidence.mjs";
5
+ import { normalizeRdapSearchHelp, planRdapReverseSearch, } from "../lib/rdap-search-workbench.mjs";
6
+ import { reviewRpkiRoute } from "../lib/rpki-evidence.mjs";
7
+ import { analyzeTlsaEvidence } from "../lib/tlsa-evidence.mjs";
8
+ import { CliUsageError } from "./errors.mjs";
9
+ const OFFLINE_EVIDENCE_REVIEW_SCHEMA = 'whoisleuth.cli.offline-evidence-review';
10
+ const OFFLINE_EVIDENCE_REVIEW_VERSION = 1;
11
+ const MAX_OFFLINE_EVIDENCE_INPUT_BYTES = 16 * 1024 * 1024;
12
+ function record(value) {
13
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
14
+ }
15
+ function parseInput(value) {
16
+ if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > MAX_OFFLINE_EVIDENCE_INPUT_BYTES) {
17
+ throw new CliUsageError(`Offline evidence input is limited to ${MAX_OFFLINE_EVIDENCE_INPUT_BYTES} bytes.`);
18
+ }
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(value.replace(/^\uFEFF/u, ''));
22
+ }
23
+ catch {
24
+ throw new CliUsageError('Offline evidence review requires one valid JSON document.');
25
+ }
26
+ const document = record(parsed);
27
+ if (document.version !== 1 || typeof document.schema !== 'string') {
28
+ throw new CliUsageError('Offline evidence review requires a supported versioned input schema.');
29
+ }
30
+ return document;
31
+ }
32
+ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString()) {
33
+ const input = parseInput(value);
34
+ let kind;
35
+ let result;
36
+ if (input.schema === 'whoisleuth.rdap-search-input') {
37
+ kind = 'rdap_search';
38
+ const request = record(input.request);
39
+ const help = normalizeRdapSearchHelp(input.help);
40
+ result = Object.freeze({
41
+ help,
42
+ plan: planRdapReverseSearch(help, {
43
+ searchableResourceType: String(request.searchableResourceType ?? ''),
44
+ relatedResourceType: String(request.relatedResourceType ?? ''),
45
+ property: String(request.property ?? ''),
46
+ value: request.value,
47
+ }),
48
+ });
49
+ }
50
+ else if (input.schema === 'whoisleuth.dnssec-evidence-input') {
51
+ kind = 'dnssec';
52
+ result = validateDnssecEvidence({
53
+ ownerName: input.ownerName,
54
+ delegationSigned: input.delegationSigned,
55
+ dsRecords: input.dsRecords,
56
+ dnskeyRecords: input.dnskeyRecords,
57
+ rrSigRecords: input.rrSigRecords,
58
+ observedAt: input.observedAt,
59
+ });
60
+ }
61
+ else if (input.schema === 'whoisleuth.tlsa-evidence-input') {
62
+ kind = 'tlsa';
63
+ result = analyzeTlsaEvidence({
64
+ serviceName: input.serviceName,
65
+ dnssecState: input.dnssecState,
66
+ pkixValidationState: input.pkixValidationState,
67
+ records: input.records,
68
+ certificateDerBase64: input.certificateDerBase64,
69
+ spkiDerBase64: input.spkiDerBase64,
70
+ authorityMaterials: input.authorityMaterials,
71
+ });
72
+ }
73
+ else if (input.schema === 'whoisleuth.rpki-route-input') {
74
+ kind = 'rpki';
75
+ result = reviewRpkiRoute({
76
+ routePrefix: input.routePrefix,
77
+ originAsn: input.originAsn,
78
+ authorizations: input.authorizations,
79
+ });
80
+ }
81
+ else if (input.schema === 'whoisleuth.local-geoip-query') {
82
+ kind = 'geoip';
83
+ result = lookupLocalGeoIp(buildLocalGeoIpDatabase(input.database), input.address);
84
+ }
85
+ else if (input.schema === 'whoisleuth.encrypted-dns-plan-input') {
86
+ kind = 'encrypted_dns';
87
+ const query = record(input.query);
88
+ result = planEncryptedDnsQuery(normalizeEncryptedDnsAdapter(input.adapter), { name: query.name, type: query.type });
89
+ }
90
+ else {
91
+ throw new CliUsageError('Offline evidence review does not recognize this input schema.');
92
+ }
93
+ return Object.freeze({
94
+ schema: OFFLINE_EVIDENCE_REVIEW_SCHEMA,
95
+ version: OFFLINE_EVIDENCE_REVIEW_VERSION,
96
+ generatedAt,
97
+ kind,
98
+ result,
99
+ limitations: Object.freeze([
100
+ 'The review is local and uses only the supplied document. It does not refresh, transmit, or independently establish the current completeness of the evidence.',
101
+ ]),
102
+ });
103
+ }
104
+ function formatOfflineEvidenceReview(document) {
105
+ const result = record(document.result);
106
+ const state = typeof result.state === 'string' ? result.state : 'reviewed';
107
+ const lines = [
108
+ 'Offline evidence review',
109
+ `Kind ${document.kind.replaceAll('_', ' ')}`,
110
+ `State ${state.replaceAll('_', ' ')}`,
111
+ ];
112
+ if (document.kind === 'rdap_search') {
113
+ const help = record(result.help);
114
+ const plan = record(result.plan);
115
+ lines.push(`Help ${String(help.state ?? 'unavailable').replaceAll('_', ' ')}`);
116
+ lines.push(`Plan ${String(plan.state ?? 'unavailable').replaceAll('_', ' ')}`);
117
+ }
118
+ else {
119
+ for (const [label, key] of [
120
+ ['Records', 'records'],
121
+ ['Rejected', 'rejectedCount'],
122
+ ['Matches', 'matchingAuthorizationCount'],
123
+ ]) {
124
+ const candidate = result[key];
125
+ if (Array.isArray(candidate))
126
+ lines.push(`${label.padEnd(7)}${candidate.length}`);
127
+ else if (typeof candidate === 'number')
128
+ lines.push(`${label.padEnd(7)}${candidate}`);
129
+ }
130
+ }
131
+ lines.push('', 'Limitations:');
132
+ for (const limitation of document.limitations)
133
+ lines.push(` - ${limitation}`);
134
+ const nestedLimitations = Array.isArray(result.limitations) ? result.limitations : [];
135
+ for (const limitation of nestedLimitations) {
136
+ if (typeof limitation === 'string')
137
+ lines.push(` - ${limitation}`);
138
+ }
139
+ return `${lines.join('\n')}\n`;
140
+ }
141
+ export { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, OFFLINE_EVIDENCE_REVIEW_SCHEMA, OFFLINE_EVIDENCE_REVIEW_VERSION, buildOfflineEvidenceReview, formatOfflineEvidenceReview, };
@@ -1,6 +1,6 @@
1
1
  import { registryStandardsCoverageSnapshot, } from "../lib/registry-capabilities.mjs";
2
2
  const REGISTRY_SUPPORT_SCHEMA = 'whoisleuth.cli.registry-support';
3
- const REGISTRY_SUPPORT_SCHEMA_VERSION = 2;
3
+ const REGISTRY_SUPPORT_SCHEMA_VERSION = 3;
4
4
  const REGISTRY_STANDARDS_COVERAGE_SCHEMA = 'whoisleuth.registry-standards-coverage';
5
5
  const MAX_REGISTRY_SUPPORT_TEXT_LENGTH = 2048;
6
6
  const MAX_REGISTRY_SUPPORT_REFERENCES = 20;
@@ -100,9 +100,6 @@ function buildRegistrySupportDocument(requestedInput, capability, catalogueVersi
100
100
  encodingProfile: boundedText(capability.whoisEncodingProfile, 'unknown', 64),
101
101
  parserProfile: boundedText(capability.whoisParserProfile, 'unknown', 128),
102
102
  },
103
- fallbackProfile: capability.fallbackProfile
104
- ? boundedText(capability.fallbackProfile, 'unknown', 128)
105
- : null,
106
103
  },
107
104
  verification: {
108
105
  fixtureScenarios: boundedList(capability.fixtureScenarios, 'token'),
package/cli/runner.mjs CHANGED
@@ -26,8 +26,12 @@ import { buildCliLookupDiff, formatCliLookupDiff } from "./lookup-diff.mjs";
26
26
  import { MAX_LOOKUP_TIMELINE_INPUT_BYTES, buildCliLookupTimeline, formatCliLookupTimeline, } from "./lookup-timeline.mjs";
27
27
  import { buildCliPageComparison, formatCliPageComparison } from "./page-compare.mjs";
28
28
  import { MAX_MAIL_REVIEW_INPUT_BYTES, buildCliMailReview, formatCliMailReview, } from "./mail-review.mjs";
29
+ import { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, buildOfflineEvidenceReview, formatOfflineEvidenceReview, } from "./offline-evidence-review.mjs";
30
+ import { DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, buildDomainControlManifest, formatDomainControlResult, reviewDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
29
31
  import { runLookupCommand } from "./lookup-command-runner.mjs";
30
32
  import { buildCliManual } from "./manual.mjs";
33
+ import { buildInvestigationPlan, formatInvestigationPlan } from "./investigation-plan.mjs";
34
+ import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../lib/project-metadata.mjs";
31
35
  import { createBufferedOutput, writePrivateFile } from "./output-file.mjs";
32
36
  import { createTerminalProgress } from "./progress.mjs";
33
37
  import { buildRegistrySupportDocument } from "./registry-support.mjs";
@@ -66,6 +70,9 @@ Review saved evidence:
66
70
  compare Compare saved registry publications.
67
71
  page-compare Compare saved static page and TLS evidence.
68
72
  mail-review Review saved passive mail exposure evidence.
73
+ review-evidence Review supplied DNS, routing, GeoIP, or RDAP evidence offline.
74
+ domain-control Build or review an integrity-protected desired-state manifest.
75
+ workflow-plan Plan a fixed investigation recipe without executing it.
69
76
  diff Compare two saved domain observations.
70
77
  timeline Compare a sequence of observations for one domain.
71
78
  export Convert a saved lookup into an evidence report.
@@ -90,7 +97,7 @@ Diagnostics are written to stderr. Fast lookup is the default; deep collection
90
97
  must be requested explicitly and can disclose a target to additional sources.
91
98
 
92
99
  Copyright 2026 slicedearth. Licensed under AGPL-3.0-only.
93
- Source and licence: https://github.com/slicedearth/whoisleuth
100
+ Source and licence: ${WHOISLEUTH_SOURCE_REPOSITORY_URL}
94
101
  `;
95
102
  const COMMAND_USAGE = Object.freeze({
96
103
  completion: 'whoisleuth completion <bash|zsh|fish|powershell>',
@@ -115,6 +122,9 @@ const COMMAND_USAGE = Object.freeze({
115
122
  compare: 'whoisleuth compare [lookup.json] [--json] [--quiet] [--no-color]',
116
123
  'page-compare': 'whoisleuth page-compare <left.json> <right.json> [--json] [--quiet] [--no-color]',
117
124
  'mail-review': 'whoisleuth mail-review [bulk.json|bulk.jsonl] [--json] [--quiet] [--no-color]',
125
+ 'review-evidence': 'whoisleuth review-evidence [evidence.json] [--json] [--quiet] [--no-color]',
126
+ 'domain-control': 'whoisleuth domain-control [manifest-input.json|review-input.json] [--json] [--quiet] [--no-color]',
127
+ 'workflow-plan': 'whoisleuth workflow-plan <recipe> <domain|brand> [--json] [--quiet] [--no-color]',
118
128
  diff: 'whoisleuth diff <left.json> <right.json> [--json] [--quiet] [--no-color]',
119
129
  timeline: 'whoisleuth timeline <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
120
130
  export: 'whoisleuth export [lookup.json] [--markdown|--html|--compact]',
@@ -230,6 +240,21 @@ const COMMAND_DETAILS = Object.freeze({
230
240
  example: 'whoisleuth mail-review candidates.json --json',
231
241
  boundary: 'Review is offline and sends no SMTP traffic. Missing or partial DNS evidence remains inconclusive.',
232
242
  },
243
+ 'review-evidence': {
244
+ description: 'Review one versioned DNSSEC, TLSA, RPKI, GeoIP, encrypted-DNS, or RDAP planning document offline.',
245
+ example: 'whoisleuth review-evidence dnssec-evidence.json --json',
246
+ boundary: 'The command reads only the supplied document. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, or SMTP request.',
247
+ },
248
+ 'domain-control': {
249
+ description: 'Build an integrity-protected desired-state manifest or compare one with supplied observations.',
250
+ example: 'whoisleuth domain-control domain-control-input.json --json',
251
+ boundary: 'The command is offline and changes no registrar, DNS, mail, or certificate configuration. Only complete supplied observations can produce drift.',
252
+ },
253
+ 'workflow-plan': {
254
+ description: 'Build a fixed domain-investigation plan from existing bounded CLI commands.',
255
+ example: 'whoisleuth workflow-plan domain-triage example.test --json',
256
+ boundary: 'Planning is offline and plan-only. It does not execute commands, expand placeholders, read files, make requests, or submit evidence.',
257
+ },
233
258
  diff: {
234
259
  description: 'Compare bounded evidence retained in two saved domain lookups.',
235
260
  example: 'whoisleuth diff first.json second.json --json',
@@ -269,6 +294,9 @@ const COMMAND_COLLECTION = Object.freeze({
269
294
  compare: { mode: 'offline', scope: 'Reads one saved Lookup and compares its separately attributed registry publications.' },
270
295
  'page-compare': { mode: 'offline', scope: 'Reads two saved Lookup documents and executes no page code.' },
271
296
  'mail-review': { mode: 'offline', scope: 'Reads one saved Bulk result and sends no DNS or SMTP traffic.' },
297
+ 'review-evidence': { mode: 'offline', scope: 'Reads one bounded versioned evidence or request-planning document and performs no collection.' },
298
+ 'domain-control': { mode: 'offline', scope: 'Reads one bounded desired-state or review document and performs no collection or configuration change.' },
299
+ 'workflow-plan': { mode: 'offline', scope: 'Builds a fixed typed recipe and executes none of its network or file steps.' },
272
300
  diff: { mode: 'offline', scope: 'Reads two saved Lookup documents for different domains.' },
273
301
  timeline: { mode: 'offline', scope: 'Reads 2 to 20 saved observations for one domain, capped at 32 MiB in total.' },
274
302
  export: { mode: 'offline', scope: 'Reads one saved Lookup and writes one bounded report.' },
@@ -644,6 +672,86 @@ async function runParsedCli(args, dependencies = {}) {
644
672
  : terminal(formatCliMailReview(document), args.color));
645
673
  return EXIT_CODES.SUCCESS;
646
674
  }
675
+ if (args.action === 'review-evidence') {
676
+ failureLabel = 'Offline evidence review';
677
+ let input;
678
+ try {
679
+ input = dependencies.readArtifactInput
680
+ ? await dependencies.readArtifactInput(args.source)
681
+ : await readSavedLookupInputBounded(args.source
682
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
683
+ : dependencies.stdin || process.stdin, {
684
+ limit: MAX_OFFLINE_EVIDENCE_INPUT_BYTES,
685
+ label: 'Offline evidence input',
686
+ });
687
+ }
688
+ catch (error) {
689
+ if (error instanceof CliUsageError)
690
+ throw error;
691
+ throw new CliUsageError(`Could not read offline evidence input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
692
+ }
693
+ if (!input.trim())
694
+ throw new CliUsageError('review-evidence requires one JSON file or a document on stdin.');
695
+ const document = buildOfflineEvidenceReview(input, commandContext.now());
696
+ if (!args.quiet)
697
+ write(stdout, args.output === 'json'
698
+ ? formatJsonDocument(document)
699
+ : terminal(formatOfflineEvidenceReview(document), args.color));
700
+ return EXIT_CODES.SUCCESS;
701
+ }
702
+ if (args.action === 'domain-control') {
703
+ failureLabel = 'Domain control review';
704
+ let input;
705
+ try {
706
+ input = dependencies.readArtifactInput
707
+ ? await dependencies.readArtifactInput(args.source)
708
+ : await readSavedLookupInputBounded(args.source
709
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
710
+ : dependencies.stdin || process.stdin, {
711
+ limit: MAX_OFFLINE_EVIDENCE_INPUT_BYTES,
712
+ label: 'Domain control input',
713
+ });
714
+ }
715
+ catch (error) {
716
+ if (error instanceof CliUsageError)
717
+ throw error;
718
+ throw new CliUsageError(`Could not read domain control input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
719
+ }
720
+ if (!input.trim())
721
+ throw new CliUsageError('domain-control requires one JSON file or a document on stdin.');
722
+ let parsed;
723
+ try {
724
+ parsed = JSON.parse(input);
725
+ }
726
+ catch {
727
+ throw new CliUsageError('Domain control input is not valid JSON.');
728
+ }
729
+ const schema = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
730
+ ? parsed.schema
731
+ : null;
732
+ const document = schema === DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA
733
+ ? buildDomainControlManifest(parsed, commandContext.now())
734
+ : schema === DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA
735
+ ? reviewDomainControlManifest(parsed, commandContext.now())
736
+ : null;
737
+ if (!document) {
738
+ throw new CliUsageError(`Domain control input must use ${DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA} or ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA}.`);
739
+ }
740
+ if (!args.quiet)
741
+ write(stdout, args.output === 'json'
742
+ ? formatJsonDocument(document)
743
+ : terminal(formatDomainControlResult(document), args.color));
744
+ return EXIT_CODES.SUCCESS;
745
+ }
746
+ if (args.action === 'workflow-plan') {
747
+ failureLabel = 'Investigation plan';
748
+ const document = buildInvestigationPlan(args.recipe, args.subject, commandContext.now());
749
+ if (!args.quiet)
750
+ write(stdout, args.output === 'json'
751
+ ? formatJsonDocument(document)
752
+ : terminal(formatInvestigationPlan(document), args.color));
753
+ return EXIT_CODES.SUCCESS;
754
+ }
647
755
  if (args.action === 'diff') {
648
756
  failureLabel = 'Lookup evidence diff';
649
757
  const readDiffInput = dependencies.readDiffInput || (async (source) => (readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), {
package/docs/cli.md CHANGED
@@ -77,6 +77,9 @@ node bin/whoisleuth.mts lookup example.com --deep --json > lookup.json
77
77
  node bin/whoisleuth.mts compare lookup.json --json
78
78
  node bin/whoisleuth.mts page-compare official.json candidate.json --json
79
79
  node bin/whoisleuth.mts mail-review bulk.json --json
80
+ node bin/whoisleuth.mts review-evidence dnssec-evidence.json --json
81
+ node bin/whoisleuth.mts domain-control domain-control-input.json --json
82
+ node bin/whoisleuth.mts workflow-plan domain-triage example.test --json
80
83
  node bin/whoisleuth.mts diff first-lookup.json second-lookup.json --json
81
84
  node bin/whoisleuth.mts timeline first-observation.json second-observation.json latest-observation.json --json
82
85
  node bin/whoisleuth.mts export lookup.json > evidence.json
@@ -181,7 +184,7 @@ Commands that query RDAP, WHOIS, DNS, HTTP, TLS, or Certificate Transparency do
181
184
  so directly from the machine running the CLI. They do not use the hosted login,
182
185
  hosted session, or deployment usage controls; upstream providers can see and
183
186
  rate-limit the local machine's network address. Offline `discover`, `compare`,
184
- `page-compare`, `mail-review`, `diff`, `timeline`, `risk-calibrate`, `verify-artifact`, `source-report`, `export`,
187
+ `page-compare`, `mail-review`, `review-evidence`, `domain-control`, `workflow-plan`, `diff`, `timeline`, `risk-calibrate`, `verify-artifact`, `source-report`, `export`,
185
188
  `commands`, `completion`, and `manual` operations make no network requests. Commands write
186
189
  to stdout unless the analyst deliberately selects a local output file.
187
190
 
@@ -338,7 +341,7 @@ machine access is not evidence that a domain is unregistered or safe.
338
341
  This release supports `lookup`, `bulk`, `ct-search`, `discover`, `discover-scan`, `posture`,
339
342
  `http`, `tls`, `registry-support`, `risk-calibrate`, `verify-artifact`,
340
343
  `inspect-archive`, `sign-artifact`, `verify-signature`, `source-report`,
341
- `compare`, `page-compare`, `mail-review`, `diff`, `timeline`, `export`, `doctor`, `commands`, `completion`, and `manual`. Additional command families
344
+ `compare`, `page-compare`, `mail-review`, `review-evidence`, `domain-control`, `workflow-plan`, `diff`, `timeline`, `export`, `doctor`, `commands`, `completion`, and `manual`. Additional command families
342
345
  are added as separate bounded increments rather than exposing incomplete
343
346
  aliases.
344
347
 
@@ -348,13 +351,13 @@ aliases.
348
351
  catalogue for one domain or suffix. It distinguishes an explicit
349
352
  fixture-backed or access-documented suffix profile from the generic IANA
350
353
  discovery profile and reports the RDAP and WHOIS discovery/access paths,
351
- WHOIS query and parser profiles, any bounded fallback, fixture scenarios,
354
+ WHOIS query and parser profiles, fixture scenarios,
352
355
  verification references, documentation references, and the catalogue's
353
356
  limitation. The same document includes a dated, official-source aggregate of
354
357
  generic, generic-restricted, sponsored, and infrastructure RDAP coverage. That
355
358
  snapshot is separate from the suffix profile and from live reachability.
356
359
 
357
- Terminal output is bounded and control-safe. `--json` emits version 2 of the
360
+ Terminal output is bounded and control-safe. `--json` emits version 3 of the
358
361
  `whoisleuth.cli.registry-support` schema. Unknown but syntactically valid
359
362
  suffixes retain the generic `discovery_only` profile; malformed input exits
360
363
  with code 2. The command never probes a registry or tests current reachability.
@@ -554,11 +557,15 @@ does not call the hosted WHOISleuth deployment. Quote a multi-word keyword so
554
557
  the shell passes it as one argument.
555
558
 
556
559
  Terminal output summarizes certificate rows, observed hostnames, canonical
557
- registrable-domain matches, observation times, and completeness. It shows at
558
- most 100 matches and five hostnames per match, with explicit omission notes.
559
- `--json` returns the complete bounded structured result in the versioned
560
- `whoisleuth.cli.ct-search` schema. CT observations do not prove that a website
561
- is active or malicious.
560
+ registrable-domain matches, observation times, bounded certificate-issuance
561
+ groups, and completeness. An issuance group contains names observed together
562
+ in one public certificate record; a cross-domain group is a review lead, not
563
+ an attribution or ownership finding. Terminal output shows at most 100 matches
564
+ and five hostnames per match, with explicit omission notes. `--json` returns
565
+ the complete bounded structured result in the versioned
566
+ `whoisleuth.cli.ct-search` schema. The certificate-group cap is reported
567
+ separately from the registrable-domain result cap. CT observations do not prove
568
+ that a website is active or malicious.
562
569
 
563
570
  ## Lookalike discovery
564
571
 
@@ -791,6 +798,92 @@ The command makes no DNS or SMTP request and retains no source path. It does
791
798
  not test message acceptance, relay behavior, mailbox existence, catch-all
792
799
  behavior, SMTP banners, or whether a mail server is rogue, safe, or malicious.
793
800
 
801
+ An analyst may add a bounded `tlsaEvidence` object to a version-2 Bulk item for
802
+ offline DANE review. It must name the exact `_25._tcp.<mx-host>` service, and
803
+ that host must occur in the same item's retained MX evidence. A certificate
804
+ association match is complete only when `dnssecState` is `validated`. TLSA
805
+ usages 0 and 1 additionally require `pkixValidationState: "validated"`.
806
+ Usages 0 and 2 compare against the bounded `authorityMaterials` array; usages
807
+ 1 and 3 compare against the supplied leaf certificate or SPKI. Mismatched
808
+ service names, missing certificate roles, unvalidated prerequisites, malformed
809
+ material, and truncation remain invalid, partial, or untrusted rather than
810
+ becoming a DANE match.
811
+
812
+ ## Offline supplied-evidence review
813
+
814
+ `review-evidence [evidence.json]` accepts one version-1 JSON input and performs
815
+ only deterministic local parsing, comparison, or request planning. Input is
816
+ capped at 16 MiB. The command never retrieves DNS, opens SMTP, contacts an
817
+ RDAP server, submits a resolver query, or looks up an address in a hosted
818
+ GeoIP service.
819
+
820
+ Supported input schemas are:
821
+
822
+ - `whoisleuth.dnssec-evidence-input`: compares bounded DS and DNSKEY records,
823
+ recalculates DNSKEY key tags and supported DS digests, and reviews supplied
824
+ RRSIG validity windows at `observedAt`. `consistent` describes only the
825
+ supplied DS/DNSKEY relationship. It does not authenticate a chain to a root
826
+ trust anchor, validate RRSIG cryptography, or prove that a missing record is
827
+ absent.
828
+ - `whoisleuth.tlsa-evidence-input`: binds TLSA records to one explicit
829
+ `_port._transport.hostname` service and compares supplied leaf or authority
830
+ certificate/SPKI bytes. `matched` requires independently validated DNSSEC;
831
+ PKIX-TA and PKIX-EE usages also require an independently validated PKIX path.
832
+ The command does not retrieve the certificate or negotiate STARTTLS.
833
+ - `whoisleuth.rdap-search-input`: normalizes a supplied RDAP search-help
834
+ response and prepares an exact supported reverse-search request without
835
+ sending it.
836
+ - `whoisleuth.rpki-route-input`: compares a route prefix and origin ASN with a
837
+ bounded analyst-supplied VRP set.
838
+ - `whoisleuth.local-geoip-query`: queries an analyst-supplied bounded prefix
839
+ database whose source, version, and licence metadata remain in the result.
840
+ - `whoisleuth.encrypted-dns-plan-input`: validates an explicitly reviewed
841
+ encrypted-DNS provider contract and prepares a bounded query plan. It does
842
+ not execute the plan.
843
+
844
+ The common output is `whoisleuth.cli.offline-evidence-review` version 1. It
845
+ retains the nested result's explicit state and limitations. A locally
846
+ consistent relationship is not converted into a claim about current
847
+ publication, ownership, safety, or maliciousness.
848
+
849
+ ## Domain control manifests
850
+
851
+ `domain-control [input.json]` builds or reviews a bounded desired-state
852
+ manifest without making a request or changing configuration. A manifest input
853
+ uses `whoisleuth.domain-control-manifest-input` version 1, includes an expiry,
854
+ and may record expected nameservers, DS, MX, CAA, TLS issuer, TLS SPKI
855
+ fingerprint, registrar-lock preference, and a renewal-review date for up to 100
856
+ domains. Empty fields mean unconfigured; they do not require a record to be
857
+ absent.
858
+
859
+ The emitted `whoisleuth.domain-control-manifest` version 1 is normalized and
860
+ protected by a canonical SHA-256 digest. It can also be passed to
861
+ `sign-artifact` for an optional local Ed25519 signature. Integrity and
862
+ signature verification detect changes and authenticate possession of the
863
+ selected key; neither establishes that the desired state is correct.
864
+
865
+ A review input uses `whoisleuth.domain-control-review-input` version 1 and
866
+ contains one manifest plus separately attributed observations. Only a complete
867
+ `observed` field may produce `drift`. Partial, unavailable, unsupported, and
868
+ missing observations remain inconclusive, and unrelated observations are
869
+ counted but ignored. The review performs no DNS, RDAP, HTTP, TLS, SMTP, or
870
+ registrar request.
871
+
872
+ ## Fixed investigation plans
873
+
874
+ `workflow-plan <recipe> <domain|brand>` turns one of four fixed recipes into a
875
+ versioned `whoisleuth.cli.investigation-plan` document. Supported recipes are
876
+ `domain-triage`, `lookalike-review`, `owned-domain-review`, and
877
+ `historical-comparison`. Each step names an existing CLI command, carries its
878
+ arguments as a JSON array, declares whether it is offline or networked, and
879
+ marks network-disclosure or analyst-selection approval gates.
880
+
881
+ The command is plan-only. It does not execute a step, construct a shell script,
882
+ interpret a placeholder as a file path, read an artifact, make a request,
883
+ change a case, or submit evidence. Analysts deliberately run selected commands
884
+ after reviewing their collection boundaries. This provides repeatable
885
+ domain-specific workflows without an arbitrary automation or plugin surface.
886
+
794
887
  ## Optional local rendered capture
795
888
 
796
889
  `packages/web-capture` is a private repo-local Playwright package, not part of
@@ -1,13 +1,7 @@
1
1
  import { normalizeBulkSessionResult, } from "./bulk-session-model.js";
2
+ import { limitedBulkSources } from "./bulk-source-coverage.js";
2
3
  export const MAX_BULK_RETRY_ROWS = 200;
3
4
  export const BULK_REVIEW_STALE_AFTER_DAYS = 7;
4
- const LIMITED_STATES = new Set([
5
- 'error',
6
- 'partial',
7
- 'skipped',
8
- 'unavailable',
9
- 'unsupported',
10
- ]);
11
5
  function freshness(value, now = Date.now()) {
12
6
  if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) {
13
7
  return { observedAt: null, ageDays: null, state: 'unknown' };
@@ -21,9 +15,7 @@ function freshness(value, now = Date.now()) {
21
15
  };
22
16
  }
23
17
  function retryRow(value, isStale) {
24
- const limitedSources = value.sourceCoverage
25
- .filter((item) => LIMITED_STATES.has(item.state))
26
- .map((item) => item.source)
18
+ const limitedSources = limitedBulkSources(value.domain, value.sourceCoverage)
27
19
  .sort()
28
20
  .slice(0, 12);
29
21
  const reasons = [];
@@ -56,6 +48,7 @@ export function buildBulkRetryPlan(values, mode, observedAtRaw = null, now = Dat
56
48
  'This repeats the selected lookup profile; the current API does not request only one upstream source.',
57
49
  `Evidence is marked stale after ${BULK_REVIEW_STALE_AFTER_DAYS} days for review purposes; this is a local observation age, not an upstream record age.`,
58
50
  'Optional third-party intelligence providers are not contacted by Bulk.',
51
+ 'Sources deliberately skipped by the selected scan profile or deployment policy do not independently trigger a retry.',
59
52
  'A retry can remain partial or fail and does not convert unavailable evidence into absence.',
60
53
  ],
61
54
  };
@@ -0,0 +1,76 @@
1
+ // Authority-aware interpretation for compact Bulk source observations. Raw
2
+ // source states remain unchanged; this layer only decides whether an observed
3
+ // limitation was unexpected for the registry's published service paths.
4
+ import { registryCapabilityFor } from "../../../../lib/registry-capabilities.mjs";
5
+ const MAX_SOURCE_OBSERVATIONS = 32;
6
+ const MAX_SOURCE_TEXT_LENGTH = 40;
7
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
8
+ const LIMITED_SOURCE_STATES = new Set([
9
+ 'error',
10
+ 'failed',
11
+ 'inconclusive',
12
+ 'partial',
13
+ 'rate_limited',
14
+ 'unavailable',
15
+ 'unsupported',
16
+ ]);
17
+ function text(value) {
18
+ return typeof value === 'string'
19
+ && value.length <= MAX_SOURCE_TEXT_LENGTH
20
+ && !CONTROL_RE.test(value)
21
+ ? value.trim().toLowerCase()
22
+ : '';
23
+ }
24
+ function record(value) {
25
+ return value && typeof value === 'object' && !Array.isArray(value)
26
+ ? value
27
+ : null;
28
+ }
29
+ function observations(value) {
30
+ return Array.isArray(value)
31
+ ? value.slice(0, MAX_SOURCE_OBSERVATIONS).flatMap((entry) => {
32
+ const item = record(entry);
33
+ const source = text(item?.source);
34
+ const state = text(item?.state);
35
+ return source && state ? [{ source, state }] : [];
36
+ })
37
+ : [];
38
+ }
39
+ function expectedUnsupportedSources(domain) {
40
+ const capability = registryCapabilityFor(domain);
41
+ if (!capability)
42
+ return new Set();
43
+ const expected = new Set();
44
+ if (capability.rdapAccessProfile === 'no-iana-service')
45
+ expected.add('rdap');
46
+ if (capability.whoisAccessProfile === 'no-iana-service')
47
+ expected.add('whois');
48
+ return expected;
49
+ }
50
+ export function isExpectedUnsupportedBulkSource(domain, observation) {
51
+ const source = text(observation.source);
52
+ const state = text(observation.state);
53
+ return state === 'unsupported' && expectedUnsupportedSources(domain).has(source);
54
+ }
55
+ export function limitedBulkSources(domain, coverage) {
56
+ const expectedUnsupported = expectedUnsupportedSources(domain);
57
+ return observations(coverage)
58
+ .filter(({ source, state }) => (LIMITED_SOURCE_STATES.has(state)
59
+ && !(state === 'unsupported' && expectedUnsupported.has(source))))
60
+ .map(({ source }) => source);
61
+ }
62
+ export function classifyBulkSourceCoverage(domain, coverage) {
63
+ const retained = observations(coverage);
64
+ if (!retained.length)
65
+ return 'unrecorded';
66
+ return limitedBulkSources(domain, retained).length ? 'limited' : 'complete';
67
+ }
68
+ export function describeBulkSourceCoverage(domain, observation) {
69
+ const source = text(observation.source);
70
+ const state = text(observation.state);
71
+ if (!source || !state)
72
+ return 'unrecorded source';
73
+ return isExpectedUnsupportedBulkSource(domain, observation)
74
+ ? `${source}: unsupported (no IANA-published service)`
75
+ : `${source}: ${state}`;
76
+ }
@@ -3,10 +3,11 @@
3
3
  // connection attempts. Bulk/watchlist/case records deliberately keep only this
4
4
  // smaller derived shape: no paths, queries, header values, attempt errors, or
5
5
  // redirect inventories.
6
+ import { MAX_HTTP_EVIDENCE_REDIRECTS } from "../../../../lib/http-evidence-bounds.mjs";
6
7
  export const HTTP_SUMMARY_VERSION = 1;
7
8
  export const MAX_HTTP_SUMMARY_ORIGIN_LENGTH = 300;
8
9
  export const MAX_HTTP_SUMMARY_CONTENT_TYPE_LENGTH = 100;
9
- export const MAX_HTTP_SUMMARY_REDIRECTS = 5;
10
+ export const MAX_HTTP_SUMMARY_REDIRECTS = MAX_HTTP_EVIDENCE_REDIRECTS;
10
11
  export const MAX_HTTP_SECURITY_HEADER_INPUTS = 20;
11
12
  export const HTTP_SECURITY_HEADER_TOKENS = Object.freeze([
12
13
  'content-security-policy',