@slicedearth/whoisleuth-cli 1.34.0 → 1.39.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 (131) hide show
  1. package/README.md +19 -2
  2. package/bin/whoisleuth.mjs +41 -24
  3. package/cli/arguments.mjs +326 -3
  4. package/cli/artifact-verify.mjs +60 -14
  5. package/cli/bulk.mjs +1 -2
  6. package/cli/compare.mjs +1 -1
  7. package/cli/completion.mjs +36 -2
  8. package/cli/doctor.mjs +11 -5
  9. package/cli/evidence-command-arguments.mjs +1 -1
  10. package/cli/evidence-command-runner.mjs +2 -2
  11. package/cli/evidence-signing.mjs +4 -4
  12. package/cli/export-evidence.mjs +9 -2
  13. package/cli/formatters/html.mjs +1 -1
  14. package/cli/formatters/json.mjs +13 -1
  15. package/cli/formatters/terminal.mjs +14 -4
  16. package/cli/investigation-plan.mjs +112 -0
  17. package/cli/local-mmdb-review.mjs +76 -0
  18. package/cli/lookalike-calibration.mjs +121 -0
  19. package/cli/lookup-command-runner.mjs +4 -1
  20. package/cli/lookup-plan.mjs +3 -3
  21. package/cli/lookup-reconcile.mjs +141 -0
  22. package/cli/lookup-timeline.mjs +1 -1
  23. package/cli/mail-review.mjs +48 -3
  24. package/cli/offline-evidence-review.mjs +159 -0
  25. package/cli/registry-doctor.mjs +125 -0
  26. package/cli/registry-support.mjs +1 -4
  27. package/cli/risk-calibration.mjs +117 -9
  28. package/cli/runner.mjs +342 -15
  29. package/cli/saved-lookup.mjs +2 -2
  30. package/cli/sharing-review.mjs +202 -0
  31. package/cli/source-reliability.mjs +1 -1
  32. package/docs/cli-reference.md +1110 -0
  33. package/docs/cli.md +224 -847
  34. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  35. package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
  36. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  37. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  38. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  39. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  40. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  41. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  42. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  43. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  44. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  45. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  46. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  47. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  48. package/frontend/src/lib/analysis/http-summary.js +2 -1
  49. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  50. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  51. package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
  52. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  53. package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
  54. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  55. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  56. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  57. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  58. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  59. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  60. package/frontend/src/lib/analysis/scoring.js +8 -88
  61. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  62. package/frontend/src/lib/analysis/utils.js +7 -40
  63. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  64. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  65. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  66. package/frontend/src/lib/analysis/visualization-models.js +370 -0
  67. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  68. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  69. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  70. package/frontend/src/lib/analysis/workspace-archive.js +2 -2
  71. package/lib/analyst-taxonomy.mjs +40 -0
  72. package/lib/availability.mjs +30 -10
  73. package/lib/browser-library-profile.mjs +15 -2
  74. package/lib/bulk-limits.mjs +13 -0
  75. package/lib/certificate-extension-profile.mjs +71 -0
  76. package/lib/credential-surface-profile.mjs +1 -1
  77. package/lib/ct-response-bounds.mjs +9 -0
  78. package/lib/ct-search.mjs +66 -12
  79. package/lib/dns-delegation-health.mjs +56 -5
  80. package/lib/dnssec-evidence-validation.mjs +235 -0
  81. package/lib/domain-assurance.mjs +368 -0
  82. package/lib/domain-control-manifest.mjs +382 -0
  83. package/lib/domain-posture.mjs +11 -10
  84. package/lib/encrypted-dns-contract.mjs +81 -0
  85. package/lib/evidence-export.mjs +17 -3
  86. package/lib/evidence-report-markdown.mjs +1 -1
  87. package/lib/evidence-report.mjs +4 -1
  88. package/lib/favicon.mjs +3 -8
  89. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  90. package/lib/html-signals.mjs +4 -0
  91. package/lib/http-evidence-bounds.mjs +4 -0
  92. package/lib/http-intelligence.mjs +2 -1
  93. package/lib/ip-prefix.mjs +87 -0
  94. package/lib/local-geoip-evidence.mjs +89 -0
  95. package/lib/lookup-response-contract.mjs +346 -0
  96. package/lib/observed-network-context.mjs +1 -1
  97. package/lib/opportunity-scoring.mjs +107 -0
  98. package/lib/outbound-identity.mjs +9 -0
  99. package/lib/outbound-request-bounds.mjs +9 -0
  100. package/lib/page-fingerprints.mjs +1 -1
  101. package/lib/perceptual-hash-comparison.mjs +34 -0
  102. package/lib/perceptual-hash.mjs +3 -31
  103. package/lib/project-metadata.mjs +8 -0
  104. package/lib/provider-policy-admission.mjs +49 -0
  105. package/lib/rdap-capabilities.mjs +6 -6
  106. package/lib/rdap-search-workbench.mjs +247 -0
  107. package/lib/rdap.mjs +7 -2
  108. package/lib/registry-access.mjs +1 -1
  109. package/lib/registry-capabilities.mjs +31 -1
  110. package/lib/registry-capability-catalogue.mjs +15 -14
  111. package/lib/registry-insights.mjs +6 -6
  112. package/lib/response-policy.mjs +81 -6
  113. package/lib/risk-scoring.mjs +206 -83
  114. package/lib/rpki-evidence.mjs +105 -0
  115. package/lib/safe-fetch.mjs +2 -1
  116. package/lib/scoring-evidence-quality.mjs +107 -0
  117. package/lib/security-txt.mjs +3 -3
  118. package/lib/semantic-version.mjs +54 -0
  119. package/lib/static-html-analysis.mjs +27 -0
  120. package/lib/threat-intelligence-manifests.mjs +1 -1
  121. package/lib/threatfox-intelligence.mjs +11 -6
  122. package/lib/tls-intelligence.mjs +13 -3
  123. package/lib/tlsa-evidence.mjs +194 -0
  124. package/lib/urlhaus-intelligence.mjs +11 -6
  125. package/lib/urlscan-intelligence.mjs +10 -5
  126. package/lib/website-security-posture.mjs +16 -9
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-chain.mjs +14 -152
  129. package/lib/whois.mjs +2 -2
  130. package/package.json +6 -2
  131. package/third-party-notices.txt +911 -19
