@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
@@ -0,0 +1,99 @@
1
+ export const CLI_LOOKUP_PLAN_SCHEMA = 'whoisleuth.cli.lookup-plan';
2
+ export const CLI_LOOKUP_PLAN_VERSION = 1;
3
+ const RDAP = Object.freeze({
4
+ source: 'rdap',
5
+ purpose: 'Collect authoritative registration or allocation evidence where supported.',
6
+ disclosure: 'The normalized target is sent to the applicable RDAP bootstrap and service endpoints.',
7
+ conditional: false,
8
+ });
9
+ const WHOIS = Object.freeze({
10
+ source: 'whois',
11
+ purpose: 'Collect separately attributed registry and referral publications.',
12
+ disclosure: 'The normalized target is sent over bounded TCP connections to applicable WHOIS services.',
13
+ conditional: false,
14
+ });
15
+ const FAST_DOMAIN_EVIDENCE = Object.freeze({
16
+ source: 'domain_evidence',
17
+ purpose: 'Derive a registration state from RDAP, with bounded DNS delegation fallback when needed.',
18
+ disclosure: 'DNS resolvers may receive the registrable domain when RDAP does not provide a usable record.',
19
+ conditional: true,
20
+ });
21
+ const DEEP_DOMAIN_EVIDENCE = Object.freeze({
22
+ source: 'domain_evidence',
23
+ purpose: 'Collect bounded DNS, HTTP, TLS, page-identity, technology, and security-posture evidence.',
24
+ disclosure: 'DNS resolvers and the target website infrastructure receive the hostname through bounded probes.',
25
+ conditional: false,
26
+ });
27
+ const REGISTRAR_RDAP = Object.freeze({
28
+ source: 'registrar_rdap',
29
+ purpose: 'Collect a separately attributed registrar RDAP publication when registry evidence advertises one.',
30
+ disclosure: 'The registrable domain is sent to the advertised registrar RDAP service.',
31
+ conditional: true,
32
+ });
33
+ const NETWORK_CONTEXT = Object.freeze({
34
+ source: 'network_context',
35
+ purpose: 'Add allocation and routing context for public addresses observed during domain collection.',
36
+ disclosure: 'Observed public addresses may be sent to applicable RDAP services.',
37
+ conditional: true,
38
+ });
39
+ const REVERSE_DNS = Object.freeze({
40
+ source: 'reverse_dns',
41
+ purpose: 'Collect operator-published reverse-DNS context for a public IP address.',
42
+ disclosure: 'A DNS resolver receives the reverse lookup for the normalized IP address.',
43
+ conditional: false,
44
+ });
45
+ function plannedSources(classified, deep) {
46
+ if (!deep) {
47
+ return Object.freeze(classified.type === 'domain' ? [RDAP, FAST_DOMAIN_EVIDENCE] : [RDAP]);
48
+ }
49
+ if (classified.type === 'domain') {
50
+ return Object.freeze([RDAP, WHOIS, DEEP_DOMAIN_EVIDENCE, REGISTRAR_RDAP, NETWORK_CONTEXT]);
51
+ }
52
+ if (classified.type === 'ipv4' || classified.type === 'ipv6') {
53
+ return Object.freeze([RDAP, WHOIS, REVERSE_DNS]);
54
+ }
55
+ return Object.freeze([RDAP, WHOIS]);
56
+ }
57
+ function buildCliLookupPlan(query, classified, deep) {
58
+ return Object.freeze({
59
+ schema: CLI_LOOKUP_PLAN_SCHEMA,
60
+ version: CLI_LOOKUP_PLAN_VERSION,
61
+ mode: deep ? 'deep' : 'fast',
62
+ target: Object.freeze({
63
+ query,
64
+ type: classified.type,
65
+ normalized: classified.value,
66
+ ...(classified.inputHostname ? { inputHostname: classified.inputHostname } : {}),
67
+ ...(classified.registrableDomain ? { registrableDomain: classified.registrableDomain } : {}),
68
+ }),
69
+ planning: Object.freeze({
70
+ networkRequestsMade: false,
71
+ collectionRequiresNetwork: true,
72
+ sources: plannedSources(classified, deep),
73
+ }),
74
+ limitations: Object.freeze([
75
+ 'This is a local preflight. It does not test source availability, feature configuration, cache state, redirects, referrals, or the exact number of requests a completed lookup may require.',
76
+ 'Conditional sources may be skipped when prerequisite evidence is absent, unsupported, disabled, or unavailable.',
77
+ ]),
78
+ });
79
+ }
80
+ function formatCliLookupPlan(plan) {
81
+ const lines = [
82
+ 'WHOISleuth lookup preflight',
83
+ `Target: ${plan.target.normalized}`,
84
+ `Type: ${plan.target.type}`,
85
+ `Mode: ${plan.mode}`,
86
+ 'Network requests made: no',
87
+ '',
88
+ 'Planned collection:',
89
+ ];
90
+ for (const source of plan.planning.sources) {
91
+ lines.push(` ${source.source}${source.conditional ? ' (conditional)' : ''}`);
92
+ lines.push(` ${source.purpose}`);
93
+ lines.push(` Disclosure: ${source.disclosure}`);
94
+ }
95
+ for (const limitation of plan.limitations)
96
+ lines.push('', `Limitation: ${limitation}`);
97
+ return `${lines.join('\n')}\n`;
98
+ }
99
+ export { buildCliLookupPlan, formatCliLookupPlan };
@@ -0,0 +1,123 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { CliUsageError } from "./errors.mjs";
3
+ import { buildCliLookupDiff } from "./lookup-diff.mjs";
4
+ import { parseSavedLookupDocument } from "./saved-lookup.mjs";
5
+ export const CLI_LOOKUP_TIMELINE_SCHEMA = 'whoisleuth.cli.lookup-timeline';
6
+ export const CLI_LOOKUP_TIMELINE_VERSION = 1;
7
+ export const MAX_LOOKUP_TIMELINE_OBSERVATIONS = 20;
8
+ export const MAX_LOOKUP_TIMELINE_INPUT_BYTES = 32 * 1024 * 1024;
9
+ function buildCliLookupTimeline(inputs, generatedAt = new Date().toISOString()) {
10
+ if (inputs.length < 2 || inputs.length > MAX_LOOKUP_TIMELINE_OBSERVATIONS) {
11
+ throw new CliUsageError(`Lookup timeline requires from 2 to ${MAX_LOOKUP_TIMELINE_OBSERVATIONS} saved Lookup documents.`);
12
+ }
13
+ const totalBytes = inputs.reduce((total, input) => total + Buffer.byteLength(input, 'utf8'), 0);
14
+ if (totalBytes > MAX_LOOKUP_TIMELINE_INPUT_BYTES) {
15
+ throw new CliUsageError(`Lookup timeline input is limited to ${MAX_LOOKUP_TIMELINE_INPUT_BYTES} bytes in total.`);
16
+ }
17
+ const normalized = inputs.map((input, index) => ({
18
+ input,
19
+ document: parseSavedLookupDocument(input, { label: `Lookup timeline input ${index + 1}` }),
20
+ inputIndex: index,
21
+ }));
22
+ const domain = normalized[0]?.document.registrableDomain;
23
+ if (!domain || normalized.some((item) => item.document.registrableDomain !== domain)) {
24
+ throw new CliUsageError('Lookup timeline requires observations for exactly one domain.');
25
+ }
26
+ normalized.sort((left, right) => (Date.parse(left.document.generatedAt) - Date.parse(right.document.generatedAt)
27
+ || left.inputIndex - right.inputIndex));
28
+ for (let index = 1; index < normalized.length; index++) {
29
+ if (normalized[index - 1]?.document.generatedAt === normalized[index]?.document.generatedAt) {
30
+ throw new CliUsageError('Lookup timeline observation times must be unique.');
31
+ }
32
+ }
33
+ const observations = normalized.map((item, index) => ({
34
+ index,
35
+ generatedAt: item.document.generatedAt,
36
+ mode: item.document.mode,
37
+ }));
38
+ const transitions = normalized.slice(1).map((current, index) => {
39
+ const previous = normalized[index];
40
+ if (!previous)
41
+ throw new TypeError('Lookup timeline ordering failed.');
42
+ const diff = buildCliLookupDiff(previous.input, current.input, generatedAt, { domainMode: 'same' });
43
+ return {
44
+ index,
45
+ fromObservation: index,
46
+ toObservation: index + 1,
47
+ comparison: diff.comparison,
48
+ };
49
+ });
50
+ const fields = {
51
+ conflicting: 0,
52
+ different: 0,
53
+ equal: 0,
54
+ missing: 0,
55
+ notRecorded: 0,
56
+ unavailable: 0,
57
+ };
58
+ let transitionsWithObservedChanges = 0;
59
+ for (const transition of transitions) {
60
+ const counts = transition.comparison.counts;
61
+ fields.conflicting += counts.conflicting;
62
+ fields.different += counts.different;
63
+ fields.equal += counts.equal;
64
+ fields.missing += counts.missing;
65
+ fields.notRecorded += counts.not_recorded;
66
+ fields.unavailable += counts.unavailable;
67
+ if (counts.conflicting + counts.different > 0)
68
+ transitionsWithObservedChanges += 1;
69
+ }
70
+ return {
71
+ schema: CLI_LOOKUP_TIMELINE_SCHEMA,
72
+ version: CLI_LOOKUP_TIMELINE_VERSION,
73
+ generatedAt,
74
+ domain,
75
+ observations,
76
+ transitions,
77
+ summary: {
78
+ observationCount: observations.length,
79
+ transitionCount: transitions.length,
80
+ transitionsWithObservedChanges,
81
+ fields,
82
+ },
83
+ privacy: {
84
+ filenamesRetained: 0,
85
+ rawRegistryPayloadsCopied: 0,
86
+ },
87
+ limitations: [
88
+ 'The timeline compares only bounded normalized fields already present in the selected saved Lookup documents and makes no network request.',
89
+ 'Input filenames and raw registry payloads are not copied into the timeline output.',
90
+ 'Missing, unavailable, and not-recorded evidence remain separate from an observed difference.',
91
+ 'A difference can reflect a domain change or changed collection conditions and does not by itself establish current state, ownership, intent, safety, or maliciousness.',
92
+ ],
93
+ };
94
+ }
95
+ function formatCliLookupTimeline(document) {
96
+ const output = [
97
+ 'Same-domain observation timeline',
98
+ `Domain ${document.domain}`,
99
+ `Observations ${document.summary.observationCount}`,
100
+ `First observed ${document.observations[0]?.generatedAt || 'Unavailable'}`,
101
+ `Last observed ${document.observations.at(-1)?.generatedAt || 'Unavailable'}`,
102
+ `Changed transitions ${document.summary.transitionsWithObservedChanges}`,
103
+ ];
104
+ for (const transition of document.transitions) {
105
+ const from = document.observations[transition.fromObservation];
106
+ const to = document.observations[transition.toObservation];
107
+ const counts = transition.comparison.counts;
108
+ const changedRows = transition.comparison.rows.filter((row) => (row.state === 'conflicting' || row.state === 'different' || row.state === 'missing'));
109
+ output.push('', `${from?.generatedAt || 'Unavailable'} to ${to?.generatedAt || 'Unavailable'}`, ` Observed changes ${counts.conflicting + counts.different}`, ` One-sided fields ${counts.missing}`, ` Unavailable ${counts.unavailable + counts.not_recorded}`);
110
+ if (!changedRows.length)
111
+ output.push(' No observed field changes in comparable evidence.');
112
+ for (const row of changedRows.slice(0, 10)) {
113
+ output.push(` ${row.label} [${row.state.replaceAll('_', ' ')}]: ${row.left} -> ${row.right}`);
114
+ }
115
+ if (changedRows.length > 10)
116
+ output.push(` ${changedRows.length - 10} additional changed fields omitted; use --json for the complete bounded comparison.`);
117
+ }
118
+ output.push('', 'Limitations:');
119
+ for (const limitation of document.limitations)
120
+ output.push(` - ${limitation}`);
121
+ return `${output.join('\n')}\n`;
122
+ }
123
+ export { buildCliLookupTimeline, formatCliLookupTimeline };
@@ -1,8 +1,9 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { getDomain } from 'tldts';
3
+ import { analyzeTlsaEvidence } from "../lib/tlsa-evidence.mjs";
3
4
  import { CliUsageError } from "./errors.mjs";
