@slicedearth/whoisleuth-cli 1.43.0 → 1.47.4

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 (153) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +67 -10
  4. package/bin/whoisleuth.mjs +22 -9
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +67 -31
  10. package/cli/bulk-command-runner.mjs +16 -6
  11. package/cli/bulk.mjs +18 -7
  12. package/cli/case-pack.mjs +819 -34
  13. package/cli/command-reference.mjs +48 -24
  14. package/cli/completion.mjs +50 -11
  15. package/cli/config-profile.mjs +15 -12
  16. package/cli/ct-event-intake.mjs +32 -2
  17. package/cli/discovery-observation-snapshot.mjs +8 -14
  18. package/cli/discovery-scan-command-runner.mjs +18 -6
  19. package/cli/discovery-snapshot.mjs +8 -15
  20. package/cli/discovery-workflow.mjs +2 -3
  21. package/cli/doctor.mjs +2 -2
  22. package/cli/domain-control-monitor.mjs +21 -15
  23. package/cli/domain-control-observations.mjs +25 -2
  24. package/cli/errors.mjs +7 -1
  25. package/cli/evidence-command-runner.mjs +17 -11
  26. package/cli/evidence-signing.mjs +73 -45
  27. package/cli/export-evidence.mjs +11 -4
  28. package/cli/formatters/html.mjs +3 -3
  29. package/cli/formatters/json.mjs +12 -4
  30. package/cli/formatters/terminal.mjs +4 -1
  31. package/cli/http.mjs +2 -1
  32. package/cli/input.mjs +57 -0
  33. package/cli/interchange-report.mjs +226 -0
  34. package/cli/investigation-manifest.mjs +16 -17
  35. package/cli/investigation-run.mjs +71 -23
  36. package/cli/local-mmdb-review.mjs +45 -14
  37. package/cli/lookup-command-runner.mjs +2 -2
  38. package/cli/lookup-diff.mjs +9 -0
  39. package/cli/lookup-reconcile.mjs +1 -1
  40. package/cli/mail-review.mjs +5 -5
  41. package/cli/manual.mjs +1 -1
  42. package/cli/network-command-runner.mjs +78 -0
  43. package/cli/offline-evidence-review.mjs +28 -8
  44. package/cli/page-compare.mjs +8 -5
  45. package/cli/registry-cohort.mjs +385 -61
  46. package/cli/registry-fixture-scaffold.mjs +4 -3
  47. package/cli/registry-support.mjs +2 -1
  48. package/cli/retained-artifact-diff.mjs +376 -0
  49. package/cli/risk-calibration.mjs +7 -5
  50. package/cli/runner.mjs +150 -177
  51. package/cli/saved-lookup.mjs +26 -3
  52. package/cli/sharing-review.mjs +12 -6
  53. package/docs/cli-reference.md +467 -74
  54. package/docs/cli.md +25 -5
  55. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  56. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  57. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  58. package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
  59. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  60. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  61. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  62. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  63. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  64. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  65. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  66. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  67. package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
  68. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  69. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  70. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  71. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  72. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  73. package/frontend/src/lib/analysis/case-record-operations.js +43 -13
  74. package/frontend/src/lib/analysis/case-report.js +39 -6
  75. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  76. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  77. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  78. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  79. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  80. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  81. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  82. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  83. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  84. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  85. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  86. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  87. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  88. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  89. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  90. package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
  91. package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
  92. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  93. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  94. package/frontend/src/lib/analysis/utils.js +27 -4
  95. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  96. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  97. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  98. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  99. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  100. package/frontend/src/lib/analysis/workspace-archive.js +105 -19
  101. package/lib/application-version.mjs +3 -0
  102. package/lib/availability.mjs +14 -12
  103. package/lib/bounded-file.mjs +82 -0
  104. package/lib/bounded-json-response.mjs +170 -0
  105. package/lib/bounded-json.mjs +249 -0
  106. package/lib/browser-library-profile.mjs +167 -38
  107. package/lib/certificate-extension-profile.mjs +2 -1
  108. package/lib/cryptographic-assurance.mjs +155 -0
  109. package/lib/ct-search.mjs +87 -47
  110. package/lib/dns-intelligence.mjs +3 -2
  111. package/lib/dns-resolver-selection.mjs +6 -4
  112. package/lib/dnssec-chain-validation.mjs +1053 -0
  113. package/lib/dnssec-evidence-validation.mjs +10 -3
  114. package/lib/domain-change-packet.mjs +7 -5
  115. package/lib/domain-control-manifest.mjs +17 -129
  116. package/lib/domain-posture.mjs +7 -4
  117. package/lib/evidence-export.mjs +751 -78
  118. package/lib/evidence-report-markdown.mjs +3 -0
  119. package/lib/evidence-report.mjs +5 -0
  120. package/lib/favicon.mjs +8 -5
  121. package/lib/http-evidence-bounds.mjs +3 -0
  122. package/lib/http-intelligence.mjs +2 -5
  123. package/lib/interchange-fidelity-registry.mjs +122 -0
  124. package/lib/local-geoip-evidence.mjs +2 -1
  125. package/lib/lookup-cache.mjs +5 -1
  126. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  127. package/lib/lookup-response-contract.mjs +691 -3
  128. package/lib/lookup.mjs +2 -2
  129. package/lib/observation.mjs +7 -6
  130. package/lib/outbound-identity.mjs +2 -3
  131. package/lib/perceptual-hash.mjs +12 -4
  132. package/lib/portable-generator.mjs +21 -0
  133. package/lib/provider-policy-admission.mjs +9 -4
  134. package/lib/rdap-normalization.mjs +18 -5
  135. package/lib/rdap-registrar.mjs +5 -1
  136. package/lib/rdap-transport.mjs +6 -6
  137. package/lib/registry-capabilities.mjs +3 -0
  138. package/lib/registry-capability-catalogue.mjs +12 -2
  139. package/lib/registry-comparison.mjs +28 -11
  140. package/lib/risk-calibration-summary.mjs +338 -0
  141. package/lib/rpki-evidence.mjs +9 -8
  142. package/lib/safe-fetch.mjs +96 -42
  143. package/lib/security-txt.mjs +26 -3
  144. package/lib/service-binding-dns.mjs +6 -1
  145. package/lib/smtp-transport-review.mjs +784 -0
  146. package/lib/sslbl-certificates.generated.mjs +136 -134
  147. package/lib/threat-intelligence-runtime.mjs +4 -13
  148. package/lib/threat-intelligence-types.mjs +18 -1
  149. package/lib/tls-intelligence.mjs +11 -5
  150. package/lib/tlsa-evidence.mjs +37 -7
  151. package/lib/website-technology.mjs +2 -6
  152. package/package.json +1 -1
  153. package/third-party-notices.txt +95 -5817