@@ -0,0 +1,9 @@
1
+ import packageDocument from '../package.json' with { type: 'json' };
2
+ import { WHOISLEUTH_PROJECT_URL, WHOISLEUTH_REQUEST_POLICY_URL, } from "./project-metadata.mjs";
3
+ import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
4
+ export { WHOISLEUTH_PROJECT_URL, WHOISLEUTH_REQUEST_POLICY_URL };
5
+ export const WHOISLEUTH_USER_AGENT_VERSION = normalizeBoundedSemanticVersion(packageDocument.version, 'Application manifest');
6
+ export const WHOISLEUTH_USER_AGENT = `WHOISleuth/${WHOISLEUTH_USER_AGENT_VERSION} (+${WHOISLEUTH_REQUEST_POLICY_URL})`;
7
+ export function whoisleuthRequestHeaders(headers = {}) {
8
+ return { ...headers, 'User-Agent': WHOISLEUTH_USER_AGENT };
9
+ }
@@ -0,0 +1,9 @@
1
+ // Shared declarative limits for direct website observation. Runtime collectors
2
+ // and the public request-policy page import these values so operator-facing
3
+ // disclosures cannot drift from the enforced request boundaries.
4
+ export const MAX_OUTBOUND_REDIRECTS = 5;
5
+ export const HOMEPAGE_FETCH_TIMEOUT_MS = 6_000;
6
+ export const MAX_HOMEPAGE_BYTES = 300_000;
7
+ export const FAVICON_FETCH_TIMEOUT_MS = 5_000;
8
+ export const MAX_FAVICON_BYTES = 200_000;
9
+ export const MAX_FAVICON_CANDIDATES = 4;
@@ -430,7 +430,7 @@ function createPageFingerprints(html, options = {}) {
430
430
  || parsedStructure.structureLimitReached || text?.truncated === true || forms?.truncated === true
431
431
  || resourceHosts.truncated || identifiers.truncated;
432
432
  const limitations = [
433
- 'Fingerprints summarize capped static HTML and are comparison aids, not cryptographic proof of page authorship or intent.',
433
+ 'Fingerprints summarise capped static HTML and are comparison aids, not cryptographic proof of page authorship or intent.',
434
434
  'Visible-text SimHash is a fuzzy similarity fingerprint and must not be treated as a cryptographic digest.',
435
435
  'DOM-structure SimHash is derived from a standards-compliant static token stream and is a review aid, not proof that two pages share an author or template.',
436
436
  ];
@@ -0,0 +1,34 @@
1
+ // Browser-safe comparison contract for the 64-bit perceptual hashes produced
2
+ // by the bounded image decoder. Keeping comparison and informativeness here
3
+ // prevents Node and browser consumers from drifting into different matches.
4
+ export const PERCEPTUAL_HASH_HEX_RE = /^[0-9a-f]{16}$/u;
5
+ export const MIN_INFORMATIVE_HASH_BITS = 10;
6
+ const POPCOUNT = new Uint8Array(16);
7
+ for (let index = 0; index < POPCOUNT.length; index += 1) {
8
+ POPCOUNT[index] = (index & 1) + ((index >> 1) & 1) + ((index >> 2) & 1) + ((index >> 3) & 1);
9
+ }
10
+ export function isPerceptualHash(value) {
11
+ return typeof value === 'string' && PERCEPTUAL_HASH_HEX_RE.test(value);
12
+ }
13
+ function popcountHex(hex) {
14
+ let bits = 0;
15
+ for (let index = 0; index < hex.length; index += 1) {
16
+ bits += POPCOUNT[parseInt(hex.charAt(index), 16)] ?? 0;
17
+ }
18
+ return bits;
19
+ }
20
+ export function isInformativePerceptualHash(value) {
21
+ if (!isPerceptualHash(value))
22
+ return false;
23
+ const bits = popcountHex(value);
24
+ return bits >= MIN_INFORMATIVE_HASH_BITS && bits <= 64 - MIN_INFORMATIVE_HASH_BITS;
25
+ }
26
+ export function hammingDistanceHex(left, right) {
27
+ if (!isPerceptualHash(left) || !isPerceptualHash(right))
28
+ return null;
29
+ let distance = 0;
30
+ for (let index = 0; index < 16; index += 1) {
31
+ distance += POPCOUNT[parseInt(left.charAt(index), 16) ^ parseInt(right.charAt(index), 16)] ?? 0;
32
+ }
33
+ return distance;
34
+ }
@@ -21,6 +21,7 @@
21
21
  // case, while staying cheap (no DCT). Output is 16 lowercase hex chars;
22
22
  // similarity is the Hamming distance between two such strings.
23
23
  import * as zlib from 'node:zlib';
24
+ import { hammingDistanceHex, isInformativePerceptualHash, } from "./perceptual-hash-comparison.mjs";
24
25
  // Bound the decode work on attacker-controlled image bytes. Real favicons top
25
26
  // out around 256x256; anything claiming to be larger is refused rather than
26
27
  // decoded (it would also never be a legitimate favicon to compare against).
@@ -371,21 +372,11 @@ function grayscaleGrid(image, cols, rows) {
371
372
  // (callers fall back to the exact hash) rather than a collision-prone one.
372
373
  // isInformativeHash re-checks this at comparison time too, since a Brand
373
374
  // Profile saved before this guard existed may already hold a degenerate hash.
374
- const MIN_INFORMATIVE_BITS = 10;
375
- function popcountHex(hex) {
376
- let bits = 0;
377
- for (let i = 0; i < hex.length; i += 1)
378
- bits += POPCOUNT[parseInt(hex.charAt(i), 16)] ?? 0;
379
- return bits;
380
- }
381
375
  function isInformativeHash(hex) {
382
- if (typeof hex !== 'string' || !HEX_HASH_RE.test(hex))
383
- return false;
384
- const bits = popcountHex(hex);
385
- return bits >= MIN_INFORMATIVE_BITS && bits <= 64 - MIN_INFORMATIVE_BITS;
376
+ return isInformativePerceptualHash(hex);
386
377
  }
387
378
  // 64-bit dHash over a 9x8 grid -> 16 hex chars, or null when the resulting
388
- // hash is degenerate (see MIN_INFORMATIVE_BITS). Each row contributes 8 bits:
379
+ // hash is degenerate (see the shared comparison contract). Each row contributes 8 bits:
389
380
  // bit set when a cell is brighter than its right-hand neighbour.
390
381
  function dHash(image) {
391
382
  const cols = 9;
@@ -429,23 +420,4 @@ function faviconPerceptualHash(buf) {
429
420
  // produced by the optional local capture package. Keep the generic name as an
430
421
  // alias so the evidence contract does not imply that a screenshot is a favicon.
431
422
  const imagePerceptualHash = faviconPerceptualHash;
432
- const HEX_HASH_RE = /^[0-9a-f]{16}$/;
433
- const POPCOUNT = new Uint8Array(16);
434
- for (let i = 0; i < 16; i += 1)
435
- POPCOUNT[i] = (i & 1) + ((i >> 1) & 1) + ((i >> 2) & 1) + ((i >> 3) & 1);
436
- /**
437
- * Hamming distance (0-64) between two 16-hex dHash strings, or null if either
438
- * isn't a well-formed hash. Smaller = more visually similar.
439
- */
440
- function hammingDistanceHex(a, b) {
441
- if (typeof a !== 'string' || typeof b !== 'string')
442
- return null;
443
- if (!HEX_HASH_RE.test(a) || !HEX_HASH_RE.test(b))
444
- return null;
445
- let distance = 0;
446
- for (let i = 0; i < 16; i += 1) {
447
- distance += POPCOUNT[parseInt(a.charAt(i), 16) ^ parseInt(b.charAt(i), 16)] ?? 0;
448
- }
449
- return distance;
450
- }
451
423
  export { faviconPerceptualHash, hammingDistanceHex, imagePerceptualHash };
@@ -0,0 +1,8 @@
1
+ // Browser-safe public project metadata shared by request identity, package
2
+ // verification, canonical URLs, structured data, and public policy pages.
3
+ export const WHOISLEUTH_SITE_ORIGIN = 'https://whoisleuth.com';
4
+ export const WHOISLEUTH_PROJECT_URL = `${WHOISLEUTH_SITE_ORIGIN}/`;
5
+ export const WHOISLEUTH_REQUEST_POLICY_URL = `${WHOISLEUTH_SITE_ORIGIN}/request-policy`;
6
+ export const WHOISLEUTH_SOURCE_REPOSITORY_URL = 'https://github.com/slicedearth/whoisleuth';
7
+ export const WHOISLEUTH_SOURCE_REPOSITORY_GIT_URL = `git+${WHOISLEUTH_SOURCE_REPOSITORY_URL}.git`;
8
+ export const WHOISLEUTH_SOURCE_ISSUES_URL = `${WHOISLEUTH_SOURCE_REPOSITORY_URL}/issues`;
@@ -0,0 +1,49 @@
1
+ export const PROVIDER_POLICY_MAX_REVIEW_AGE_DAYS = 180;
2
+ export const DEPLOYMENT_PURPOSES = ['personal', 'internal', 'commercial'];
3
+ function deploymentPurpose(value) {
4
+ const normalized = String(value ?? '').trim().toLowerCase();
5
+ return DEPLOYMENT_PURPOSES.includes(normalized)
6
+ ? normalized
7
+ : null;
8
+ }
9
+ export function providerPolicyAdmission(terms, env = process.env, now = Date.now()) {
10
+ const source = env && typeof env === 'object' ? env : {};
11
+ 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;
16
+ if (!purpose) {
17
+ return {
18
+ allowed: false,
19
+ purpose: null,
20
+ reviewAgeDays,
21
+ reason: 'Optional intelligence requires WHOISLEUTH_DEPLOYMENT_PURPOSE=personal, internal, or commercial.',
22
+ };
23
+ }
24
+ if (reviewAgeDays === null || reviewAgeDays > PROVIDER_POLICY_MAX_REVIEW_AGE_DAYS) {
25
+ return {
26
+ allowed: false,
27
+ purpose,
28
+ reviewAgeDays,
29
+ reason: `Optional intelligence provider policy review is older than ${PROVIDER_POLICY_MAX_REVIEW_AGE_DAYS} days or invalid.`,
30
+ };
31
+ }
32
+ if (terms.commercialUse === 'unknown') {
33
+ return {
34
+ allowed: false,
35
+ purpose,
36
+ reviewAgeDays,
37
+ reason: 'Optional intelligence commercial-use permission is unknown.',
38
+ };
39
+ }
40
+ if (terms.commercialUse === 'restricted' && purpose !== 'personal') {
41
+ return {
42
+ allowed: false,
43
+ purpose,
44
+ reviewAgeDays,
45
+ reason: `Optional intelligence is classified as restricted for the declared ${purpose} deployment purpose.`,
46
+ };
47
+ }
48
+ return { allowed: true, purpose, reviewAgeDays, reason: null };
49
+ }
@@ -44,7 +44,7 @@ const EXTENSION_CATALOG = Object.freeze({
44
44
  farv1: {
45
45
  label: 'Federated authentication',
46
46
  category: 'standard',
47
- capability: 'Federated authorization profile declaration.',
47
+ capability: 'Federated authorisation profile declaration.',
48
48
  },
49
49
  fred: {
50
50
  label: 'Registry-system extension',
@@ -111,7 +111,7 @@ const EXTENSION_CATALOG = Object.freeze({
111
111
  paging: {
112
112
  label: 'Result paging',
113
113
  category: 'standard',
114
- capability: 'Paged search-result behavior.',
114
+ capability: 'Paged search-result behaviour.',
115
115
  },
116
116
  platformns: {
117
117
  label: 'Nameserver platform extension',
@@ -146,17 +146,17 @@ const EXTENSION_CATALOG = Object.freeze({
146
146
  rirsearch1: {
147
147
  label: 'Address-registry search',
148
148
  category: 'standard',
149
- capability: 'Versioned network and autonomous-system search behavior.',
149
+ capability: 'Versioned network and autonomous-system search behaviour.',
150
150
  },
151
151
  sorting: {
152
152
  label: 'Result sorting',
153
153
  category: 'standard',
154
- capability: 'Sorted search-result behavior.',
154
+ capability: 'Sorted search-result behaviour.',
155
155
  },
156
156
  subsetting: {
157
157
  label: 'Partial response',
158
158
  category: 'standard',
159
- capability: 'Field-subset response behavior.',
159
+ capability: 'Field-subset response behaviour.',
160
160
  },
161
161
  ttl0: {
162
162
  label: 'DNS TTL extension',
@@ -270,7 +270,7 @@ export function inspectRdapCapabilities(parsedValue, status) {
270
270
  .map((entry) => entry.identifier)
271
271
  .slice(0, MAX_RDAP_UNKNOWN_CAPABILITIES);
272
272
  const limitations = [
273
- 'Declarations describe what the server claims for this response; they do not prove that an operation is authorized, implemented correctly, complete, or available anonymously.',
273
+ 'Declarations describe what the server claims for this response; they do not prove that an operation is authorised, implemented correctly, complete, or available anonymously.',
274
274
  'Unknown identifiers remain visible and neutral. Catalogue recognition never affects availability or Risk scoring.',
275
275
  ];
276
276
  if (state === 'unavailable') {
@@ -0,0 +1,247 @@
1
+ const RDAP_SEARCH_WORKBENCH_SCHEMA = 'whoisleuth.rdap-search-workbench';
2
+ const RDAP_SEARCH_WORKBENCH_VERSION = 1;
3
+ const MAX_HELP_ENTRIES = 64;
4
+ const MAX_QUERY_VALUE_LENGTH = 254;
5
+ const SEARCHABLE_RESOURCE_TYPES = new Set(['domains', 'nameservers', 'entities']);
6
+ const RELATED_RESOURCE_TYPES = new Set(['entity']);
7
+ const RECOGNIZED_PROPERTIES = new Set(['handle', 'fn', 'email', 'role']);
8
+ const SENSITIVE_PROPERTIES = new Set(['fn', 'email']);
9
+ const REGISTERED_PROPERTY_PATHS = Object.freeze({
10
+ email: Object.freeze(["$.entities[*].vcardArray[1][?(@[0]=='email')][3]"]),
11
+ fn: Object.freeze(["$.entities[*].vcardArray[1][?(@[0]=='fn')][3]"]),
12
+ handle: Object.freeze(['$.entities[*].handle']),
13
+ role: Object.freeze(['$.entities[*].roles']),
14
+ });
15
+ function record(value) {
16
+ return value && typeof value === 'object' && !Array.isArray(value)
17
+ ? value
18
+ : null;
19
+ }
20
+ function boundedToken(value) {
21
+ if (typeof value !== 'string')
22
+ return null;
23
+ const normalized = value.trim().toLowerCase();
24
+ if (!normalized || normalized.length > 48 || !/^[a-z][a-z0-9_-]*$/u.test(normalized))
25
+ return null;
26
+ return normalized;
27
+ }
28
+ function declarationKey(value) {
29
+ return `${value.searchableResourceType}:${value.relatedResourceType}:${value.property}`;
30
+ }
31
+ function emptySummary(state, limitation, rejectedCount = 0) {
32
+ return Object.freeze({
33
+ schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
34
+ version: RDAP_SEARCH_WORKBENCH_VERSION,
35
+ state,
36
+ capabilities: Object.freeze([]),
37
+ rejectedCount,
38
+ truncated: false,
39
+ limitations: Object.freeze([limitation]),
40
+ });
41
+ }
42
+ function normalizeRdapSearchHelp(value) {
43
+ const document = record(value);
44
+ if (!document)
45
+ return emptySummary('invalid', 'The RDAP help response was not an object.');
46
+ const raw = document.reverse_search_properties;
47
+ if (raw === undefined) {
48
+ return emptySummary('unsupported', 'The help response did not advertise reverse-search properties.');
49
+ }
50
+ if (!Array.isArray(raw)) {
51
+ return emptySummary('invalid', 'The advertised reverse-search properties were malformed.', 1);
52
+ }
53
+ const capabilities = [];
54
+ const seen = new Set();
55
+ let rejectedCount = 0;
56
+ const truncated = raw.length > MAX_HELP_ENTRIES;
57
+ for (const entry of raw.slice(0, MAX_HELP_ENTRIES)) {
58
+ const candidate = record(entry);
59
+ const searchableResourceType = boundedToken(candidate?.searchableResourceType);
60
+ const relatedResourceType = boundedToken(candidate?.relatedResourceType);
61
+ const property = boundedToken(candidate?.property);
62
+ if (!searchableResourceType || !relatedResourceType || !property) {
63
+ rejectedCount += 1;
64
+ continue;
65
+ }
66
+ const declaration = { searchableResourceType, relatedResourceType, property };
67
+ const key = declarationKey(declaration);
68
+ if (seen.has(key))
69
+ continue;
70
+ seen.add(key);
71
+ const supported = SEARCHABLE_RESOURCE_TYPES.has(searchableResourceType)
72
+ && RELATED_RESOURCE_TYPES.has(relatedResourceType)
73
+ && RECOGNIZED_PROPERTIES.has(property);
74
+ capabilities.push(Object.freeze({
75
+ ...declaration,
76
+ state: supported ? 'supported' : 'unsupported',
77
+ disclosure: SENSITIVE_PROPERTIES.has(property) ? 'contact' : 'identifier',
78
+ limitation: supported ? null : 'The server advertised a tuple that this release does not construct.',
79
+ }));
80
+ }
81
+ if (truncated)
82
+ rejectedCount += raw.length - MAX_HELP_ENTRIES;
83
+ const supportedCount = capabilities.filter((entry) => entry.state === 'supported').length;
84
+ const limitations = [];
85
+ if (truncated)
86
+ limitations.push(`Only the first ${MAX_HELP_ENTRIES} advertised properties were reviewed.`);
87
+ if (rejectedCount > 0)
88
+ limitations.push(`${rejectedCount} malformed or excess properties were not used.`);
89
+ if (capabilities.some((entry) => entry.state === 'unsupported')) {
90
+ limitations.push('Unrecognized advertised tuples remain visible but cannot be planned by this release.');
91
+ }
92
+ if (supportedCount === 0)
93
+ limitations.push('No supported reverse-search tuple was advertised.');
94
+ return Object.freeze({
95
+ schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
96
+ version: RDAP_SEARCH_WORKBENCH_VERSION,
97
+ state: supportedCount === 0 ? 'unsupported' : limitations.length > 0 ? 'partial' : 'supported',
98
+ capabilities: Object.freeze(capabilities),
99
+ rejectedCount,
100
+ truncated,
101
+ limitations: Object.freeze(limitations),
102
+ });
103
+ }
104
+ function normalizeQueryValue(value) {
105
+ if (typeof value !== 'string')
106
+ return null;
107
+ const normalized = value.trim();
108
+ if (!normalized || normalized.length > MAX_QUERY_VALUE_LENGTH)
109
+ return null;
110
+ if (/[\x00-\x1f\x7f]/u.test(normalized))
111
+ return null;
112
+ return normalized;
113
+ }
114
+ function planRdapReverseSearch(help, requested) {
115
+ const searchableResourceType = boundedToken(requested.searchableResourceType);
116
+ const relatedResourceType = boundedToken(requested.relatedResourceType);
117
+ const property = boundedToken(requested.property);
118
+ const value = normalizeQueryValue(requested.value);
119
+ if (!searchableResourceType || !relatedResourceType || !property || !value) {
120
+ return Object.freeze({
121
+ schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
122
+ version: RDAP_SEARCH_WORKBENCH_VERSION,
123
+ state: 'invalid',
124
+ requestPath: null,
125
+ query: null,
126
+ disclosure: null,
127
+ limitation: 'The requested resource types, property, or value were invalid or outside the supported bounds.',
128
+ });
129
+ }
130
+ const capability = help.capabilities.find((entry) => (entry.state === 'supported'
131
+ && entry.searchableResourceType === searchableResourceType
132
+ && entry.relatedResourceType === relatedResourceType
133
+ && entry.property === property));
134
+ if (!capability) {
135
+ return Object.freeze({
136
+ schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
137
+ version: RDAP_SEARCH_WORKBENCH_VERSION,
138
+ state: 'unsupported',
139
+ requestPath: null,
140
+ query: null,
141
+ disclosure: null,
142
+ limitation: 'The supplied help response did not advertise this exact reverse-search tuple.',
143
+ });
144
+ }
145
+ const contactDisclosure = capability.disclosure === 'contact';
146
+ return Object.freeze({
147
+ schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
148
+ version: RDAP_SEARCH_WORKBENCH_VERSION,
149
+ state: 'ready',
150
+ requestPath: `/${searchableResourceType}/reverse_search/${relatedResourceType}`,
151
+ query: Object.freeze({ [property]: value }),
152
+ disclosure: Object.freeze({
153
+ class: capability.disclosure,
154
+ summary: contactDisclosure
155
+ ? `Would disclose the supplied ${property} contact value to the selected RDAP server.`
156
+ : `Would disclose the supplied ${property} identifier to the selected RDAP server.`,
157
+ requiresApproval: true,
158
+ }),
159
+ limitation: 'This is an offline request plan. It neither sends the query nor establishes result authority, completeness, or currentness.',
160
+ });
161
+ }
162
+ function inspectRdapReverseSearchResponse(value, requestedProperties = []) {
163
+ const document = record(value);
164
+ if (!document) {
165
+ return Object.freeze({
166
+ schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
167
+ version: RDAP_SEARCH_WORKBENCH_VERSION,
168
+ state: 'invalid',
169
+ mappings: Object.freeze([]),
170
+ missingProperties: Object.freeze([]),
171
+ rejectedCount: 0,
172
+ truncated: false,
173
+ limitations: Object.freeze(['The reverse-search response was not an object.']),
174
+ });
175
+ }
176
+ const requested = [...new Set(requestedProperties
177
+ .map(boundedToken)
178
+ .filter((property) => Boolean(property)))]
179
+ .slice(0, RECOGNIZED_PROPERTIES.size);
180
+ const raw = document.reverse_search_properties_mapping;
181
+ if (!Array.isArray(raw)) {
182
+ return Object.freeze({
183
+ schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
184
+ version: RDAP_SEARCH_WORKBENCH_VERSION,
185
+ state: 'invalid',
186
+ mappings: Object.freeze([]),
187
+ missingProperties: Object.freeze(requested),
188
+ rejectedCount: raw === undefined ? 0 : 1,
189
+ truncated: false,
190
+ limitations: Object.freeze([
191
+ 'The response did not provide a valid reverse-search property mapping, so its result fields cannot be reconciled with the requested predicates.',
192
+ ]),
193
+ });
194
+ }
195
+ const mappings = [];
196
+ const seen = new Set();
197
+ let rejectedCount = 0;
198
+ const truncated = raw.length > MAX_HELP_ENTRIES;
199
+ for (const entry of raw.slice(0, MAX_HELP_ENTRIES)) {
200
+ const candidate = record(entry);
201
+ const property = boundedToken(candidate?.property);
202
+ const propertyPath = typeof candidate?.propertyPath === 'string'
203
+ ? candidate.propertyPath.trim().slice(0, 512)
204
+ : '';
205
+ if (!property || !propertyPath || /[\x00-\x1f\x7f]/u.test(propertyPath)) {
206
+ rejectedCount += 1;
207
+ continue;
208
+ }
209
+ const key = `${property}\u0000${propertyPath}`;
210
+ if (seen.has(key))
211
+ continue;
212
+ seen.add(key);
213
+ mappings.push(Object.freeze({
214
+ property,
215
+ propertyPath,
216
+ state: REGISTERED_PROPERTY_PATHS[property]?.includes(propertyPath)
217
+ ? 'registered'
218
+ : 'unrecognized',
219
+ }));
220
+ }
221
+ if (truncated)
222
+ rejectedCount += raw.length - MAX_HELP_ENTRIES;
223
+ const mappedProperties = new Set(mappings.map((mapping) => mapping.property));
224
+ const missingProperties = requested.filter((property) => !mappedProperties.has(property));
225
+ const limitations = [];
226
+ if (missingProperties.length) {
227
+ limitations.push(`The response omitted mappings for requested properties: ${missingProperties.join(', ')}.`);
228
+ }
229
+ if (mappings.some((mapping) => mapping.state === 'unrecognized')) {
230
+ limitations.push('At least one mapping differs from the locally reviewed IANA registration and requires manual specification review.');
231
+ }
232
+ if (rejectedCount)
233
+ limitations.push(`${rejectedCount} malformed or excess mapping entr${rejectedCount === 1 ? 'y was' : 'ies were'} not used.`);
234
+ if (!mappings.length)
235
+ limitations.push('No usable reverse-search property mapping was retained.');
236
+ return Object.freeze({
237
+ schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
238
+ version: RDAP_SEARCH_WORKBENCH_VERSION,
239
+ state: mappings.length && !limitations.length ? 'complete' : mappings.length ? 'partial' : 'invalid',
240
+ mappings: Object.freeze(mappings),
241
+ missingProperties: Object.freeze(missingProperties),
242
+ rejectedCount,
243
+ truncated,
244
+ limitations: Object.freeze(limitations),
245
+ });
246
+ }
247
+ export { MAX_HELP_ENTRIES, MAX_QUERY_VALUE_LENGTH, RDAP_SEARCH_WORKBENCH_SCHEMA, RDAP_SEARCH_WORKBENCH_VERSION, normalizeRdapSearchHelp, planRdapReverseSearch, inspectRdapReverseSearchResponse, };
package/lib/rdap.mjs CHANGED
@@ -4,11 +4,16 @@ import { fetchRdapFromBasesWithParser, fetchRdapRecordWithParser, } from "./rdap
4
4
  import { normalizeRdapEvents, parseRdap, summarizeLifecycle, } from "./rdap-normalization.mjs";
5
5
  import { fetchRegistrarRdapRecordWithParser, selectRegistrarRdapLink, } from "./rdap-registrar.mjs";
6
6
  import { fetchRdapWithTimeout, } from "./rdap-transport.mjs";
7
+ import { registryServiceAdmissionFor } from "./registry-capabilities.mjs";
7
8
  async function fetchRdapFromBases(type, value, bases, fetchUpstream = fetchRdapWithTimeout) {
8
9
  return fetchRdapFromBasesWithParser(type, value, bases, parseRdap, fetchUpstream);
9
10
  }
10
- async function fetchRdapRecord(type, value) {
11
- return fetchRdapRecordWithParser(type, value, parseRdap);
11
+ async function fetchRdapRecord(type, value, options = {}) {
12
+ if (type === 'domain' && registryServiceAdmissionFor(value, 'rdap')?.allowed === false) {
13
+ return null;
14
+ }
15
+ const fetchRecord = options.fetchRecord ?? fetchRdapRecordWithParser;
16
+ return fetchRecord(type, value, parseRdap);
12
17
  }
13
18
  async function fetchRegistrarRdapRecord(domain, registryRecord, options = {}) {
14
19
  return fetchRegistrarRdapRecordWithParser(domain, registryRecord, parseRdap, options);
@@ -3,7 +3,7 @@ const REGISTRY_ACCESS_PROFILE_LABELS = Object.freeze({
3
3
  'iana-referral': 'IANA referral discovery',
4
4
  'no-iana-service': 'No service published by IANA',
5
5
  'registry-policy-restricted': 'Registry policy restricted',
6
- 'source-ip-authorization-required': 'Source-IP authorization required',
6
+ 'source-ip-authorization-required': 'Source-IP authorisation required',
7
7
  });
8
8
  const WHOIS_REGISTRY_ACCESS_PROFILES = new Set([
9
9
  'iana-referral',
@@ -90,6 +90,36 @@ function registryAccessDiagnosticFor(value) {
90
90
  authority: 'context_only',
91
91
  };
92
92
  }
93
+ function registryServiceAdmissionFor(value, service) {
94
+ const capability = registryCapabilityFor(value);
95
+ if (!capability || (service !== 'rdap' && service !== 'whois'))
96
+ return null;
97
+ if (service === 'rdap') {
98
+ const allowed = capability.rdapAccessProfile === 'iana-bootstrap';
99
+ return {
100
+ service,
101
+ state: allowed ? 'allowed' : 'unsupported',
102
+ allowed,
103
+ limitation: capability.limitation,
104
+ };
105
+ }
106
+ if (capability.whoisAccessProfile === 'iana-referral') {
107
+ return {
108
+ service,
109
+ state: 'allowed',
110
+ allowed: true,
111
+ limitation: capability.limitation,
112
+ };
113
+ }
114
+ const permissionRequired = capability.whoisAccessProfile === 'registry-policy-restricted'
115
+ || capability.whoisAccessProfile === 'source-ip-authorization-required';
116
+ return {
117
+ service,
118
+ state: permissionRequired ? 'permission_required' : 'unsupported',
119
+ allowed: false,
120
+ limitation: capability.limitation,
121
+ };
122
+ }
93
123
  function listRegistryCapabilities() {
94
124
  return EXPLICIT_CAPABILITIES.map((capability) => cloneCapability(capability));
95
125
  }
@@ -114,4 +144,4 @@ function registryCompatibilityMatrix() {
114
144
  })))
115
145
  .sort((left, right) => (left.suffixes[0] ?? '').localeCompare(right.suffixes[0] ?? ''));
116
146
  }
117
- export { REGISTRY_CAPABILITIES_VERSION, VERSION_26_NO_RDAP_SUFFIXES, VERSION_27_RDAP_ONLY_GENERIC_SUFFIXES, registryCapabilityFor, registryCompatibilityMatrix, listRegistryCapabilities, registryAccessDiagnosticFor, registryStandardsCoverageSnapshot, };
147
+ export { REGISTRY_CAPABILITIES_VERSION, VERSION_26_NO_RDAP_SUFFIXES, VERSION_27_RDAP_ONLY_GENERIC_SUFFIXES, registryCapabilityFor, registryCompatibilityMatrix, listRegistryCapabilities, registryAccessDiagnosticFor, registryServiceAdmissionFor, registryStandardsCoverageSnapshot, };
@@ -1,15 +1,16 @@
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 = 27;
4
+ const REGISTRY_CAPABILITIES_VERSION = 28;
5
5
  const MAX_CAPABILITY_INPUT_LENGTH = 253;
6
6
  const REGISTRY_STANDARDS_COVERAGE_SNAPSHOT = Object.freeze({
7
7
  schema: 'whoisleuth.registry-standards-coverage',
8
8
  version: 1,
9
- verifiedAt: '2026-08-01',
9
+ verifiedAt: '2026-08-03',
10
10
  sources: Object.freeze({
11
- rootZoneVersion: '2026073100',
12
- rootZoneLastUpdatedAt: '2026-07-31T07:07:01.000Z',
11
+ rootZoneVersion: '2026080200',
12
+ rootZoneLastUpdatedAt: '2026-08-02T07:07:01.000Z',
13
+ rootZoneTldSetSha256: 'aa0a75a9860b2cba07d7fe8172f4546d981be3674bf6764fb0d5f39a45940d25',
13
14
  rdapBootstrapPublication: '2026-07-23T02:00:03.000Z',
14
15
  rdapBootstrapVersion: '1.0',
15
16
  urls: Object.freeze([
@@ -50,9 +51,9 @@ const REGISTRY_STANDARDS_COVERAGE_SNAPSHOT = Object.freeze({
50
51
  ]),
51
52
  interpretation: 'This official-source snapshot describes published service coverage at the verification date. It does not test current reachability or decide registration, availability, ownership, safety, or maliciousness.',
52
53
  });
53
- const DISCOVERY_LIMITATION = 'IANA discovery is available, but no suffix-specific query, encoding, or parser behavior is fixture-verified.';
54
+ const DISCOVERY_LIMITATION = 'IANA discovery is available, but no suffix-specific query, encoding, or parser behaviour is fixture-verified.';
54
55
  const FIXTURE_LIMITATION = 'Synthetic fixtures verify the current parser profile; they do not prove current live-registry reachability, policy, or field publication.';
55
- const ES_ACCESS_LIMITATION = 'The registry WHOIS service requires advance source-IP authorization. A failed or unavailable query is not evidence that the domain is unregistered.';
56
+ const ES_ACCESS_LIMITATION = 'The registry WHOIS service requires advance source-IP authorisation. A failed or unavailable query is not evidence that the domain is unregistered.';
56
57
  const SWITCH_ACCESS_LIMITATION = 'The registry directs public domain searches to its official lookup. Its non-standard-port Domain Check is not integrated, and IANA publishes no RDAP service. Missing registry data is not evidence that the domain is unregistered.';
57
58
  const VN_ACCESS_LIMITATION = 'IANA publishes no domain WHOIS or RDAP service for this suffix. The official browser lookup is not integrated, and missing registry data is not evidence that the domain is unregistered.';
58
59
  const UK_TRANSITION_LIMITATION = 'Synthetic fixtures verify the documented sectioned port-43 response while that WHOIS service is phased out. RDAP remains the preferred registry source, and fixture coverage does not prove current reachability or field publication.';
@@ -63,8 +64,8 @@ const SHARED_WHOIS_NO_RDAP_LIMITATION = 'Synthetic fixture reuse is limited to a
63
64
  const WHOIS_ONLY_FIXTURE_LIMITATION = 'Synthetic fixtures verify the current WHOIS parser profile. IANA publishes no RDAP bootstrap service for this suffix, and missing registry data is not evidence that the domain is unregistered.';
64
65
  const NEGATIVE_ONLY_FIXTURE_LIMITATION = 'Synthetic fixtures verify only the authoritative no-record dialect; registered-field compatibility is not claimed, and fixture coverage does not prove current live-registry reachability or policy.';
65
66
  const WHOIS_ONLY_NEGATIVE_FIXTURE_LIMITATION = 'Synthetic fixtures verify only the authoritative WHOIS no-record dialect; registered-field compatibility is not claimed, IANA publishes no RDAP bootstrap service, and missing registry data is not evidence that the domain is unregistered.';
66
- const UNVERIFIED_WHOIS_REFERRAL_LIMITATION = 'IANA publishes a domain WHOIS referral, but its response behavior is not fixture-verified. A failed, restricted, prohibited, reserved, or otherwise inconclusive response is not evidence that the domain is unregistered, and IANA publishes no RDAP bootstrap service for this suffix.';
67
- const UNVERIFIED_WHOIS_WITH_RDAP_LIMITATION = 'IANA publishes domain WHOIS and RDAP discovery, but the WHOIS response behavior is not fixture-verified. A failed, restricted, prohibited, reserved, or otherwise inconclusive WHOIS response is not evidence that the domain is unregistered; RDAP remains separately attributed.';
67
+ const UNVERIFIED_WHOIS_REFERRAL_LIMITATION = 'IANA publishes a domain WHOIS referral, but its response behaviour is not fixture-verified. A failed, restricted, prohibited, reserved, or otherwise inconclusive response is not evidence that the domain is unregistered, and IANA publishes no RDAP bootstrap service for this suffix.';
68
+ const UNVERIFIED_WHOIS_WITH_RDAP_LIMITATION = 'IANA publishes domain WHOIS and RDAP discovery, but the WHOIS response behaviour is not fixture-verified. A failed, restricted, prohibited, reserved, or otherwise inconclusive WHOIS response is not evidence that the domain is unregistered; RDAP remains separately attributed.';
68
69
  const NORID_CLOSED_SUFFIX_LIMITATION = 'The registry has not opened this suffix for registrations, and IANA publishes no domain WHOIS or RDAP service. Missing registry data is contextual only and must not be interpreted as a live availability result.';
69
70
  const MIL_ACCESS_LIMITATION = 'IANA publishes no public domain WHOIS or RDAP service for this sponsored suffix. Missing registry data is contextual only and is not evidence that a name is unregistered or available.';
70
71
  const ARPA_ACCESS_LIMITATION = 'This infrastructure suffix is not ordinary public registration space. IANA publishes WHOIS metadata but no RDAP bootstrap service, and missing or non-domain-shaped data must not be interpreted as registration availability.';
@@ -158,7 +159,6 @@ const DEFAULT_CAPABILITY = freezeCapability({
158
159
  whoisQueryScope: 'first-referral',
159
160
  whoisEncodingProfile: 'utf-8',
160
161
  whoisParserProfile: 'generic-colon',
161
- fallbackProfile: null,
162
162
  whoisAccessProfile: 'iana-referral',
163
163
  rdapAccessProfile: 'iana-bootstrap',
164
164
  coverageState: 'discovery_only',
@@ -661,10 +661,12 @@ const EXPLICIT_CAPABILITY_SEEDS = [
661
661
  ],
662
662
  },
663
663
  {
664
- id: 'gt-registry-web', suffixes: ['gt'], registryClass: 'country-code',
665
- whoisParserProfile: 'generic-colon', fallbackProfile: 'gt-registry-web',
666
- fixtureScenarios: ['registered', 'not_found', 'unavailable'],
667
- verificationFiles: ['test/whois-gt-fallback.test.mts'],
664
+ id: 'gt-no-iana-service', suffixes: ['gt'], registryClass: 'country-code',
665
+ whoisParserProfile: 'generic-colon', fixtureScenarios: [],
666
+ coverageState: 'access_documented', whoisAccessProfile: 'no-iana-service',
667
+ rdapAccessProfile: 'no-iana-service', verificationFiles: [],
668
+ documentationUrls: ['https://www.iana.org/domains/root/db/gt.html'],
669
+ limitation: 'IANA publishes no RDAP service or domain WHOIS service for this suffix. WHOISleuth does not automate the registry website.',
668
670
  },
669
671
  {
670
672
  id: 'carnet-icann-colon', suffixes: ['hr'], registryClass: 'country-code',
@@ -1179,7 +1181,6 @@ const EXPLICIT_CAPABILITIES = EXPLICIT_CAPABILITY_SEEDS.map((entry) => {
1179
1181
  const rdapAccessProfile = resolvedSeedRdapAccessProfile(entry);
1180
1182
  return freezeCapability({
1181
1183
  ...entry,
1182
- fallbackProfile: entry.fallbackProfile || null,
1183
1184
  rdapDiscovery: 'iana-bootstrap',
1184
1185
  whoisDiscovery: 'iana-referral',
1185
1186
  whoisQueryProfile: entry.whoisQueryProfile || 'plain-domain',