4
5
  export const CLI_MAIL_REVIEW_SCHEMA = 'whoisleuth.cli.mail-review';
5
- export const CLI_MAIL_REVIEW_VERSION = 1;
6
+ export const CLI_MAIL_REVIEW_VERSION = 2;
6
7
  export const MAX_MAIL_REVIEW_INPUT_BYTES = 16 * 1024 * 1024;
7
8
  export const MAX_MAIL_REVIEW_ROWS = 500;
8
9
  function record(value) {
@@ -53,6 +54,21 @@ function mailState(input) {
53
54
  return 'no_explicit_mx';
54
55
  return 'evidence_incomplete';
55
56
  }
57
+ function alignMailDaneEvidence(report, hosts) {
58
+ const aligned = report.service?.transport === 'tcp'
59
+ && report.service.port === 25
60
+ && hosts.includes(report.service.hostname);
61
+ if (aligned || report.state === 'invalid')
62
+ return report;
63
+ return Object.freeze({
64
+ ...report,
65
+ state: 'invalid',
66
+ limitations: Object.freeze([
67
+ ...report.limitations,
68
+ 'The TLSA service name did not identify port 25 on an MX hostname retained for this domain.',
69
+ ]),
70
+ });
71
+ }
56
72
  function normalizeMailRow(value) {
57
73
  const item = record(value);
58
74
  if (item.ok !== true)
@@ -70,6 +86,18 @@ function normalizeMailRow(value) {
70
86
  const hasSpf = boolean(availability.hasSpf) ?? boolean(summary.hasSpf);
71
87
  const hasDmarc = boolean(availability.hasDmarc) ?? boolean(summary.hasDmarc);
72
88
  const state = mailState({ dnsStatus, hasMx, hasNullMx, hasSpf, hasDmarc });
89
+ const tlsaInput = record(item.tlsaEvidence);
90
+ const dane = Object.keys(tlsaInput).length
91
+ ? alignMailDaneEvidence(analyzeTlsaEvidence({
92
+ serviceName: tlsaInput.serviceName,
93
+ dnssecState: tlsaInput.dnssecState,
94
+ pkixValidationState: tlsaInput.pkixValidationState,
95
+ records: tlsaInput.records,
96
+ certificateDerBase64: tlsaInput.certificateDerBase64,
97
+ spkiDerBase64: tlsaInput.spkiDerBase64,
98
+ authorityMaterials: tlsaInput.authorityMaterials,
99
+ }), hosts)
100
+ : null;
73
101
  return {
74
102
  domain,
75
103
  state,
@@ -80,9 +108,11 @@ function normalizeMailRow(value) {
80
108
  hasDmarc,
81
109
  mxHosts: hosts,
82
110
  providerDomains: providerDomains(hosts),
111
+ dane,
83
112
  limitations: [
84
113
  ...(dnsStatus === 'partial' ? ['DNS evidence was partial.'] : []),
85
114
  ...(state === 'no_explicit_mx' ? ['No explicit MX is not equivalent to a null MX or proof that delivery is impossible.'] : []),
115
+ ...(dane?.state === 'matched' ? [] : dane ? ['Supplied TLSA evidence did not establish one complete DNSSEC-validated match.'] : ['No TLSA evidence was supplied for offline DANE comparison.']),
86
116
  'Mail configuration does not establish use, control, intent, safety, or maliciousness.',
87
117
  ],
88
118
  };
@@ -142,6 +172,17 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
142
172
  };
143
173
  for (const row of rows)
144
174
  counts[row.state] += 1;
175
+ const daneCounts = {
176
+ matched: 0,
177
+ different: 0,
178
+ partial: 0,
179
+ untrusted: 0,
180
+ unavailable: 0,
181
+ invalid: 0,
182
+ not_supplied: 0,
183
+ };
184
+ for (const row of rows)
185
+ daneCounts[row.dane?.state ?? 'not_supplied'] += 1;
145
186
  const providerMap = new Map();
146
187
  for (const row of rows) {
147
188
  for (const provider of row.providerDomains) {
@@ -165,11 +206,13 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
165
206
  version: CLI_MAIL_REVIEW_VERSION,
166
207
  generatedAt,
167
208
  counts,
209
+ daneCounts,
168
210
  rows,
169
211
  providerRelationships,
170
212
  limitations: [
171
213
  'This review is passive and uses DNS evidence already retained in a WHOISleuth Bulk result; it makes no network request.',
172
214
  'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
215
+ 'Optional TLSA evidence is compared offline. A DANE match requires separately validated DNSSEC evidence from the same observation.',
173
216
  'SMTP delivery, mailbox existence, catch-all behavior, banner collection, and message acceptance were not tested.',
174
217
  ],
175
218
  };
@@ -183,12 +226,14 @@ function formatCliMailReview(document) {
183
226
  `Null MX ${document.counts.null_mx}`,
184
227
  `No explicit MX ${document.counts.no_explicit_mx}`,
185
228
  `Incomplete ${document.counts.evidence_incomplete + document.counts.mail_auth_incomplete}`,
229
+ `DANE matched ${document.daneCounts.matched}`,
186
230
  '',
187
231
  ];
188
232
  for (const row of document.rows) {
189
233
  lines.push(`${row.domain} ${row.state.replaceAll('_', ' ')}`);
190
234
  lines.push(` MX providers ${row.providerDomains.join(', ') || 'None observed'}`);
191
235
  lines.push(` SPF / DMARC ${row.hasSpf === null ? 'unknown' : row.hasSpf ? 'observed' : 'not observed'} / ${row.hasDmarc === null ? 'unknown' : row.hasDmarc ? 'observed' : 'not observed'}`);
236
+ lines.push(` DANE / TLSA ${row.dane?.state ?? 'not supplied'}`);
192
237
  }
193
238
  if (document.providerRelationships.length) {
194
239
  lines.push('', 'Shared mail-provider relationships');
@@ -201,4 +246,4 @@ function formatCliMailReview(document) {
201
246
  lines.push(` - ${limitation}`);
202
247
  return `${lines.join('\n')}\n`;
203
248
  }
204
- export { buildCliMailReview, formatCliMailReview, mailState, normalizeMailRow };
249
+ export { alignMailDaneEvidence, buildCliMailReview, formatCliMailReview, mailState, normalizeMailRow };
package/cli/manual.mjs CHANGED
@@ -7,7 +7,8 @@ function roffText(value) {
7
7
  function buildCliManual(options) {
8
8
  const commands = options.commands.map((command) => {
9
9
  const detail = options.details[command];
10
- return `.SS ${roffText(command)}\n${roffText(detail.description)}\n.PP\n.B ${roffText(options.usage[command])}\n.PP\nExample: ${roffText(detail.example)}\n.PP\nBoundary: ${roffText(detail.boundary)}`;
10
+ const collection = options.collections[command];
11
+ return `.SS ${roffText(command)}\n${roffText(detail.description)}\n.PP\n.B ${roffText(options.usage[command])}\n.PP\nExample: ${roffText(detail.example)}\n.PP\nCollection: ${roffText(collection.mode)}. ${roffText(collection.scope)}\n.PP\nBoundary: ${roffText(detail.boundary)}`;
11
12
  }).join('\n');
12
13
  return `.TH WHOISLEUTH 1 "" "WHOISleuth ${roffText(options.version)}" "User Commands"
13
14
  .SH NAME
@@ -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'),