package/lib/lookup.mjs CHANGED
@@ -320,7 +320,7 @@ async function runUnifiedLookup(classified, options = {}) {
320
320
  ? { ...rdapRecord, ...(registrarRdap ? { registrarRdap } : {}) }
321
321
  : {
322
322
  error: rdapResult.status === 'rejected'
323
- ? errorMessage(rdapResult.reason, 'RDAP lookup failed')
323
+ ? boundedSourceDetail(rdapResult.reason, 'RDAP lookup failed')
324
324
  : 'No RDAP registry found for this query via IANA bootstrap',
325
325
  attempts: rdapAttempts,
326
326
  };
@@ -337,7 +337,7 @@ async function runUnifiedLookup(classified, options = {}) {
337
337
  else {
338
338
  whois = {
339
339
  error: whoisResult.status === 'rejected'
340
- ? errorMessage(whoisResult.reason, 'WHOIS lookup failed')
340
+ ? boundedSourceDetail(whoisResult.reason, 'WHOIS lookup failed')
341
341
  : 'WHOIS returned no referral chain',
342
342
  };
343
343
  }
@@ -12,9 +12,10 @@ const STATUSES = new Set([
12
12
  'not_applicable',
13
13
  ]);
14
14
  const SCAN_MODES = new Set(['fast', 'deep']);
15
- const MAX_LIMITATIONS = 10;
16
- const MAX_LIMITATION_LENGTH = 300;
17
- const MAX_DIAGNOSTICS = 20;
15
+ const MAX_OBSERVATION_LIMITATIONS = 10;
16
+ const MAX_OBSERVATION_LIMITATION_LENGTH = 300;
17
+ const MAX_OBSERVATION_DIAGNOSTICS = 20;
18
+ const MAX_DIAGNOSTICS = MAX_OBSERVATION_DIAGNOSTICS;
18
19
  const MAX_DIAGNOSTIC_KEY = 40;
19
20
  const MAX_DIAGNOSTIC_STRING = 240;
20
21
  const MAX_DURATION_MS = 120_000;
@@ -76,9 +77,9 @@ function createObservation(input = {}) {
76
77
  const observedAt = isoTimestamp(input.observedAt) || new Date().toISOString();
77
78
  const duration = Number(input.durationMs);
78
79
  const limitations = [...new Set((Array.isArray(input.limitations) ? input.limitations : [])
79
- .map((item) => safeString(item, MAX_LIMITATION_LENGTH))
80
+ .map((item) => safeString(item, MAX_OBSERVATION_LIMITATION_LENGTH))
80
81
  .filter((item) => item !== null))]
81
- .slice(0, MAX_LIMITATIONS);
82
+ .slice(0, MAX_OBSERVATION_LIMITATIONS);
82
83
  return {
83
84
  version: OBSERVATION_VERSION,
84
85
  status,
@@ -111,4 +112,4 @@ function readObservationEnvelope(value) {
111
112
  }
112
113
  return { state: 'supported', observation: createObservation(record) };
113
114
  }
114
- export { OBSERVATION_VERSION, createObservation, readObservationEnvelope, };
115
+ export { MAX_OBSERVATION_DIAGNOSTICS, MAX_OBSERVATION_LIMITATIONS, MAX_OBSERVATION_LIMITATION_LENGTH, OBSERVATION_VERSION, createObservation, readObservationEnvelope, };
@@ -1,8 +1,7 @@
1
- import packageDocument from '../package.json' with { type: 'json' };
1
+ import { WHOISLEUTH_APPLICATION_VERSION } from "./application-version.mjs";
2
2
  import { WHOISLEUTH_PROJECT_URL, WHOISLEUTH_REQUEST_POLICY_URL, } from "./project-metadata.mjs";
3
- import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
4
3
  export { WHOISLEUTH_PROJECT_URL, WHOISLEUTH_REQUEST_POLICY_URL };
5
- export const WHOISLEUTH_USER_AGENT_VERSION = normalizeBoundedSemanticVersion(packageDocument.version, 'Application manifest');
4
+ export const WHOISLEUTH_USER_AGENT_VERSION = WHOISLEUTH_APPLICATION_VERSION;
6
5
  export const WHOISLEUTH_USER_AGENT = `WHOISleuth/${WHOISLEUTH_USER_AGENT_VERSION} (+${WHOISLEUTH_REQUEST_POLICY_URL})`;
7
6
  export function whoisleuthRequestHeaders(headers = {}) {
8
7
  return { ...headers, 'User-Agent': WHOISLEUTH_USER_AGENT };
@@ -406,18 +406,26 @@ function dHash(image) {
406
406
  * back to the exact hash.
407
407
  */
408
408
  function faviconPerceptualHash(buf) {
409
+ return inspectDecodedImage(buf).perceptualHash;
410
+ }
411
+ function inspectDecodedImage(buf) {
409
412
  try {
410
413
  const image = decodeImage(buf);
411
414
  if (!image)
412
- return null;
413
- return dHash(image);
415
+ return Object.freeze({ decodable: false, width: null, height: null, perceptualHash: null });
416
+ return Object.freeze({
417
+ decodable: true,
418
+ width: image.width,
419
+ height: image.height,
420
+ perceptualHash: dHash(image),
421
+ });
414
422
  }
415
423
  catch {
416
- return null;
424
+ return Object.freeze({ decodable: false, width: null, height: null, perceptualHash: null });
417
425
  }
418
426
  }
419
427
  // The same bounded decoder is also suitable for fixed-size PNG screenshots
420
428
  // produced by the optional local capture package. Keep the generic name as an
421
429
  // alias so the evidence contract does not imply that a screenshot is a favicon.
422
430
  const imagePerceptualHash = faviconPerceptualHash;
423
- export { faviconPerceptualHash, hammingDistanceHex, imagePerceptualHash };
431
+ export { faviconPerceptualHash, hammingDistanceHex, imagePerceptualHash, inspectDecodedImage };
@@ -0,0 +1,21 @@
1
+ import { WHOISLEUTH_SOURCE_REPOSITORY_URL, } from "./project-metadata.mjs";
2
+ import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
3
+ export function buildPortableGeneratorMetadata(applicationVersion) {
4
+ let version = null;
5
+ if (applicationVersion !== null && applicationVersion !== undefined && applicationVersion !== '') {
6
+ try {
7
+ version = normalizeBoundedSemanticVersion(applicationVersion, 'Portable report application');
8
+ }
9
+ catch {
10
+ version = null;
11
+ }
12
+ }
13
+ return Object.freeze({
14
+ name: 'WHOISleuth',
15
+ version,
16
+ projectUrl: WHOISLEUTH_SOURCE_REPOSITORY_URL,
17
+ });
18
+ }
19
+ export function portableGeneratorAttribution(generator) {
20
+ return `Generated with ${generator.name}${generator.version ? ` ${generator.version}` : ''} · Source: ${generator.projectUrl}`;
21
+ }
@@ -1,4 +1,5 @@
1
1
  export const PROVIDER_POLICY_MAX_REVIEW_AGE_DAYS = 180;
2
+ export const PROVIDER_POLICY_MAX_FUTURE_SKEW_MS = 5 * 60 * 1000;
2
3
  export const DEPLOYMENT_PURPOSES = ['personal', 'internal', 'commercial'];
3
4
  function deploymentPurpose(value) {
4
5
  const normalized = String(value ?? '').trim().toLowerCase();
@@ -6,13 +7,17 @@ function deploymentPurpose(value) {
6
7
  ? normalized
7
8
  : null;
8
9
  }
10
+ export function providerPolicyReviewAgeDays(reviewedAtValue, now) {
11
+ const reviewedAt = typeof reviewedAtValue === 'string' ? Date.parse(reviewedAtValue) : Number.NaN;
12
+ if (!Number.isFinite(reviewedAt) || !Number.isFinite(now) || reviewedAt > now + PROVIDER_POLICY_MAX_FUTURE_SKEW_MS) {
13
+ return null;
14
+ }
15
+ return Math.max(0, Math.floor((now - reviewedAt) / 86_400_000));
16
+ }
9
17
  export function providerPolicyAdmission(terms, env = process.env, now = Date.now()) {
10
18
  const source = env && typeof env === 'object' ? env : {};
11
19
  const purpose = deploymentPurpose(source.WHOISLEUTH_DEPLOYMENT_PURPOSE);
12
- const reviewedAt = Date.parse(terms.reviewedAt);
13
- const reviewAgeDays = Number.isFinite(reviewedAt)
14
- ? Math.max(0, Math.floor((now - reviewedAt) / 86_400_000))
15
- : null;
20
+ const reviewAgeDays = providerPolicyReviewAgeDays(terms.reviewedAt, now);
16
21
  if (!purpose) {
17
22
  return {
18
23
  allowed: false,
@@ -564,12 +564,25 @@ function parseRdapObject(type, data) {
564
564
  : {};
565
565
  const v4 = Array.isArray(ipAddresses.v4) ? ipAddresses.v4 : [];
566
566
  const v6 = Array.isArray(ipAddresses.v6) ? ipAddresses.v6 : [];
567
- if (v4.length + v6.length > 20)
567
+ const maximumCandidates = 200;
568
+ const maximumAddresses = 20;
569
+ const addresses = [];
570
+ let visited = 0;
571
+ for (const [candidates, family] of [[v4, 4], [v6, 6]]) {
572
+ for (let index = 0; index < candidates.length; index += 1) {
573
+ if (visited >= maximumCandidates || addresses.length >= maximumAddresses)
574
+ break;
575
+ visited += 1;
576
+ const address = boundedString(candidates[index], 80);
577
+ if (address && net.isIP(address) === family)
578
+ addresses.push(address);
579
+ }
580
+ if (visited >= maximumCandidates || addresses.length >= maximumAddresses)
581
+ break;
582
+ }
583
+ if (v4.length + v6.length > visited || v4.length + v6.length > maximumAddresses) {
568
584
  nameserverAddressesTruncated = true;
569
- const addresses = [
570
- ...v4.map((address) => boundedString(address, 80)).filter((address) => address && net.isIP(address) === 4),
571
- ...v6.map((address) => boundedString(address, 80)).filter((address) => address && net.isIP(address) === 6),
572
- ].slice(0, 20);
585
+ }
573
586
  return {
574
587
  name: boundedString(nameserver.ldhName || nameserver.unicodeName, 253),
575
588
  addresses,
@@ -85,11 +85,14 @@ export function selectRegistrarRdapLink(domain, links, registryEndpoint = null)
85
85
  }
86
86
  return null;
87
87
  }
88
+ function registrarRdapTransport(override, safeDefault = fetchRdapDetailedWithTimeout) {
89
+ return override ?? safeDefault;
90
+ }
88
91
  export async function fetchRegistrarRdapRecordWithParser(domain, registryRecord, parseRdap, options = {}) {
89
92
  const canonical = canonicalRdapDomain(domain);
90
93
  if (!canonical)
91
94
  throw new Error('A valid domain is required for registrar RDAP.');
92
- const fetchUpstream = options.fetchUpstream || fetchRdapDetailedWithTimeout;
95
+ const fetchUpstream = registrarRdapTransport(options.fetchUpstream);
93
96
  return cached(`rdap-registrar:domain:${canonical}`, async () => {
94
97
  const registryParsed = recordOrNull(registryRecord?.parsed);
95
98
  const registryEndpoint = typeof registryRecord?.rdapServer === 'string' ? registryRecord.rdapServer : null;
@@ -194,3 +197,4 @@ export async function fetchRegistrarRdapRecordWithParser(domain, registryRecord,
194
197
  };
195
198
  });
196
199
  }
200
+ export { registrarRdapTransport };
@@ -3,12 +3,12 @@
3
3
  // lib/rdap.mts.
4
4
  import { readTextCapped, safeFetch, safeFetchDetailed } from "./safe-fetch.mjs";
5
5
  const MAX_RDAP_BYTES = 2000000;
6
- async function fetchRdapWithTimeout(url, options, timeoutMs) {
6
+ async function fetchRdapWithTimeout(url, options, timeoutMs, dependencies = {}) {
7
7
  const controller = new AbortController();
8
8
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
9
9
  try {
10
- const response = await safeFetch(url, { ...options, signal: controller.signal });
11
- const { text, truncated } = await readTextCapped(response, MAX_RDAP_BYTES);
10
+ const response = await (dependencies.fetch ?? safeFetch)(url, { ...options, signal: controller.signal });
11
+ const { text, truncated } = await (dependencies.readText ?? readTextCapped)(response, MAX_RDAP_BYTES);
12
12
  if (truncated)
13
13
  throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
14
14
  return { status: response.status, ok: response.ok, text };
@@ -17,12 +17,12 @@ async function fetchRdapWithTimeout(url, options, timeoutMs) {
17
17
  clearTimeout(timeout);
18
18
  }
19
19
  }
20
- async function fetchRdapDetailedWithTimeout(url, options, timeoutMs) {
20
+ async function fetchRdapDetailedWithTimeout(url, options, timeoutMs, dependencies = {}) {
21
21
  const controller = new AbortController();
22
22
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
23
23
  try {
24
- const result = await safeFetchDetailed(url, { ...options, signal: controller.signal });
25
- const { text, truncated } = await readTextCapped(result.response, MAX_RDAP_BYTES);
24
+ const result = await (dependencies.fetchDetailed ?? safeFetchDetailed)(url, { ...options, signal: controller.signal });
25
+ const { text, truncated } = await (dependencies.readText ?? readTextCapped)(result.response, MAX_RDAP_BYTES);
26
26
  if (truncated)
27
27
  throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
28
28
  return {
@@ -86,6 +86,9 @@ function registryAccessDiagnosticFor(value) {
86
86
  coverageState: capability.coverageState,
87
87
  whoisAccessProfile: capability.whoisAccessProfile,
88
88
  rdapAccessProfile: capability.rdapAccessProfile,
89
+ ...(capability.officialLookupUrl
90
+ ? { officialLookupUrl: capability.officialLookupUrl }
91
+ : {}),
89
92
  limitation: capability.limitation,
90
93
  authority: 'context_only',
91
94
  };
@@ -1,7 +1,7 @@
1
1
  // Registry compatibility metadata. IANA bootstrap and referral discovery
2
2
  // remain authoritative. Explicit query profiles may alter only the first
3
3
  // referred registry query after a fixture-backed adapter is integrated.
4
- const REGISTRY_CAPABILITIES_VERSION = 28;
4
+ const REGISTRY_CAPABILITIES_VERSION = 29;
5
5
  const MAX_CAPABILITY_INPUT_LENGTH = 253;
6
6
  const REGISTRY_STANDARDS_COVERAGE_SNAPSHOT = Object.freeze({
7
7
  schema: 'whoisleuth.registry-standards-coverage',
@@ -165,6 +165,7 @@ const DEFAULT_CAPABILITY = freezeCapability({
165
165
  fixtureScenarios: ['registered', 'not_found', 'rate_limited'],
166
166
  verificationFiles: ['fixtures/whois-registry-fixtures.mts'],
167
167
  documentationUrls: [],
168
+ officialLookupUrl: null,
168
169
  limitation: DISCOVERY_LIMITATION,
169
170
  });
170
171
  const EXPLICIT_CAPABILITY_SEEDS = [
@@ -260,6 +261,7 @@ const EXPLICIT_CAPABILITY_SEEDS = [
260
261
  'https://www.iana.org/domains/root/db/ch.html',
261
262
  'https://www.iana.org/domains/root/db/li.html',
262
263
  ],
264
+ officialLookupUrl: 'https://www.nic.ch/whois/',
263
265
  limitation: SWITCH_ACCESS_LIMITATION,
264
266
  },
265
267
  {
@@ -644,9 +646,11 @@ const EXPLICIT_CAPABILITY_SEEDS = [
644
646
  coverageState: 'access_documented', whoisAccessProfile: 'source-ip-authorization-required',
645
647
  rdapAccessProfile: 'no-iana-service', verificationFiles: [],
646
648
  documentationUrls: [
649
+ 'https://www.dominios.es/es',
647
650
  'https://www.iana.org/domains/root/db/es.html',
648
651
  'https://www.dominios.es/es/sobre-dominios/valores-anadidos/whois-43',
649
652
  ],
653
+ officialLookupUrl: 'https://www.dominios.es/es',
650
654
  limitation: ES_ACCESS_LIMITATION,
651
655
  },
652
656
  {
@@ -665,7 +669,11 @@ const EXPLICIT_CAPABILITY_SEEDS = [
665
669
  whoisParserProfile: 'generic-colon', fixtureScenarios: [],
666
670
  coverageState: 'access_documented', whoisAccessProfile: 'no-iana-service',
667
671
  rdapAccessProfile: 'no-iana-service', verificationFiles: [],
668
- documentationUrls: ['https://www.iana.org/domains/root/db/gt.html'],
672
+ documentationUrls: [
673
+ 'https://www.gt/sitio/',
674
+ 'https://www.iana.org/domains/root/db/gt.html',
675
+ ],
676
+ officialLookupUrl: 'https://www.gt/sitio/',
669
677
  limitation: 'IANA publishes no RDAP service or domain WHOIS service for this suffix. WHOISleuth does not automate the registry website.',
670
678
  },
671
679
  {
@@ -1166,6 +1174,7 @@ const EXPLICIT_CAPABILITY_SEEDS = [
1166
1174
  'https://www.iana.org/domains/root/db/vn.html',
1167
1175
  'https://whois.vnnic.vn/',
1168
1176
  ],
1177
+ officialLookupUrl: 'https://whois.vnnic.vn/',
1169
1178
  limitation: VN_ACCESS_LIMITATION,
1170
1179
  },
1171
1180
  ];
@@ -1191,6 +1200,7 @@ const EXPLICIT_CAPABILITIES = EXPLICIT_CAPABILITY_SEEDS.map((entry) => {
1191
1200
  coverageState: entry.coverageState || 'fixture_verified',
1192
1201
  verificationFiles: entry.verificationFiles || ['fixtures/whois-registry-fixtures.mts'],
1193
1202
  documentationUrls: entry.documentationUrls || [],
1203
+ officialLookupUrl: entry.officialLookupUrl || null,
1194
1204
  limitation: entry.limitation || (rdapAccessProfile === 'no-iana-service'
1195
1205
  ? WHOIS_ONLY_FIXTURE_LIMITATION
1196
1206
  : FIXTURE_LIMITATION),
@@ -107,10 +107,11 @@ function normalizeRdapPublicationStatus(value) {
107
107
  function normalizeNameserver(value) {
108
108
  return normalizeDomain(value);
109
109
  }
110
- function normalizeSet(values, normalizeItem) {
110
+ function normalizeSet(values, normalizeItem, maximum = 200) {
111
111
  if (!Array.isArray(values))
112
112
  return [];
113
- return [...new Set(values.map(normalizeItem).filter((value) => Boolean(value)))].sort();
113
+ return [...new Set(values.slice(0, maximum).map(normalizeItem)
114
+ .filter((value) => Boolean(value)))].sort();
114
115
  }
115
116
  function sameNormalizedValue(left, right) {
116
117
  if (Array.isArray(left) || Array.isArray(right)) {
@@ -138,14 +139,20 @@ function displayValue(value, state, status) {
138
139
  return 'Disabled by deployment policy';
139
140
  return 'Source unavailable';
140
141
  }
141
- if (Array.isArray(value))
142
- return value.join(', ');
143
- return String(value);
142
+ if (Array.isArray(value)) {
143
+ return value.slice(0, 200)
144
+ .filter((item) => typeof item === 'string')
145
+ .map((item) => item.slice(0, 253))
146
+ .join(', ');
147
+ }
148
+ return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
149
+ ? String(value).slice(0, 1_024)
150
+ : 'Not published';
144
151
  }
145
152
  function findRdapEvent(rdap, action) {
146
153
  if (!Array.isArray(rdap.events))
147
154
  return null;
148
- const event = rdap.events.map(record).find((item) => item.action === action);
155
+ const event = rdap.events.slice(0, 100).map(record).find((item) => item.action === action);
149
156
  return event?.date || null;
150
157
  }
151
158
  function rdapLifecycleDate(rdap, field, fallbackAction) {
@@ -223,8 +230,8 @@ export function compareRegistrySources(rdapParsed, whoisParsed, options = {}) {
223
230
  dateField('Expires', 'expiryDate', 'expiration'),
224
231
  dateField('Last updated', 'updatedDate', 'last changed'),
225
232
  compareField('DNSSEC', rdap.dnssec, whois.dnssec, normalizeDnssec, sourceHealth),
226
- compareField('Statuses', rdap.statuses, whois.statuses, (values) => normalizeSet(values, statusNormalizer), sourceHealth),
227
- compareField('Name servers', rdap.nameservers, whois.nameservers, (values) => normalizeSet(values, normalizeNameserver), sourceHealth),
233
+ compareField('Statuses', rdap.statuses, whois.statuses, (values) => normalizeSet(values, statusNormalizer, 100), sourceHealth),
234
+ compareField('Name servers', rdap.nameservers, whois.nameservers, (values) => normalizeSet(values, normalizeNameserver, 200), sourceHealth),
228
235
  ].filter((field) => field !== null);
229
236
  const counts = {
230
237
  equivalent: 0, conflict: 0, rdap_only: 0, whois_only: 0,
@@ -265,7 +272,17 @@ export function compareRdapPublications(registryParsed, registrarParsed, options
265
272
  const registry = record(registryParsed);
266
273
  const registrar = record(registrarParsed);
267
274
  const registrarLifecycle = record(registrar.lifecycle);
268
- const comparison = compareRegistrySources({ ...registry, handle: null }, {
275
+ const comparison = compareRegistrySources({
276
+ domain: registry.domain,
277
+ registrar: registry.registrar,
278
+ registrarIanaId: registry.registrarIanaId,
279
+ lifecycle: registry.lifecycle,
280
+ events: Array.isArray(registry.events) ? registry.events.slice(0, 100) : [],
281
+ dnssec: registry.dnssec,
282
+ statuses: Array.isArray(registry.statuses) ? registry.statuses.slice(0, 100) : [],
283
+ nameservers: Array.isArray(registry.nameservers) ? registry.nameservers.slice(0, 200) : [],
284
+ handle: null,
285
+ }, {
269
286
  domainName: registrar.domain,
270
287
  registrar: registrarValue(registrar.registrar),
271
288
  registrarIanaId: registrar.registrarIanaId,
@@ -276,8 +293,8 @@ export function compareRdapPublications(registryParsed, registrarParsed, options
276
293
  updatedDate: registrarLifecycle.updatedDate,
277
294
  updatedDateIso: registrarLifecycle.updatedDateIso,
278
295
  dnssec: registrar.dnssec,
279
- statuses: registrar.statuses,
280
- nameservers: registrar.nameservers,
296
+ statuses: Array.isArray(registrar.statuses) ? registrar.statuses.slice(0, 100) : [],
297
+ nameservers: Array.isArray(registrar.nameservers) ? registrar.nameservers.slice(0, 200) : [],
281
298
  }, {
282
299
  rdapStatus: options.registryStatus,
283
300
  whoisStatus: options.registrarStatus,